Skip to content

feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload#2347

Merged
felixweinberger merged 1 commit into
fweinberger/auth-1-sep2468-clientfrom
fweinberger/auth-4-sep2468-server
Jun 24, 2026
Merged

feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload#2347
felixweinberger merged 1 commit into
fweinberger/auth-1-sep2468-clientfrom
fweinberger/auth-4-sep2468-server

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

27b2cbe00

mcpAuthRouter/createOAuthMetadata in @modelcontextprotocol/server-legacy emit iss on authorization and error redirects and advertise authorization_response_iss_parameter_supported: true. finishAuth on StreamableHTTPClientTransport and SSEClientTransport gains a (callbackParams: URLSearchParams) overload that extracts code + iss, validates iss first, and on mismatch throws a sanitized IssuerMismatchError without surfacing error/error_description/error_uri.

Motivation and Context

SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468, spec wording in #2646).

Normative sentences implemented:

MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses … Authorization servers that include the iss parameter MUST advertise this by setting authorization_response_iss_parameter_supported to true in their metadata.
basic/authorization/index.mdx

This validation applies equally to error responses — on mismatch the client MUST NOT act on or display error, error_description, or error_uri.
basic/authorization/index.mdx

The URLSearchParams overload exists so hosts can hand the SDK the whole callback query and have the SDK enforce the MUST-NOT-display rule structurally.

How Has This Been Tested?

  • E2E: hosting:auth:iss-emission, hosting:auth:iss-metadata-advertised, client-auth:finishAuth:{urlsearchparams, sanitized-error}.
  • Unit: server-legacy authorize.ts redirect param assertions.
  • This is the only automated proof for the server-side SHOULD and the error-param sanitization (conformance does not observe either).

Breaking Changes

Behavior change for @modelcontextprotocol/server-legacy consumers with a custom OAuthServerProvider.authorize(): the bundled metadata now advertises authorization_response_iss_parameter_supported: true by default. The SDK appends iss to authorization redirects via the res.redirect() it passes you — if your authorize() redirects another way (e.g., writes Location directly), append params.issuer yourself or set authorizationResponseIssParameterSupported: false in mcpAuthRouter options. Otherwise SEP-2468 clients will reject callbacks (advertised-but-missing iss → row-2 reject).

The finishAuth(URLSearchParams) overload is additive — finishAuth(code: string, iss?: string) still compiles.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Split from PR 2 to keep the client-side MUSTs (which are the breaking-change surface) reviewable independently of the server-legacy SHOULD.


@felixweinberger felixweinberger requested a review from a team as a code owner June 23, 2026 13:18
@changeset-bot

changeset-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 46b6b01

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2347

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2347

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2347

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2347

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2347

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2347

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2347

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2347

commit: ef079b3

Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/server-legacy/src/auth/router.ts Outdated
Comment thread packages/server-legacy/src/auth/handlers/authorize.ts Outdated
* present. Validated per RFC 9207 against the recorded issuer before the code is redeemed.
*/
async finishAuth(authorizationCode: string, iss?: string): Promise<void> {
async finishAuth(authorizationCode: string, iss?: string): Promise<void>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if we want the "base" one to be an object, and then also allow params.

@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7 Compare June 23, 2026 19:03
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867 Compare June 23, 2026 19:03
Comment thread packages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment thread packages/client/src/client/auth.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4c Compare June 23, 2026 21:20
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6 Compare June 23, 2026 21:20
Comment thread docs/migration.md Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6 Compare June 23, 2026 21:50
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1 Compare June 23, 2026 21:50
Comment thread packages/server-legacy/src/auth/router.ts Outdated
Comment thread docs/migration.md Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271 Compare June 23, 2026 22:57
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82b Compare June 23, 2026 22:57
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968 Compare June 23, 2026 23:05
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3 Compare June 23, 2026 23:05
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment thread packages/server-legacy/src/auth/handlers/authorize.ts Outdated
…ed refresh bypass

Adds onInsufficientScope handling: computeScopeUnion (previously-granted ∪
challenged), per-transport retry cap, and superset-gated refresh bypass when
the held token already covers the challenged scope. InsufficientScopeError
joins the other flow errors in authErrors.ts.

Includes the scoped-tools example story (server + demo AS + client). The demo
authorization server is hardened: DCR rejects any redirect_uri that is not a
loopback host (127.0.0.1, localhost, [::1]) with http/https scheme; /authorize
validates the request's redirect_uri exactly against the per-client registered
set; both the demo AS and the MCP resource server bind to 127.0.0.1 only.

Also wires the conformance fixture's fetch-level withOAuthRetry to use the
exported computeScopeUnion on 403 insufficient_scope (the fixture intercepts
before the transport's _stepUpAuthorize sees it). The 2026-07-28 leg's
auth/scope-step-up entry stays in the expected-failures baseline — it is
gated on the fixture's 2025 lifecycle (-32020 before scope escalation is
observed), orthogonal to this change.

Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfc Compare June 24, 2026 09:43
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01 Compare June 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-client June 24, 2026 09:43
@felixweinberger felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-client Jun 24, 2026
2 checks passed
@felixweinberger felixweinberger deleted the fweinberger/auth-4-sep2468-server branch June 24, 2026 09:43
Comment on lines +387 to +392
if (stepUpRetries >= this._maxStepUpRetries) {
throw new SdkHttpError(
SdkErrorCode.ClientHttpForbidden,
`Server returned 403 insufficient_scope after step-up re-authorization (retry limit ${this._maxStepUpRetries} reached)`,
{ status: 403, statusText: challenge.statusText ?? 'Forbidden', text: challenge.text }
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Setting maxStepUpRetries: 0 (accepted by the Math.max(0, ...) clamp) makes the very first 403 insufficient_scope throw an SdkHttpError whose message claims "after step-up re-authorization (retry limit 0 reached)" even though no re-authorization was attempted, and a host using 0 to disable automatic step-up gets that opaque error instead of the typed InsufficientScopeError (with requiredScope/resourceMetadataUrl/errorDescription) that the 'throw' mode and no-provider paths produce. Consider treating a cap of 0 as equivalent to onInsufficientScope: 'throw', rewording the message, or documenting/disallowing 0.

Extended reasoning...

What happens. The constructor clamps the option with Math.max(0, opts?.maxStepUpRetries ?? DEFAULT_MAX_STEP_UP_RETRIES), so 0 is an accepted (and, given that negatives are explicitly clamped to 0, apparently sanctioned) configuration. In _stepUpAuthorize, the retry-cap check if (stepUpRetries >= this._maxStepUpRetries) runs before any auth() call. Both call sites — _send and _startOrAuthSse — invoke _stepUpAuthorize with stepUpRetries = 0 on the first 403, so with maxStepUpRetries: 0 the comparison 0 >= 0 fires immediately and the transport throws SdkHttpError(ClientHttpForbidden) with the message "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 0 reached)".\n\nWhy the message is wrong. In that configuration no re-authorization was ever attempted — the error text describes a step-up that never happened. The JSDoc on maxStepUpRetries neither documents nor prohibits 0, and no test covers it (the new tests exercise the default cap of 1 and maxStepUpRetries: 2 only), so this isn't an intentional contract.\n\nWhy the error type matters. 0 is the natural value a host would reach for to mean "don't step up automatically; let me drive it." But the two adjacent paths that serve exactly that intent — onInsufficientScope: 'throw' and the no-OAuthClientProvider case — both throw the typed InsufficientScopeError carrying the challenge parameters (requiredScope, resourceMetadataUrl, errorDescription). The cap-0 path instead surfaces only an SdkHttpError with status/text, so the host cannot drive step-up itself from the error contents and gets a misleading explanation of why the request failed.\n\nStep-by-step.\n1. Host constructs new StreamableHTTPClientTransport(url, { authProvider: oauthProvider, maxStepUpRetries: 0 }) intending to disable automatic step-up.\n2. The server answers a POST with 403 + WWW-Authenticate: Bearer error=\"insufficient_scope\", scope=\"files:write\".\n3. _send extracts the challenge and calls _stepUpAuthorize(challenge, 0).\n4. onInsufficientScope is the default 'reauthorize' and an OAuth provider exists, so the first two guards fall through; the cap check 0 >= 0 is true.\n5. The transport throws SdkHttpError(ClientHttpForbidden, "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 0 reached)", ...) — no auth() call ever ran, and the challenge's scope/resource_metadata/error_description are not on the error.\n\nWhy nothing else prevents it. The clamp accepts 0, the JSDoc is silent on it, and the cap check sits ahead of every other branch in _stepUpAuthorize, so there is no path by which a 0-cap configuration reaches either the typed error or an accurate message.\n\nImpact and mitigations. This is a polish issue, not a correctness blocker: the documented way to disable automatic step-up is onInsufficientScope: 'throw', 0 is an unusual deliberate configuration, the request still fails loudly, and "(retry limit 0 reached)" lets a careful reader infer the cause. No incorrect auth occurs.\n\nHow to fix (any of these is small).\n- Treat maxStepUpRetries: 0 as equivalent to 'throw': when stepUpRetries === 0 and the cap is 0, throw InsufficientScopeError with the challenge params instead of the SdkHttpError.\n- Or reword the cap message so it doesn't claim a re-authorization happened when stepUpRetries === 0.\n- Or document on maxStepUpRetries that 0 is disallowed (and clamp to 1) or that hosts wanting to disable step-up should use onInsufficientScope: 'throw'.

Comment on lines +1 to +5
---
'@modelcontextprotocol/client': minor
---

SEP-2350 scope step-up: on `403 insufficient_scope`, `StreamableHTTPClientTransport` now re-authorizes with the **union** of the previously-requested and challenged scopes (`computeScopeUnion`), bypassing the refresh-token branch when the union is a strict superset of the current token's granted scope (`isStrictScopeSuperset`, `AuthOptions.forceReauthorization`). New `onInsufficientScope: 'reauthorize' | 'throw'` (default `'reauthorize'`) and `maxStepUpRetries` (default 1) on `StreamableHTTPClientTransportOptions`; `'throw'` raises the new `InsufficientScopeError`. The GET listen-stream open path now applies the same step-up handling. The previous verbatim-header retry guard is replaced by the bounded per-send counter.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The changeset presents SEP-2350 as purely additive, but this PR also unconditionally changes what a 403 insufficient_scope throws for transports configured without an OAuthClientProvider (minimal AuthProvider, requestInit-only headers, or no authProvider): previously SdkHttpError(ClientHttpNotImplemented), now the new InsufficientScopeError, which does not extend SdkError, so existing instanceof SdkError/SdkHttpError handlers stop matching. docs/migration.md documents this and the PR checks the breaking-change box — consider adding one sentence to the changeset (the published CHANGELOG text) so the default-mode error-class change is not a silent surprise on upgrade.

Extended reasoning...

What changed. Pre-PR, the 403 branch in _send was gated on response.status === 403 && this._oauthProvider. A transport configured with a minimal AuthProvider, requestInit-only headers, or no authProvider at all therefore fell through to the generic SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: …') on a 403 insufficient_scope. Post-PR the gate is just response.status === 403, and _stepUpAuthorize (packages/client/src/client/streamableHttp.ts, ~line 380) throws the new InsufficientScopeError whenever this._oauthProvider is absent — even in the default 'reauthorize' mode, with no opt-in by the consumer. The GET listen-stream open path gains the same behavior (it previously threw SdkHttpError(ClientHttpFailedToOpenStream)).\n\nWhy this is an error-class change consumers can hit. InsufficientScopeError extends OAuthClientFlowError → Error, not SdkError/SdkHttpError. A host using one of the affected configurations whose error handling does if (error instanceof SdkHttpError) (the pattern the migration guide itself recommends) will stop matching this case after upgrading, with no code change on their side.\n\nThe documentation gap. docs/migration.md covers this precisely ("If you pass a non-OAuth authProvider (or only requestInit headers), a 403 insufficient_scope now throws InsufficientScopeError instead of the previous generic SdkHttpError(ClientHttpNotImplemented) … existing instanceof SdkError catches no longer match this case"), docs/migration-SKILL.md has the matching throw-site row, and the PR description checks the Breaking-change box. But .changeset/sep-2350-scope-step-up.md — the text that lands in the published CHANGELOG that upgraders actually read — describes the feature as purely additive and only mentions InsufficientScopeError in connection with the opt-in 'throw' mode ("'throw' raises the new InsufficientScopeError"), which under-describes the implementation.\n\nStep-by-step example.\n1. A host today uses new StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: 'Bearer …' } } }) (no OAuthClientProvider) and wraps sends in catch (e) { if (e instanceof SdkHttpError) … }.\n2. The RS responds 403 with WWW-Authenticate: Bearer error=\"insufficient_scope\", scope=\"files:write\".\n3. Pre-PR: the 403 branch is skipped (no _oauthProvider), the request falls through to SdkHttpError(ClientHttpNotImplemented) and the handler matches.\n4. Post-PR: _stepUpAuthorize runs (the gate no longer requires _oauthProvider), hits the if (!this._oauthProvider) branch, and throws InsufficientScopeError. The instanceof SdkHttpError handler no longer matches; the host's fallback path runs instead. Reading the changeset alone gives no hint of this — it implies InsufficientScopeError only appears when onInsufficientScope: 'throw' is set.\n\nOn the counter-argument that this is editorial. It is true that the repo's required upgrade documentation (migration.md / migration-SKILL.md) covers the change, that the changeset makes no false claim, and that the new typed error is strictly more informative — which is why this is filed as a nit, not a blocker. But the changeset is the only one of those documents that ships in the package CHANGELOG that consumers see at upgrade time, and it currently scopes InsufficientScopeError to the opt-in 'throw' mode while the default mode also throws it for a whole class of transport configurations. That is a meaningful omission for the audience the changeset serves, and the fix is a single sentence.\n\nSuggested fix. Append one sentence to .changeset/sep-2350-scope-step-up.md, e.g.: "For transports without an OAuthClientProvider (minimal AuthProvider, requestInit-only headers, or no authProvider), a 403 insufficient_scope now throws InsufficientScopeError (not SdkError) instead of the previous generic SdkHttpError(ClientHttpNotImplemented) — see the migration guide's Scope step-up section."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants