Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/sep-837-application-type.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1692,13 +1699,54 @@ 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}.
*
* 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,
Expand Down Expand Up @@ -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 })
})
});
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
extractResourceMetadataUrl,
extractWWWAuthenticateParams,
fetchToken,
inferApplicationType,
isHttpsUrl,
parseErrorResponse,
prepareAuthorizationCodeRequest,
Expand Down
123 changes: 121 additions & 2 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
inferApplicationType,
isHttpsUrl,
refreshAuthorization,
registerClient,
Expand Down Expand Up @@ -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' })
})
);
});
Expand Down Expand Up @@ -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' })
})
);
});
Expand Down Expand Up @@ -2133,6 +2136,122 @@ describe('OAuth Authorization', () => {
})
).rejects.toThrow('Dynamic client registration failed');
});

describe('application_type (SEP-837)', () => {
const mockRegistrationResponse = (clientInfo: Record<string, unknown>) => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => clientInfo
});
};

const lastRegistrationBody = (): Record<string, unknown> => {
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', () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/shared/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
27 changes: 6 additions & 21 deletions test/conformance/expected-failures.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading