From 3322a52e65d736356e9588e29d0b447024c94a37 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 21:34:09 +0100 Subject: [PATCH 1/2] feat(client,core): application_type client metadata with native/web inference (SEP-837) - Add optional application_type to OAuthClientMetadataSchema (and thus OAuthClientInformationFullSchema) so user-supplied values survive DCR request bodies and response parsing. - registerClient() infers application_type when absent: 'native' if every redirect URI is loopback (localhost, 127.0.0.1, [::1]) or a custom non-http(s) scheme, otherwise 'web'. Explicit values are never overridden. - New inferApplicationType(redirectUris) export. - JSDoc on OAuthClientProvider.clientMetadata + docs/client.md note. Closes #2198 --- .changeset/sep-837-application-type.md | 12 +++ docs/client.md | 2 + packages/client/src/client/auth.ts | 51 ++++++++++ packages/client/src/index.ts | 1 + packages/client/test/client/auth.test.ts | 123 ++++++++++++++++++++++- packages/core/src/shared/auth.ts | 19 ++++ 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 .changeset/sep-837-application-type.md diff --git a/.changeset/sep-837-application-type.md b/.changeset/sep-837-application-type.md new file mode 100644 index 0000000000..f8e766992f --- /dev/null +++ b/.changeset/sep-837-application-type.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': patch +--- + +Support `application_type` client metadata with native/web inference for dynamic client registration (SEP-837) + +Per the MCP authorization specification, clients MUST specify an appropriate `application_type` when registering dynamically: OIDC-based authorization servers default the field to `'web'`, which conflicts with native/loopback redirect URIs and can cause registration to fail. + +- `OAuthClientMetadataSchema` (and therefore `OAuthClientInformationFullSchema`) now includes an optional `application_type` field, so user-supplied values are no longer stripped from registration requests or responses. +- `registerClient()` infers `application_type` when it is absent from the provided metadata: `'native'` if every redirect URI is loopback (`localhost`, `127.0.0.1`, `[::1]`) or uses a custom non-http(s) scheme, otherwise `'web'`. An explicitly provided value is never overridden. +- New `inferApplicationType(redirectUris)` export implements the inference rule. diff --git a/docs/client.md b/docs/client.md index 0c852f4e11..e7bf5b4b3a 100644 --- a/docs/client.md +++ b/docs/client.md @@ -164,6 +164,8 @@ For a runnable example supporting both auth methods via environment variables, s For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. +When registering dynamically, set `application_type` in your client metadata (`'native'` for desktop/CLI apps using loopback or custom-scheme redirect URIs, `'web'` for remote browser-based apps) — OIDC-based authorization servers default to `'web'`, which rejects loopback redirect URIs (SEP-837). If you omit the field, the SDK infers it from `redirect_uris` via {@linkcode @modelcontextprotocol/client!client/auth.inferApplicationType | inferApplicationType}. + For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). ### Cross-App Access (Enterprise Managed Authorization) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a08..51bb7e6729 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -155,6 +155,13 @@ export interface OAuthClientProvider { /** * Metadata about this OAuth client. + * + * Per the MCP authorization specification (SEP-837), clients MUST specify an + * appropriate `application_type` when registering dynamically: `'native'` for + * desktop/CLI apps using loopback or custom-scheme redirect URIs, `'web'` for + * remote browser-based apps. If `application_type` is omitted, + * {@linkcode registerClient} infers it from `redirect_uris` (see + * {@linkcode inferApplicationType}). */ get clientMetadata(): OAuthClientMetadata; @@ -1692,6 +1699,43 @@ export async function fetchToken( }); } +/** + * Infers the OIDC `application_type` for dynamic client registration from a + * client's redirect URIs (SEP-837). + * + * Returns `'native'` when every redirect URI is either a loopback address + * (`localhost`, `127.0.0.1`, or `[::1]`) or uses a custom non-http(s) scheme + * (e.g. `myapp://callback`); otherwise returns `'web'`. + * + * OIDC-based authorization servers default `application_type` to `'web'`, which + * rejects loopback/custom-scheme redirect URIs — so native apps must declare + * themselves explicitly. Invalid or empty inputs conservatively yield `'web'`, + * matching the OIDC default. + */ +export function inferApplicationType(redirectUris: string[]): 'web' | 'native' { + if (redirectUris.length === 0) { + return 'web'; + } + + return redirectUris.every(uri => isNativeRedirectUri(uri)) ? 'native' : 'web'; +} + +function isNativeRedirectUri(uri: string): boolean { + let url: URL; + try { + url = new URL(uri); + } catch { + return false; + } + + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; + } + + // Custom (non-http/https) schemes are used by native apps. + return true; +} + /** * Performs OAuth 2.0 Dynamic Client Registration according to * {@link https://datatracker.ietf.org/doc/html/rfc7591 | RFC 7591}. @@ -1699,6 +1743,10 @@ export async function fetchToken( * If `scope` is provided, it overrides `clientMetadata.scope` in the registration * request body. This allows callers to apply the Scope Selection Strategy (SEP-835) * consistently across both DCR and the subsequent authorization request. + * + * If `clientMetadata.application_type` is absent, it is inferred from + * `redirect_uris` via {@linkcode inferApplicationType} (SEP-837). An explicitly + * provided `application_type` is never overridden. */ export async function registerClient( authorizationServerUrl: string | URL, @@ -1733,6 +1781,9 @@ export async function registerClient( }, body: JSON.stringify({ ...clientMetadata, + ...(clientMetadata.application_type === undefined + ? { application_type: inferApplicationType(clientMetadata.redirect_uris) } + : {}), ...(scope === undefined ? {} : { scope }) }) }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8a08e8fd79..9dd82f2702 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -26,6 +26,7 @@ export { extractResourceMetadataUrl, extractWWWAuthenticateParams, fetchToken, + inferApplicationType, isHttpsUrl, parseErrorResponse, prepareAuthorizationCodeRequest, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 04d7f4a3fb..9c5a023e02 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -14,6 +14,7 @@ import { discoverOAuthServerInfo, exchangeAuthorization, extractWWWAuthenticateParams, + inferApplicationType, isHttpsUrl, refreshAuthorization, registerClient, @@ -2045,7 +2046,9 @@ describe('OAuth Authorization', () => { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validClientMetadata) + // `application_type` is inferred (SEP-837) when absent; localhost-only + // redirect URIs infer 'native'. + body: JSON.stringify({ ...validClientMetadata, application_type: 'native' }) }) ); }); @@ -2082,7 +2085,7 @@ describe('OAuth Authorization', () => { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' }) + body: JSON.stringify({ ...clientMetadataWithScope, application_type: 'native', scope: 'openid profile' }) }) ); }); @@ -2133,6 +2136,122 @@ describe('OAuth Authorization', () => { }) ).rejects.toThrow('Dynamic client registration failed'); }); + + describe('application_type (SEP-837)', () => { + const mockRegistrationResponse = (clientInfo: Record) => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => clientInfo + }); + }; + + const lastRegistrationBody = (): Record => { + const [, init] = mockFetch.mock.calls.at(-1)!; + return JSON.parse(init.body as string); + }; + + it('infers native for loopback-only redirect URIs', async () => { + mockRegistrationResponse(validClientInfo); + + await registerClient('https://auth.example.com', { + clientMetadata: { + redirect_uris: ['http://localhost:3000/callback', 'http://127.0.0.1:8080/cb', 'http://[::1]:9090/cb'] + } + }); + + expect(lastRegistrationBody().application_type).toBe('native'); + }); + + it('infers native for custom-scheme redirect URIs', async () => { + mockRegistrationResponse(validClientInfo); + + await registerClient('https://auth.example.com', { + clientMetadata: { + redirect_uris: ['myapp://oauth/callback'] + } + }); + + expect(lastRegistrationBody().application_type).toBe('native'); + }); + + it('infers web for https redirect URIs', async () => { + mockRegistrationResponse(validClientInfo); + + await registerClient('https://auth.example.com', { + clientMetadata: { + redirect_uris: ['https://app.example.com/callback'] + } + }); + + expect(lastRegistrationBody().application_type).toBe('web'); + }); + + it('infers web for mixed loopback and remote redirect URIs', async () => { + mockRegistrationResponse(validClientInfo); + + await registerClient('https://auth.example.com', { + clientMetadata: { + redirect_uris: ['http://localhost:3000/callback', 'https://app.example.com/callback'] + } + }); + + expect(lastRegistrationBody().application_type).toBe('web'); + }); + + it('never overrides an explicitly provided application_type', async () => { + mockRegistrationResponse(validClientInfo); + + await registerClient('https://auth.example.com', { + clientMetadata: { + // Loopback-only redirect URIs would infer 'native', but the + // explicit value must win. + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'web' + } + }); + + expect(lastRegistrationBody().application_type).toBe('web'); + }); + + it('retains application_type from the registration response', async () => { + mockRegistrationResponse({ ...validClientInfo, application_type: 'native' }); + + const clientInfo = await registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata + }); + + expect(clientInfo.application_type).toBe('native'); + }); + }); + }); + + describe('inferApplicationType', () => { + it('returns native when every redirect URI is loopback', () => { + expect(inferApplicationType(['http://localhost/callback', 'http://127.0.0.1:1234/cb', 'http://[::1]/cb'])).toBe('native'); + }); + + it('returns native for custom non-http(s) schemes', () => { + expect(inferApplicationType(['myapp://oauth/callback', 'com.example.app:/redirect'])).toBe('native'); + }); + + it('returns web for remote http(s) redirect URIs', () => { + expect(inferApplicationType(['https://app.example.com/callback'])).toBe('web'); + expect(inferApplicationType(['http://app.example.com/callback'])).toBe('web'); + }); + + it('returns web when any redirect URI is remote', () => { + expect(inferApplicationType(['http://localhost:3000/callback', 'https://app.example.com/callback'])).toBe('web'); + }); + + it('does not treat localhost subdomains as loopback', () => { + expect(inferApplicationType(['https://localhost.example.com/callback'])).toBe('web'); + }); + + it('returns web for empty or unparseable input', () => { + expect(inferApplicationType([])).toBe('web'); + expect(inferApplicationType(['not a url'])).toBe('web'); + }); }); describe('auth function', () => { diff --git a/packages/core/src/shared/auth.ts b/packages/core/src/shared/auth.ts index deee583aa1..554f71626c 100644 --- a/packages/core/src/shared/auth.ts +++ b/packages/core/src/shared/auth.ts @@ -179,6 +179,25 @@ export const OptionalSafeUrlSchema = SafeUrlSchema.optional().or(z.literal('').t export const OAuthClientMetadataSchema = z .object({ redirect_uris: z.array(SafeUrlSchema), + /** + * OpenID Connect Dynamic Client Registration `application_type`. + * + * The standard values are `'web'` and `'native'`. OIDC-based authorization + * servers default this to `'web'` when omitted, which conflicts with + * native/loopback redirect URIs (e.g. `http://localhost`, `http://127.0.0.1`, + * or custom URI schemes) and can cause registration to fail. Per the MCP + * authorization specification (SEP-837), clients MUST specify an appropriate + * `application_type` when registering: native apps (desktop, CLI, anything + * using loopback or custom-scheme redirects) SHOULD use `'native'`, while + * remote browser-based apps SHOULD use `'web'`. Authorization servers that + * do not implement OIDC registration ignore this field. + * + * Typed as a plain string because OIDC permits extension values beyond + * `'web'` and `'native'`. + * + * @see https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + */ + application_type: z.string().optional(), token_endpoint_auth_method: z.string().optional(), grant_types: z.array(z.string()).optional(), response_types: z.array(z.string()).optional(), From 7f811c4244b1f31b14de178ea766621fe796f696 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 21:46:55 +0100 Subject: [PATCH 2/2] test(conformance): remove SEP-837 application_type entries from expected-failures baseline The application_type implementation makes the 13 scenarios that failed only on the SEP-837 DCR checks pass, and the conformance runner fails on stale baseline entries. auth/scope-step-up stays: it still emits a SEP-2350 WARNING (scope union on re-authorization), which the evaluator counts as a failure. --- test/conformance/expected-failures.yaml | 27 ++++++------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index f4b7ce4213..0cbc78f4b9 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -3,8 +3,8 @@ # # Baseline established against @modelcontextprotocol/conformance 0.2.0-alpha.1, # which adds the draft-spec scenario suite (SEP-2575, SEP-2322, SEP-2243, SEP-2549, -# SEP-2468, ...) plus new checks on existing scenarios (SEP-837 application_type -# during DCR). +# SEP-2468, ...) plus new checks on existing scenarios. The SEP-837 +# application_type DCR checks are implemented and their scenarios pass. # # Entries are grouped by SEP. As each SEP/milestone is implemented in the SDK the # corresponding scenarios start passing and MUST be removed from this list (the @@ -34,26 +34,11 @@ client: # SEP-2352 (authorization server migration): client does not re-register when # PRM authorization_servers changes. - auth/authorization-server-migration - # SEP-2207 (offline_access scope) scenario, currently failing only on the new - # SEP-837 application_type check (see the SEP-837 group below). - - auth/offline-access-not-supported - - # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- - # SEP-837: client MUST send application_type during Dynamic Client Registration. - # Single new check; everything else in these scenarios passes. - - auth/metadata-default - - auth/metadata-var1 - - auth/metadata-var2 - - auth/metadata-var3 - - auth/scope-from-www-authenticate - - auth/scope-from-scopes-supported - - auth/scope-omitted-when-undefined + # SEP-2350 (scope step-up): WARNING-only — client SHOULD compute the union of + # previously requested and newly challenged scopes on re-authorization, but it + # drops the previously-granted scope; the evaluator counts WARNINGs as failures. + # (The SEP-837 application_type checks in this scenario pass.) - auth/scope-step-up - - auth/scope-retry-limit - - auth/token-endpoint-auth-basic - - auth/token-endpoint-auth-post - - auth/token-endpoint-auth-none - - auth/2025-03-26-oauth-metadata-backcompat # SEP-990 (enterprise-managed authorization extension): no fixture handler / # client support for the token-exchange + JWT bearer flow. - auth/enterprise-managed-authorization