fix: accumulate OAuth scopes on 401/403 instead of overwriting#1657
fix: accumulate OAuth scopes on 401/403 instead of overwriting#1657rechedev9 wants to merge 6 commits intomodelcontextprotocol:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 7e2cf80 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
@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 { |
There was a problem hiding this comment.
seems like this should be in core rather than defined in 2 places.
There was a problem hiding this comment.
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.
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
cd84edd to
a4b48e1
Compare
Changes in this updateRebased on Source changes
Test improvements
Verification
|
|
@claude review |
|
Pushed follow-up commit Addressed the latest review comments and the adjacent 401 metadata issue:
Verified locally and again via the pre-push hook:
|
|
@claude review |
felixweinberger
left a comment
There was a problem hiding this comment.
Back to your queue to address claude's finding above, looks legit to me.
Summary
this._scope = scopeassignments with amergeScopes()utility that unions existing and incoming scope strings (space-separated,Set-based deduplication, insertion-order stable)StreamableHTTPClientTransport(lines 520, 553) andSSEClientTransport(lines 167, 281)Root cause
The 401/403 handlers overwrote
this._scopewith only the scope from the currentWWW-Authenticateheader. With per-operation scopes (e.g.,initfor initialize,mcp:tools:readfor 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:Set<string>(deduplicates, preserves insertion order)undefinedwhen the result is empty (matches_scope?: stringsemantics)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 compatibilitysse.test.ts: scope accumulation in EventSource reconnect and send pathsAll 265 client tests pass. Typecheck and lint pass.
Fixes #1582