Skip to content

fix: accumulate OAuth scopes on 401/403 instead of overwriting#1657

Open
rechedev9 wants to merge 6 commits into
modelcontextprotocol:mainfrom
rechedev9:fix/scope-overwrite-infinite-reauth
Open

fix: accumulate OAuth scopes on 401/403 instead of overwriting#1657
rechedev9 wants to merge 6 commits into
modelcontextprotocol:mainfrom
rechedev9:fix/scope-overwrite-infinite-reauth

Conversation

@rechedev9
Copy link
Copy Markdown
Contributor

Summary

  • Replaces raw this._scope = scope assignments with a mergeScopes() utility that unions existing and incoming scope strings (space-separated, Set-based deduplication, insertion-order stable)
  • Fixes both StreamableHTTPClientTransport (lines 520, 553) and SSEClientTransport (lines 167, 281)
  • Prevents infinite re-authorization loops when an MCP server uses per-operation progressive authorization per RFC 6750 §3.1

Root cause

The 401/403 handlers overwrote this._scope with only the scope from the current WWW-Authenticate header. With per-operation scopes (e.g., init for initialize, mcp:tools:read for tools/list), re-authorizing for the new scope dropped coverage of all prior scopes, causing an infinite loop between operations.

Approach

A module-level unexported mergeScopes(existing, incoming) function:

  1. Splits both scope strings on whitespace
  2. Unions via Set<string> (deduplicates, preserves insertion order)
  3. Returns undefined when the result is empty (matches _scope?: string semantics)

Duplicated in both transport files to keep each self-contained — no new shared modules or public API surface.

Testing

Added 8 regression tests:

  • streamableHttp.test.ts: mergeScopes edge cases, multi-scope accumulation across 401→403 sequences, circuit-breaker compatibility
  • sse.test.ts: scope accumulation in EventSource reconnect and send paths

All 265 client tests pass. Typecheck and lint pass.

Fixes #1582

@rechedev9 rechedev9 requested a review from a team as a code owner March 10, 2026 17:02
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 10, 2026

🦋 Changeset detected

Latest commit: 7e2cf80

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 Patch
@modelcontextprotocol/core Patch

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
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 10, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1657

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1657

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1657

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1657

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1657

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1657

commit: 7e2cf80

@felixweinberger felixweinberger added the auth Issues and PRs related to Authentication / OAuth label Mar 27, 2026
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

* Returns undefined when the resulting set is empty.
* Preserves insertion order of first occurrence for determinism.
*/
function mergeScopes(existing: string | undefined, incoming: string | undefined): string | undefined {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

seems like this should be in core rather than defined in 2 places.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

LGTM — straightforward scope-accumulation fix with good test coverage.

Extended reasoning...

Overview

This PR modifies 4 scope assignment lines across sse.ts and streamableHttp.ts, replacing direct overwrites (this._scope = scope) with a mergeScopes() call that unions existing and incoming OAuth scopes via a Set. The function is duplicated in both transport files (no new shared module). Eight regression tests cover accumulation across 401/403 sequences, deduplication, undefined-preservation, and circuit-breaker compatibility.

Security risks

The change touches OAuth auth code paths, but the modification is strictly conservative — it accumulates scopes instead of discarding them, which prevents the infinite re-auth loop described in the root cause. The mergeScopes function performs no network I/O and has no injection surface. All three bug reports from the bug hunting system are pre-existing issues that predate this PR.

Level of scrutiny

The actual code change is minimal (4 one-line substitutions plus a 10-line pure utility function duplicated in two files). The logic is easy to verify by inspection: split on whitespace, union via Set, join back. The test suite is thorough and covers the key scenarios. This warrants a standard review rather than deep scrutiny.

Other factors

  • The changeset file is properly formatted for a patch release.
  • The PR cleanly fixes issue #1582.
  • No CODEOWNERS file restrictions appear to apply.
  • No outstanding reviewer comments to address.
  • The pre-existing bugs (fetchWithInit inconsistency on 403, unconditional resourceMetadataUrl overwrite, SSE missing circuit breaker) are valid observations but are out of scope for this PR and should be tracked separately.

Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts Outdated
Comment thread packages/client/src/client/sse.ts Outdated
Replace direct this._scope = scope assignments with mergeScopes() that unions
existing and incoming scope strings via Set deduplication. Prevents infinite
re-auth loops when servers use per-operation progressive authorization
(RFC 6750 §3.1).

Fixes modelcontextprotocol#1582
@rechedev9 rechedev9 force-pushed the fix/scope-overwrite-infinite-reauth branch from cd84edd to a4b48e1 Compare March 27, 2026 20:28
@rechedev9
Copy link
Copy Markdown
Contributor Author

Changes in this update

Rebased on upstream/main and reworked the implementation + tests:

Source changes

  • 5 merge sites (vs 4 in the original): also fixed the _startOrAuthSse 401 handler at streamableHttp.ts:240 which was overwriting scopes identically to the other sites
  • mergeScopes() remains duplicated in each transport file (no shared module), matching the original design intent

Test improvements

  • Refactored streamableHttp.test.ts tests to use 403 paths instead of 401 paths for scope accumulation assertions — the 401 path goes through handleOAuthUnauthorizedauth() as an internal ESM call, which vi.spyOn on the module namespace cannot intercept
  • Used minimal AuthProvider (not OAuthClientProvider) in the SSE test to avoid adaptOAuthProvider wrapping, allowing direct UnauthorizedError-based scope inspection
  • Extracted shared test helpers within the mergeScopes describe block: testMessage, getScope()/setScope(), sortedTokens(), and beforeEach/afterEach for the auth spy — reducing boilerplate

Verification

  • 319/319 client tests pass
  • Typecheck clean

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment thread .changeset/fix-scope-overwrite-infinite-reauth.md
Comment thread packages/client/test/client/streamableHttp.test.ts
@rechedev9
Copy link
Copy Markdown
Contributor Author

Pushed follow-up commit 731ffae.

Addressed the latest review comments and the adjacent 401 metadata issue:

  • preserve resourceMetadataUrl when a later 401 omits resource_metadata
  • add @modelcontextprotocol/core to the changeset because mergeScopes is now exported there
  • make the auth spies restore in streamableHttp tests deterministic with try/finally
  • add regression coverage for repeated 401s in both streamableHttp and sse

Verified locally and again via the pre-push hook:

  • focused client/core tests
  • pnpm -r typecheck
  • pnpm -r build
  • pnpm -r lint

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment thread packages/client/src/client/streamableHttp.ts
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

Back to your queue to address claude's finding above, looks legit to me.

@localden
Copy link
Copy Markdown
Contributor

This is also covered in #1624 (@SamMorrowDrums) which will cover the SEP-based implementation

@SamMorrowDrums
Copy link
Copy Markdown

@localden I need to update that PR really. I'm sure it's pretty stale. Will check next week.

Also, need to check because this PR might still be directionally right because I think mine is wrong side of the advice (server side adding scope union where your clarification on the expected behaviour was that it should be client side and server should not have to introspect token for active scopes).

Let me get the PR open for review and double check, it has been a while.

@localden
Copy link
Copy Markdown
Contributor

Following up on the SEP side - this PR now has a spec home in SEP-2350 (merged), which formalizes client-side scope accumulation. Can we link it from the PR description and the mergeScopes JSDoc so the next person knows why this exists?

One gap I noticed cross-checking against the SEP: _scope only accumulates from WWW-Authenticate headers. The spec says the client should union with "previously requested" scopes, which includes whatever the first auth round derived from PRM scopes_supported or clientMetadata.scope (the SEP-835 fallback at auth.ts:669). Right now that resolved scope never makes it back into _scope, so:

  1. First auth resolves to [a, b, c] from PRM, _scope stays undefined
  2. Server challenges with scope="d" per RFC 6750 section 3.1
  3. mergeScopes(undefined, "d") returns "d", re-auth requests only d, drops a b c

That's the same loss the PR is fixing for the challenge-to-challenge case, just one hop earlier. IMO the smallest fix is having auth() surface resolvedScope so the transport can seed _scope after the first successful auth. Alternatively we merge provider.clientMetadata.scope into the accumulator on construction, but that misses the PRM-derived case.

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

Labels

auth Issues and PRs related to Authentication / OAuth

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scope overwrite in 403 upscoping prevents progressive authorization for servers with per-operation scopes

4 participants