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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload by felixweinberger · Pull Request #2347 · modelcontextprotocol/typescript-sdk · GitHub
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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload by felixweinberger · Pull Request #2347 · modelcontextprotocol/typescript-sdk · GitHub
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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload by felixweinberger · Pull Request #2347 · modelcontextprotocol/typescript-sdk · GitHub
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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload by felixweinberger · Pull Request #2347 · modelcontextprotocol/typescript-sdk · GitHub
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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload by felixweinberger · Pull Request #2347 · modelcontextprotocol/typescript-sdk · GitHub
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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload by felixweinberger · Pull Request #2347 · modelcontextprotocol/typescript-sdk · GitHub
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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload by felixweinberger · Pull Request #2347 · modelcontextprotocol/typescript-sdk · GitHub
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

@felixweinbergerfelixweinberger 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 ownerJune 23, 2026 13:18
@changeset-bot

changeset-botBot 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
NameType
@modelcontextprotocol/clientMinor

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-newBot 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 threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7CompareJune 23, 2026 19:03
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867CompareJune 23, 2026 19:03
Comment threadpackages/server-legacy/src/auth/providers/proxyProvider.ts Outdated
Comment threadpackages/client/src/client/auth.ts Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4cCompareJune 23, 2026 21:20
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6CompareJune 23, 2026 21:20
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6CompareJune 23, 2026 21:50
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1CompareJune 23, 2026 21:50
Comment threadpackages/server-legacy/src/auth/router.ts Outdated
Comment threaddocs/migration.md Outdated
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271CompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 1196cf1 to 6b3f82bCompareJune 23, 2026 22:57
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968CompareJune 23, 2026 23:05
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from 6b3f82b to ef079b3CompareJune 23, 2026 23:05
Comment threadpackages/client/src/client/auth.ts Outdated
Comment threadpackages/server-legacy/src/auth/handlers/authorize.ts Outdated
Comment threadpackages/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
felixweinbergerforce-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfcCompareJune 24, 2026 09:43
@felixweinberger
felixweinbergerforce-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01CompareJune 24, 2026 09:43
Base automatically changed from fweinberger/auth-3-sep2350-stepup to fweinberger/auth-1-sep2468-clientJune 24, 2026 09:43
@felixweinberger
felixweinberger merged commit 46b6b01 into fweinberger/auth-1-sep2468-clientJun 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 freeto 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

@felixweinberger@pcarleton