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
11 changes: 11 additions & 0 deletions .changeset/sep-837-application-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@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 an HTTP loopback URI (`localhost`, `127.0.0.1`, `[::1]`) or uses a custom non-http(s) scheme, otherwise `'web'`. An explicitly provided value is never overridden.
2 changes: 2 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,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 HTTP loopback or custom-scheme redirect URIs, `'web'` for remote browser-based apps) — OIDC-based authorization servers default to `'web'`, which rejects native redirect URIs (SEP-837). If you omit the field, the SDK infers it from `redirect_uris`.

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
54 changes: 54 additions & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ 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`.
*/
get clientMetadata(): OAuthClientMetadata;

Expand Down Expand Up @@ -1692,13 +1698,58 @@ 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 an HTTP 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.
*/
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:') {
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
}

if (url.protocol === 'https:') {
return false;
}

// Custom (non-http/https) schemes are used by native apps.
Comment thread
mattzcarey marked this conversation as resolved.
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` (SEP-837). An explicitly provided `application_type` is never
* overridden.
*/
export async function registerClient(
authorizationServerUrl: string | URL,
Expand Down Expand Up @@ -1733,6 +1784,9 @@ export async function registerClient(
},
body: JSON.stringify({
...clientMetadata,
...(clientMetadata.application_type === undefined
? { application_type: inferApplicationType(clientMetadata.redirect_uris) }
: {}),
...(scope === undefined ? {} : { scope })
})
});
Expand Down
106 changes: 104 additions & 2 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2063,7 +2063,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 @@ -2100,7 +2102,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 @@ -2151,6 +2153,106 @@ 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 https loopback redirect URIs', async () => {
mockRegistrationResponse(validClientInfo);

await registerClient('https://auth.example.com', {
clientMetadata: {
redirect_uris: ['https://localhost:8443/callback', 'https://127.0.0.1:8443/callback', 'https://[::1]:8443/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('auth function', () => {
Expand Down
12 changes: 6 additions & 6 deletions packages/codemod/src/generated/versions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate.
export const V2_PACKAGE_VERSIONS: Record<string, string> = {
'@modelcontextprotocol/client': '^2.0.0-alpha.2',
'@modelcontextprotocol/server': '^2.0.0-alpha.2',
'@modelcontextprotocol/node': '^2.0.0-alpha.2',
'@modelcontextprotocol/express': '^2.0.0-alpha.2',
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2',
'@modelcontextprotocol/core': '^2.0.0-alpha.0'
'@modelcontextprotocol/client': '^2.0.0-alpha.3',
'@modelcontextprotocol/server': '^2.0.0-alpha.3',
'@modelcontextprotocol/node': '^2.0.0-alpha.3',
'@modelcontextprotocol/express': '^2.0.0-alpha.3',
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3',
'@modelcontextprotocol/core': '^2.0.0-alpha.1'
};
19 changes: 19 additions & 0 deletions packages/core-internal/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
5 changes: 1 addition & 4 deletions test/conformance/expected-failures.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,11 @@ client:
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
- auth/authorization-server-migration
# SEP-837 (application_type during DCR): the check only fires on draft-version
# runs; this draft scenario is the one place the client still hits it.
- auth/offline-access-not-supported

# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-2350 (scope step-up): WARNING-only — client does not compute the union of
# previously requested and newly challenged scopes on re-authorization; the
# expected-failures evaluator counts WARNINGs as failures.
# (The SEP-837 application_type checks in this scenario pass.)
- auth/scope-step-up
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
# client support for the token-exchange + JWT bearer flow.
Expand Down
Loading