Skip to content

Commit b445a38

Browse files
feat(client): SEP-837/2207 — application_type heuristic, grant_types default, https token-endpoint guard
Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ
1 parent 7c38bad commit b445a38

18 files changed

Lines changed: 904 additions & 83 deletions

File tree

‎.changeset/auth-dcr-hygiene.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
'@modelcontextprotocol/core': minor
4+
---
5+
6+
Dynamic Client Registration hygiene for the 2026-07-28 authorization requirements (SEP-837, SEP-2207). New `resolveClientMetadata(provider)` reads `provider.clientMetadata` and applies the spec defaults — `application_type` derived from the redirect URIs (loopback or custom scheme → `'native'`, otherwise `'web'`), `grant_types: ['authorization_code', 'refresh_token']` when omitted — and `auth()` feeds the resolved document to DCR only (scope selection still reads the raw consumer-supplied `clientMetadata` so statically-registered/CIMD clients are not pushed into `offline_access` + `prompt=consent`); consumer-set values are never overwritten. DCR rejection now throws the new `RegistrationRejectedError` carrying the HTTP status, raw body, and submitted metadata — **breaking for direct `registerClient()` callers**: rejection no longer throws `OAuthError`, so update `instanceof` checks. `OAuthClientMetadata` gains a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `OAuthErrorCode` adds `InvalidRedirectUri`. The token-exchange, refresh, and Cross-App Access (`requestJwtAuthorizationGrant` / `exchangeJwtAuthGrant`) paths now throw the new `InsecureTokenEndpointError` for a non-`https:` token endpoint (`localhost` / `127.0.0.1` / `::1` exempt), and `auth()` surfaces it on the refresh branch instead of silently re-authorizing.

‎docs/migration-SKILL.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ Individual OAuth error classes replaced with single `OAuthError` class and `OAut
213213

214214
Removed: `OAUTH_ERRORS` constant.
215215

216+
The OAuth client flow additionally throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry path will not catch them):
217+
218+
| Throw site | v2 class |
219+
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
220+
| `registerClient()` rejected by AS (any RFC 7591 error incl. `invalid_client_metadata`, `invalid_redirect_uri`) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) |
221+
| `exchangeAuthorization()` / `refreshAuthorization()` / `fetchToken()` / `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()` non-https token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) |
222+
| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) |
223+
216224
Update OAuth error handling:
217225

218226
```typescript
@@ -580,6 +588,10 @@ OAuth callback handling: pass the callback URL's `URLSearchParams` to `transport
580588

581589
`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3), throwing `IssuerMismatchError`. Pass `skipIssuerMetadataValidation: true` on `AuthOptions` (or `skipIssuerValidation: true` on the helper) only as a temporary workaround for a known-misconfigured AS.
582590

591+
`auth()` reads `provider.clientMetadata` once via `resolveClientMetadata()` and applies SEP-837/SEP-2207 defaults to the DCR body: `grant_types` defaults to `['authorization_code', 'refresh_token']`; `application_type` is derived from `redirect_uris` (loopback / custom URI scheme → `'native'`, otherwise `'web'`). A field you set explicitly is never overwritten — set `clientMetadata.application_type` / `clientMetadata.grant_types` to override. Direct `registerClient()` callers wanting the same defaults pass `resolveClientMetadata(provider)` as `clientMetadata`. The `grant_types` default applies to the Dynamic Client Registration body only; it does **not** drive the `offline_access` scope / `prompt=consent` augmentation on the authorize request — statically-registered and CIMD clients that want that augmentation must set `clientMetadata.grant_types` explicitly. Non-interactive providers (no `redirectUrl`) get no `grant_types` default.
592+
593+
Token-exchange / refresh now refuse to send credentials to a non-`https:` token endpoint (loopback `localhost` / `127.0.0.1` / `::1` exempt), throwing `InsecureTokenEndpointError` with no opt-out. `auth()` surfaces this on every path including refresh — switch any plain-`http:` AS on a non-loopback host to TLS.
594+
583595
No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed
584596
there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt
585597
into the same routing.

‎docs/migration.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,35 @@ try {
930930
}
931931
```
932932

933+
### Dynamic Client Registration: `application_type` and `grant_types` defaults (SEP-837, SEP-2207)
934+
935+
`OAuthClientMetadata` now has a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `auth()` resolves your provider's `clientMetadata` once via the new `resolveClientMetadata()` and uses the resolved document for the Dynamic Client Registration body (scope selection still reads your raw `clientMetadata`). When `application_type` is unset, it is derived from your `redirect_uris`: a loopback host (`localhost` / `127.0.0.1` / `[::1]`) or a custom URI scheme yields `'native'`; anything else yields `'web'`. Set it explicitly when the heuristic is wrong for your deployment (for example a web app dev-served on `localhost`); a value you set is never overwritten.
936+
937+
`resolveClientMetadata()` also defaults `grant_types` to `['authorization_code', 'refresh_token']` when you omit it, so authorization servers that gate refresh-token issuance on the registered grant types issue one. If you set `grant_types` explicitly, include `'refresh_token'` yourself if you want refresh tokens. CIMD users author the hosted metadata document themselves and should include `refresh_token` there. Direct callers of `registerClient()` that want the same defaults should pass `resolveClientMetadata(provider)` as `clientMetadata`. The `grant_types` default applies to the Dynamic Client Registration body only; it does **not** drive the `offline_access` scope / `prompt=consent` augmentation on the authorize request. Statically-registered and CIMD clients that want that augmentation must set `clientMetadata.grant_types = ['authorization_code', 'refresh_token']` explicitly. Non-interactive providers (no `redirectUrl`) get no `grant_types` default.
938+
939+
DCR rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, and `submittedMetadata`) instead of a generic `OAuthError`. Catch it to inspect the AS's `error` / `error_description` and retry with adjusted metadata, or surface a meaningful error.
940+
941+
```typescript
942+
import { registerClient, RegistrationRejectedError, OAuthErrorCode } from '@modelcontextprotocol/client';
943+
944+
try {
945+
await registerClient(authorizationServerUrl, { metadata, clientMetadata });
946+
} catch (e) {
947+
if (e instanceof RegistrationRejectedError) {
948+
const parsed = JSON.parse(e.body) as { error?: string; error_description?: string };
949+
if (parsed.error === OAuthErrorCode.InvalidRedirectUri) {
950+
// AS rejected redirect_uris — retry with adjusted metadata
951+
}
952+
}
953+
}
954+
```
955+
956+
### Token endpoint must use TLS (SEP-2207)
957+
958+
`exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App Access helpers `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()` now throw `InsecureTokenEndpointError` when the resolved token endpoint is not `https:`. Only `localhost`, `127.0.0.1`, and `::1` are exempt for local development. `auth()` surfaces this error on every path (including the refresh branch) rather than silently re-authorizing. If you were pointing at a plain-`http:` authorization server on a non-loopback host — including cluster-DNS names like `http://oauth.svc.cluster.local` or private addresses like `http://10.0.0.5` — switch it to TLS; there is no opt-out.
959+
960+
**Storage confidentiality remains yours.** `OAuthClientProvider.saveTokens()` receives the raw `refresh_token`; store it in platform-appropriate secure storage. The SDK guarantees transit confidentiality but cannot secure your storage layer.
961+
933962
### Experimental tasks interception removed
934963

935964
The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement:

‎examples/oauth/client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ runClient('oauth', async () => {
9494
redirect_uris: [CALLBACK_URL],
9595
grant_types: ['authorization_code', 'refresh_token'],
9696
response_types: ['code'],
97+
application_type: 'native',
9798
token_endpoint_auth_method: 'client_secret_post'
9899
};
99100
const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => {

‎examples/oauth/simpleOAuthClient.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ class InteractiveOAuthClient {
174174
redirect_uris: [CALLBACK_URL],
175175
grant_types: ['authorization_code', 'refresh_token'],
176176
response_types: ['code'],
177+
application_type: 'native',
177178
token_endpoint_auth_method: 'client_secret_post'
178179
};
179180

‎packages/client/src/client/auth.ts‎

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ import {
2727
} from '@modelcontextprotocol/core';
2828
import pkceChallenge from 'pkce-challenge';
2929

30-
import { IssuerMismatchError } from './authErrors.js';
30+
import { InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors.js';
3131

3232
// Re-exported for back-compat — the canonical home is ./authErrors.js.
33-
export { IssuerMismatchError } from './authErrors.js';
33+
export { InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors.js';
3434

3535
/**
3636
* Function type for adding client authentication to token requests.
@@ -616,6 +616,80 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void
616616
params.set('client_id', clientId);
617617
}
618618

619+
/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */
620+
function isLoopbackHost(hostname: string): boolean {
621+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
622+
}
623+
624+
/**
625+
* SEP-2207: refuse to send credentials to a non-TLS, non-loopback token endpoint.
626+
* Throws {@linkcode InsecureTokenEndpointError}. Loopback hosts are exempt.
627+
*/
628+
export function assertSecureTokenEndpoint(tokenEndpoint: string | URL): URL {
629+
const url = new URL(String(tokenEndpoint));
630+
if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname)) {
631+
throw new InsecureTokenEndpointError(url.href);
632+
}
633+
return url;
634+
}
635+
636+
/**
637+
* Derives an OIDC `application_type` from a client's registered redirect URIs
638+
* when the consumer has not set one explicitly (SEP-837). Loopback hosts and
639+
* non-`http(s)` custom URI schemes indicate a native application (RFC 8252);
640+
* everything else is treated as a web application. The result is a heuristic
641+
* default — callers that know better should set `clientMetadata.application_type`
642+
* themselves, which {@linkcode resolveClientMetadata} never overwrites.
643+
*
644+
* A mixed redirect set (for example a public `https:` URI alongside a loopback
645+
* URI) is inherently ambiguous under OIDC DCR §2 — neither value satisfies the
646+
* AS for both URIs — so consumers with mixed sets should set `application_type`
647+
* explicitly rather than relying on this heuristic.
648+
*/
649+
function deriveApplicationType(redirectUris: readonly string[] | undefined): 'native' | 'web' {
650+
for (const raw of redirectUris ?? []) {
651+
let url: URL;
652+
try {
653+
url = new URL(raw);
654+
} catch {
655+
continue;
656+
}
657+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'native';
658+
if (isLoopbackHost(url.hostname)) return 'native';
659+
}
660+
return 'web';
661+
}
662+
663+
/**
664+
* Reads {@linkcode OAuthClientProvider.clientMetadata | clientMetadata} from the
665+
* provider and fills the SEP-837 / SEP-2207 defaults the SDK relies on, so
666+
* {@linkcode registerClient} sees a consistent, fully-populated document.
667+
*
668+
* - `grant_types` defaults to `['authorization_code', 'refresh_token']` for
669+
* interactive providers (those with a {@linkcode OAuthClientProvider.redirectUrl | redirectUrl})
670+
* so authorization servers that gate refresh-token issuance on the registered
671+
* grant types issue one (SEP-2207). Non-interactive providers (no
672+
* `redirectUrl`) get no `grant_types` default. This default applies to the
673+
* Dynamic Client Registration body only — it does **not** drive
674+
* {@linkcode determineScope}'s `offline_access` augmentation.
675+
* - `application_type` defaults from `redirect_uris`: loopback redirect hosts
676+
* and custom URI schemes → `'native'`, otherwise `'web'` (SEP-837 / RFC 8252).
677+
*
678+
* A field the consumer set explicitly is **never** overwritten. {@linkcode auth}
679+
* calls this once at the top of the flow; direct callers of
680+
* {@linkcode registerClient} that want the same defaults should pass the result
681+
* of this function as `clientMetadata`.
682+
*/
683+
export function resolveClientMetadata(provider: Pick<OAuthClientProvider, 'clientMetadata' | 'redirectUrl'>): OAuthClientMetadata {
684+
const clientMetadata = provider.clientMetadata;
685+
return {
686+
...clientMetadata,
687+
grant_types:
688+
clientMetadata.grant_types ?? (provider.redirectUrl === undefined ? undefined : ['authorization_code', 'refresh_token']),
689+
application_type: clientMetadata.application_type ?? deriveApplicationType(clientMetadata.redirect_uris)
690+
};
691+
}
692+
619693
/**
620694
* Parses an OAuth error response from a string or Response object.
621695
*
@@ -722,8 +796,10 @@ export function determineScope(options: {
722796
// 4. Omit scope parameter
723797
let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
724798

725-
// SEP-2207: Append offline_access when the AS advertises it
726-
// and the client supports the refresh_token grant.
799+
// SEP-2207: Append offline_access when the AS advertises it and the client
800+
// supports the refresh_token grant. Gated on consumer-supplied grant_types;
801+
// SDK DCR default intentionally NOT applied here so statically-registered/CIMD
802+
// clients are not pushed into offline_access + prompt=consent.
727803
if (
728804
effectiveScope &&
729805
authServerMetadata?.scopes_supported?.includes('offline_access') &&
@@ -740,6 +816,10 @@ async function authInternal(
740816
provider: OAuthClientProvider,
741817
{ serverUrl, authorizationCode, iss, scope, resourceMetadataUrl, fetchFn, skipIssuerMetadataValidation }: AuthOptions
742818
): Promise<AuthResult> {
819+
// SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope()
820+
// intentionally reads the raw provider.clientMetadata instead.
821+
const clientMetadata = resolveClientMetadata(provider);
822+
743823
// Check if the provider has cached discovery state to skip discovery
744824
const cachedState = await provider.discoveryState?.();
745825

@@ -866,7 +946,7 @@ async function authInternal(
866946

867947
const fullInformation = await registerClient(authorizationServerUrl, {
868948
metadata,
869-
clientMetadata: provider.clientMetadata,
949+
clientMetadata,
870950
scope: resolvedScope,
871951
fetchFn
872952
});
@@ -923,6 +1003,12 @@ async function authInternal(
9231003
await provider.saveTokens(newTokens);
9241004
return 'AUTHORIZED';
9251005
} catch (error) {
1006+
// A non-TLS token endpoint is a configuration error — re-authorizing cannot
1007+
// fix it. Surface it so the consumer sees the misconfiguration instead of an
1008+
// unexplained re-auth prompt.
1009+
if (error instanceof InsecureTokenEndpointError) {
1010+
throw error;
1011+
}
9261012
// If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
9271013
if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) {
9281014
// Could not refresh OAuth tokens
@@ -1660,7 +1746,7 @@ export async function executeTokenRequest(
16601746
fetchFn?: FetchLike;
16611747
}
16621748
): Promise<OAuthTokens> {
1663-
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
1749+
const tokenUrl = assertSecureTokenEndpoint(metadata?.token_endpoint ?? new URL('/token', authorizationServerUrl));
16641750

16651751
const headers = new Headers({
16661752
'Content-Type': 'application/x-www-form-urlencoded',
@@ -1944,19 +2030,24 @@ export async function registerClient(
19442030
registrationUrl = new URL('/register', authorizationServerUrl);
19452031
}
19462032

2033+
// `clientMetadata` arrives via resolveClientMetadata() inside auth(), so the
2034+
// SEP-837/2207 defaults are already applied. Direct callers that want the
2035+
// same defaults should pass resolveClientMetadata(provider) here.
2036+
const submittedMetadata: OAuthClientMetadata = {
2037+
...clientMetadata,
2038+
...(scope === undefined ? {} : { scope })
2039+
};
2040+
19472041
const response = await (fetchFn ?? fetch)(registrationUrl, {
19482042
method: 'POST',
19492043
headers: {
19502044
'Content-Type': 'application/json'
19512045
},
1952-
body: JSON.stringify({
1953-
...clientMetadata,
1954-
...(scope === undefined ? {} : { scope })
1955-
})
2046+
body: JSON.stringify(submittedMetadata)
19562047
});
19572048

19582049
if (!response.ok) {
1959-
throw await parseErrorResponse(response);
2050+
throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata });
19602051
}
19612052

19622053
return OAuthClientInformationFullSchema.parse(await response.json());

0 commit comments

Comments
 (0)