Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
14 changes: 14 additions & 0 deletions .changeset/sep-2468-iss-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/client': minor
---

Add RFC 9207 `iss` parameter validation for authorization responses (SEP-2468). `OAuthMetadataSchema` and `OpenIdProviderMetadataSchema` now recognize `authorization_response_iss_parameter_supported`. The client exports a new `validateAuthorizationResponseIssuer()` helper,
`auth()` accepts an optional `iss`, and `StreamableHTTPClientTransport.finishAuth()` / `SSEClientTransport.finishAuth()` accept an optional `{ iss }` second argument. The `iss` option is tri-state: a string is validated by exact comparison against the issuer recorded in the
authorization server metadata before the authorization code is sent to any token endpoint (mismatch rejects the response without processing any other response parameters); `null` asserts the caller inspected the authorization response and it carried no `iss`, enabling the RFC
9207 fail-closed rejection when the AS advertises `authorization_response_iss_parameter_supported: true`; `undefined` (omitted) skips RFC 9207 response validation, so existing `finishAuth(code)` callers that never see the authorization response are unaffected.

Discovery also now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a PRM-provided authorization server URL is rejected when its `issuer` does not match that URL, and the public `discoverAuthorizationServerMetadata()` helper
throws on mismatches or invalid issuer identifiers unless called with `{ validateIssuer: false }` for intentional alias discovery. Cached discovery state is also validated; stale legacy no-PRM fallback state that saved the MCP server origin before learning a distinct metadata
issuer is ignored and refreshed. For legacy servers without protected resource metadata, metadata is still discovered at the MCP server origin; when that metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL for persisted
discovery state and fallback endpoint construction.
2 changes: 1 addition & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ For a runnable example supporting both auth methods via environment variables, s

### Full OAuth with user authorization

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.
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, extract the callback `code` and `iss` query parameters, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth} with the code and `iss` option, and reconnect. Pass `iss: null` when the callback was inspected and omitted `iss`; leaving it `undefined` preserves legacy behavior and skips RFC 9207 issuer validation.

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).

Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,10 @@ members of the request/result/notification unions, the `tasks` capability key, `

`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead.

OAuth client discovery validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a protected-resource metadata authorization server URL must have a matching `issuer`; cached discovery state is also revalidated. The public
`discoverAuthorizationServerMetadata()` helper throws for mismatched or invalid issuers unless called with `{ validateIssuer: false }`. Legacy no-PRM fallback still discovers metadata at the MCP server origin and adopts a distinct valid metadata `issuer` for saved discovery
state.

### Server (Streamable HTTP transport)

No code changes required; these are wire-behavior notes:
Expand Down
9 changes: 9 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ a working demo with `better-auth`.

Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`.

### Authorization server metadata issuer validation

OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata identifies an authorization server URL, the discovered metadata's `issuer` must match that URL after standard URL parsing/serialization and
trailing slash normalization. Cached discovery state is also validated; stale legacy no-PRM fallback state that saved the MCP server origin before learning a distinct metadata issuer is ignored and refreshed. The public `discoverAuthorizationServerMetadata()` helper throws when
metadata has a mismatched or invalid issuer unless called with `{ validateIssuer: false }`. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata.

For legacy MCP servers without protected resource metadata, the SDK still discovers authorization-server metadata at the MCP server origin. If that origin-hosted metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL saved in
discovery state and used for fallback endpoint construction.
Comment thread
claude[bot] marked this conversation as resolved.

### `Headers` object instead of plain objects

Transport APIs and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain `Record<string, string | string[] | undefined>` (`IsomorphicHeaders` has been removed).
Expand Down
12 changes: 6 additions & 6 deletions examples/client/src/elicitationUrlExample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,8 @@ async function handleURLElicitation(params: ElicitRequestURLParams): Promise<Eli
/**
* Starts a temporary HTTP server to receive the OAuth callback
*/
async function waitForOAuthCallback(): Promise<string> {
return new Promise<string>((resolve, reject) => {
async function waitForOAuthCallback(): Promise<{ code: string; iss: string | null }> {
return new Promise<{ code: string; iss: string | null }>((resolve, reject) => {
const server = createServer((req, res) => {
// Ignore favicon requests
if (req.url === '/favicon.ico') {
Expand All @@ -453,6 +453,7 @@ async function waitForOAuthCallback(): Promise<string> {
console.log(`📥 Received callback: ${req.url}`);
const parsedUrl = new URL(req.url || '', 'http://localhost');
const code = parsedUrl.searchParams.get('code');
const iss = parsedUrl.searchParams.get('iss');
const error = parsedUrl.searchParams.get('error');

if (code) {
Expand All @@ -469,7 +470,7 @@ async function waitForOAuthCallback(): Promise<string> {
</html>
`);

resolve(code);
resolve({ code, iss });
setTimeout(() => server.close(), 15_000);
} else if (error) {
console.log(`❌ Authorization error: ${error}`);
Expand Down Expand Up @@ -519,9 +520,8 @@ async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Pr
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log('🔐 OAuth required - waiting for authorization...');
const callbackPromise = waitForOAuthCallback();
const authCode = await callbackPromise;
await transport.finishAuth(authCode);
const { code: authCode, iss } = await waitForOAuthCallback();
await transport.finishAuth(authCode, { iss });
console.log('🔐 Authorization code received:', authCode);
console.log('🔌 Reconnecting with authenticated transport...');
// Recursively retry connection after OAuth completion
Expand Down
12 changes: 6 additions & 6 deletions examples/client/src/simpleOAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ class InteractiveOAuthClient {
/**
* Starts a temporary HTTP server to receive the OAuth callback
*/
private async waitForOAuthCallback(): Promise<string> {
return new Promise<string>((resolve, reject) => {
private async waitForOAuthCallback(): Promise<{ code: string; iss: string | null }> {
return new Promise<{ code: string; iss: string | null }>((resolve, reject) => {
const server = createServer((req, res) => {
// Ignore favicon requests
if (req.url === '/favicon.ico') {
Expand All @@ -85,6 +85,7 @@ class InteractiveOAuthClient {
console.log(`📥 Received callback: ${req.url}`);
const parsedUrl = new URL(req.url || '', 'http://localhost');
const code = parsedUrl.searchParams.get('code');
const iss = parsedUrl.searchParams.get('iss');
const error = parsedUrl.searchParams.get('error');

if (code) {
Expand All @@ -100,7 +101,7 @@ class InteractiveOAuthClient {
</html>
`);

resolve(code);
resolve({ code, iss });
setTimeout(() => server.close(), 3000);
} else if (error) {
console.log(`❌ Authorization error: ${error}`);
Expand Down Expand Up @@ -143,9 +144,8 @@ class InteractiveOAuthClient {
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log('🔐 OAuth required - waiting for authorization...');
const callbackPromise = this.waitForOAuthCallback();
const authCode = await callbackPromise;
await transport.finishAuth(authCode);
const { code: authCode, iss } = await this.waitForOAuthCallback();
await transport.finishAuth(authCode, { iss });
console.log('🔐 Authorization code received:', authCode);
console.log('🔌 Reconnecting with authenticated transport...');
await this.attemptConnection(oauthProvider);
Expand Down
Loading
Loading