Skip to content

feat(client): SEP-2350 scope step-up — union, retry cap, superset-gated refresh bypass#2346

Merged
felixweinberger merged 1 commit into
fweinberger/auth-1-sep2468-clientfrom
fweinberger/auth-3-sep2350-stepup
Jun 24, 2026
Merged

feat(client): SEP-2350 scope step-up — union, retry cap, superset-gated refresh bypass#2346
felixweinberger merged 1 commit into
fweinberger/auth-1-sep2468-clientfrom
fweinberger/auth-3-sep2350-stepup

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

a0266c630 (squash of 07fdc0d2d + ae5453f21 + 9be8a7e97)

Adds onInsufficientScope handling on both HTTP transports: on 403 insufficient_scope the transport computes previously-granted ∪ challenged via the new exported computeScopeUnion(), applies a bounded per-transport retry cap, and — when the union is a strict superset of the held token's scope — bypasses the refresh branch and forces a fresh startAuthorization (RFC 6749 §6: refresh cannot widen scope). New exported InsufficientScopeError. Adds the examples/scoped-tools headless story (server + demo AS + client) with the demo AS hardened to loopback-only redirect_uris at DCR. Wires the conformance fixture's withOAuthRetry to use computeScopeUnion so auth/scope-step-up burns on the 2026 leg.

Motivation and Context

SEP-2350 (modelcontextprotocol/modelcontextprotocol#2350).

Normative sentences implemented:

Clients MUST treat the scopes provided in the [WWW-Authenticate scope parameter] as the additional scopes required to satisfy the current request. When re-authorizing, clients SHOULD include these scopes in addition to any previously requested scopes.
basic/authorization/index.mdx

SHOULD compute the union of previously requested scopes and newly required scopes.
basic/authorization/index.mdx §Scope Challenge Handling

Clients SHOULD implement retry limits and SHOULD track scope upgrade attempts to avoid [infinite loops].
basic/authorization/index.mdx

The superset-gated refresh bypass is the security-review-mandated guard from the PRD: a refresh grant cannot widen scope per RFC 6749 §6, so step-up to a strict superset must go through startAuthorization.

How Has This Been Tested?

  • Unit: computeScopeUnion set semantics; retry-cap counter.
  • E2E: client-auth:step-up:{union, retry-cap, superset-bypass-refresh, throw-mode, get-stream-403}, hosting-entry-auth step-up rows.
  • Examples runner: examples/scoped-tools is a self-verifying headless OAuth leg in pnpm run:examples.
  • Conformance: auth/scope-step-up burns on the 2026-07-28 leg (the fixture's fetch-level withOAuthRetry now calls the exported computeScopeUnion on 403, exercising the same helper a real consumer would).

Breaking Changes

None. onInsufficientScope defaults to 'reauthorize', which is the existing behavior at the plan pin (PRD user stories 30–31: the named consumer's wrapFetchWithStepUpDetection keeps working without an opt-in). 'throw' is the new opt-in.

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

Demo AS hardening (folded in from ae5453f21). The examples/scoped-tools demo authorization server now: rejects any DCR redirect_uri that is not a loopback host (127.0.0.1, localhost, [::1]) with http/https scheme; validates /authorize's redirect_uri exactly against the per-client registered set; binds both the demo AS and the MCP resource server to 127.0.0.1 only. This prevents an unauthenticated DCR from registering an external redirect target and exfiltrating authorization codes from the demo.

Fixture wiring (folded in from 9be8a7e97). The conformance fixture's fetch-level withOAuthRetry intercepts 403 before the transport's _stepUpAuthorize sees it, so its handle401 now calls the exported computeScopeUnion directly.


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

changeset-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 52b1cfc

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

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/core Minor

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

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

@pkg-pr-new

pkg-pr-new Bot commented Jun 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: a2c6968

@pcarleton pcarleton left a comment

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.

LGTM

@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from 1416e4a to e06add7 Compare June 23, 2026 19:03
@felixweinberger felixweinberger force-pushed the fweinberger/auth-2-sep837-2207 branch from 99fbb49 to 5156ed7 Compare June 23, 2026 19:03
Comment thread packages/client/src/client/sse.ts Outdated
Comment thread packages/client/src/client/authErrors.ts Outdated
Comment thread docs/migration.md Outdated
Comment thread docs/migration.md Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from e06add7 to 2bffe4c Compare June 23, 2026 21:20
@felixweinberger felixweinberger force-pushed the fweinberger/auth-2-sep837-2207 branch from 5156ed7 to 409363a Compare June 23, 2026 21:20
Comment thread packages/client/src/client/streamableHttp.ts Outdated
Comment thread examples/scoped-tools/server.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from 2bffe4c to f9285f6 Compare June 23, 2026 21:50
@felixweinberger felixweinberger force-pushed the fweinberger/auth-2-sep837-2207 branch from 409363a to 32d3af6 Compare June 23, 2026 21:50
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from f9285f6 to 8b2f271 Compare June 23, 2026 22:57
@felixweinberger felixweinberger force-pushed the fweinberger/auth-2-sep837-2207 branch 2 times, most recently from b629c06 to b445a38 Compare June 23, 2026 23:05
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from 8b2f271 to a2c6968 Compare June 23, 2026 23:05
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread test/conformance/src/helpers/withOAuthRetry.ts Outdated
Comment thread examples/scoped-tools/server.ts Outdated
Comment thread packages/client/src/client/streamableHttp.ts Outdated
Comment thread examples/scoped-tools/client.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-3-sep2350-stepup branch from a2c6968 to 52b1cfc Compare June 24, 2026 09:43
@felixweinberger felixweinberger force-pushed the fweinberger/auth-2-sep837-2207 branch from b445a38 to 318fca3 Compare June 24, 2026 09:43
Base automatically changed from fweinberger/auth-2-sep837-2207 to fweinberger/auth-1-sep2468-client June 24, 2026 09:43
@felixweinberger felixweinberger merged commit 52b1cfc into fweinberger/auth-1-sep2468-client Jun 24, 2026
1 check passed
@felixweinberger felixweinberger deleted the fweinberger/auth-3-sep2350-stepup branch June 24, 2026 09:43
Comment on lines +1 to +6
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/core': minor
---

Dynamic Client Registration hygiene for the 2026-07-28 authorization requirements (SEP-837, SEP-2207). New `resolveClientMetadata(provider)` reads `provider.clientMetadata` and applies the spec defaults — `application_type` derived from the redirect URIs (loopback or custom scheme → `'native'`, otherwise `'web'`), `grant_types: ['authorization_code', 'refresh_token']` when omitted — and `auth()` feeds the resolved document to DCR only (scope selection still reads the raw consumer-supplied `clientMetadata` so statically-registered/CIMD clients are not pushed into `offline_access` + `prompt=consent`); consumer-set values are never overwritten. DCR rejection now throws the new `RegistrationRejectedError` carrying the HTTP status, raw body, and submitted metadata — **breaking for direct `registerClient()` callers**: rejection no longer throws `OAuthError`, so update `instanceof` checks. `OAuthClientMetadata` gains a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `OAuthErrorCode` adds `InvalidRedirectUri`. The token-exchange, refresh, and Cross-App Access (`requestJwtAuthorizationGrant` / `exchangeJwtAuthGrant`) paths now throw the new `InsecureTokenEndpointError` for a non-`https:` token endpoint (`localhost` / `127.0.0.1` / `::1` exempt), and `auth()` surfaces it on the refresh branch instead of silently re-authorizing.

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 PR title and description describe a SEP-2350 scope step-up feature (onInsufficientScope on the HTTP transports, exported computeScopeUnion()/InsufficientScopeError, an examples/scoped-tools story, conformance withOAuthRetry wiring, and client-auth:step-up:* e2e scenarios) that does not exist anywhere in this diff or at HEAD (52b1cfc) — the actual change is the SEP-837/SEP-2207 DCR-hygiene work described by this changeset and the HEAD commit message. Please rewrite the title/description to match the DCR-hygiene change actually contained here (or push the intended step-up commits); as-is the Motivation, How-Has-This-Been-Tested, and Breaking-Changes sections describe a different change and will mislead reviewers and the merge-commit history.

Extended reasoning...

What the mismatch is. The PR title ("feat(client): SEP-2350 scope step-up — union, retry cap, superset-gated refresh bypass") and the entire PR description claim a scope step-up feature: an onInsufficientScope option on both HTTP transports, exported computeScopeUnion() and InsufficientScopeError, an examples/scoped-tools headless story with a hardened demo AS, conformance-fixture withOAuthRetry wiring, and client-auth:step-up:* e2e scenarios. None of that content is present in this PR. The diff (and this changeset, .changeset/auth-dcr-hygiene.md) implements SEP-837/SEP-2207 Dynamic Client Registration hygiene instead: resolveClientMetadata() with application_type/grant_types defaults, RegistrationRejectedError, InsecureTokenEndpointError + assertSecureTokenEndpoint(), OAuthErrorCode.InvalidRedirectUri, and the matching e2e/conformance updates.\n\nStep-by-step verification at the PR head. (1) The changeset bot reports the PR's latest commit as 52b1cfca1d5a625d82aec89f373d0db4be4ffca3, which is exactly HEAD of this changeset, so this is not a stale-checkout artifact. (2) Grepping packages/client/src at that commit for computeScopeUnion, InsufficientScopeError (the transport-level class), onInsufficientScope, or _stepUpAuthorize returns no matches. (3) examples/scoped-tools does not exist, and the 18 changed files include no streamableHttp.ts, sse.ts, or test/conformance/src changes. (4) The new e2e scenarios are client-auth:dcr:*, client-auth:token-endpoint:https-guard, client-auth:refresh:rotation-handling, and client-auth:scope:offline-access-gate — not the client-auth:step-up:* rows the description claims under How Has This Been Tested. (5) The expected-failures edits remove SEP-837 application_type baseline entries; auth/scope-step-up remains baselined on the 2026-07-28 leg, contradicting the description's claim that it "burns" with this change. (6) The HEAD commit message itself reads "feat(client): SEP-837/2207 — application_type heuristic, grant_types default, https token-endpoint guard", matching the diff.\n\nWhy nothing else catches it. The earlier claude[bot] review comments referencing _stepUpAuthorize at an older head (8b2f271) indicate the branch previously carried the SEP-2350 work and was later force-pushed to the DCR-hygiene stack without updating the PR title/description. CI passes because the code that is present is internally consistent — there is no automated check that the description matches the diff.\n\nImpact. Reviewers approving this PR based on the description would be approving claims about transport behavior, breaking-change posture ("onInsufficientScope defaults to 'reauthorize'"), and testing evidence (step-up e2e scenarios, the conformance auth/scope-step-up burn) that the diff does not back. On squash-merge the description typically becomes the merge-commit body, permanently misdescribing what landed; future readers tracing SEP-2350 vs SEP-837/2207 history will be pointed at the wrong commit.\n\nHow to fix. Either (a) rewrite the PR title and description to describe the SEP-837/SEP-2207 DCR-hygiene change actually contained — the changeset text and the HEAD commit message are accurate starting points — including correcting the testing section to the client-auth:dcr:* / token-endpoint:https-guard scenarios and the SEP-837 expected-failures burn-down, or (b) push the intended SEP-2350 step-up commits to this branch if this PR was meant to carry them. This is an editorial/process issue rather than a code defect, but the description-vs-diff mismatch is total and should be resolved before merge.

Comment on lines +82 to +96
/** HTTP status code returned by the registration endpoint. */
public readonly status: number;
/** Raw response body text (typically an RFC 7591 error JSON document). */
public readonly body: string;
/** The exact client metadata that was POSTed (after SDK defaults were applied). */
public readonly submittedMetadata: OAuthClientMetadata;

constructor(args: { status: number; body: string; submittedMetadata: OAuthClientMetadata }) {
super(`Dynamic Client Registration rejected (HTTP ${args.status}): ${args.body}`);
this.status = args.status;
this.body = args.body;
this.submittedMetadata = args.submittedMetadata;
}
}

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 base-class OAuthClientFlowError JSDoc a few lines above still says "In the release that introduces it no subclass exists yet — the guard … will not match anything until the first behavior change ships", but this PR adds two concrete subclasses (RegistrationRejectedError, InsecureTokenEndpointError) in this same file and migration-SKILL.md now documents three subclasses with instanceof guidance. Doc-only nit: drop or rewrite that second @remarks sentence.

Extended reasoning...

What is stale. The @remarks on the OAuthClientFlowError base class near the top of packages/client/src/client/authErrors.ts reads: "Nothing in the SDK throws this base class directly. In the release that introduces it no subclass exists yet — the guard is a forward-compat hook and will not match anything until the first behavior change ships." The first sentence is still true; the second is now plainly false.

Why this PR makes it false. This PR adds two concrete subclasses to the same module, a few lines below that remark: RegistrationRejectedError (thrown by registerClient() when the AS rejects DCR) and InsecureTokenEndpointError (thrown by assertSecureTokenEndpoint() on the exchange/refresh/cross-app paths). Together with the pre-existing IssuerMismatchError (added in 318fca3), the family now has three shipped, actively-thrown members — so the instanceof OAuthClientFlowError guard the remark says "will not match anything" matches real errors the SDK throws on common paths.

Why it contradicts the PR's own docs. The migration-SKILL.md hunk added in this same PR publishes a three-row table of OAuthClientFlowError subclasses and their throw sites and explicitly recommends that consumers dispatch on them ("all extend OAuthClientFlowError, not OAuthError"). A reader who hovers the base class in their IDE sees JSDoc claiming the family is empty, while the migration guide shipped by the same PR tells them to catch its members.

Step-by-step. (1) Commit 874ca25 introduced OAuthClientFlowError with this remark, which was accurate at that moment (no subclasses). (2) Commit 318fca3 added IssuerMismatchError, making the remark stale. (3) This PR adds RegistrationRejectedError and InsecureTokenEndpointError directly below the remark and adds the consumer-facing subclass table — extending exactly the pattern the remark denies, in the same file, without updating it.

Impact. Documentation-only; no behavioral effect. The cost is reader confusion: the JSDoc actively tells consumers the catch-all guard is inert, which could discourage them from using the very instanceof dispatch the migration guide recommends.

How to fix. One-sentence JSDoc edit: delete the second @remarks sentence, or rewrite it to reflect reality, e.g. "Concrete subclasses currently include IssuerMismatchError, RegistrationRejectedError, and InsecureTokenEndpointError; catch the base class to handle the whole family." Since this PR touches the same file and adds the subclasses, this is the natural diff in which to make the change (the staleness technically began one commit earlier, but the PR directly compounds it).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants