Skip to content

[v1.x] fix(client): use resource_metadata from WWW-Authenticate on GET SSE 401 - #2962

Open
kimnamu wants to merge 1 commit into
modelcontextprotocol:v1.xfrom
kimnamu:fix/v1x-get-401-resource-metadata
Open

kimnamu wants to merge 1 commit into
modelcontextprotocol:v1.xfrom
kimnamu:fix/v1x-get-401-resource-metadata

Conversation

@kimnamu

@kimnamu kimnamu commented Oct 6, 2026

Copy link
Copy Markdown

Thanks for keeping v1.x patched alongside v2 (1.32.1 today), and for #1710, which already fixed this on main.

Fixes #1450 on v1.x. When the 401 comes from the GET SSE request, v1.x ignores the WWW-Authenticate header, so discovery can fall back to the MCP server origin instead of the authorization server named in the advertised resource_metadata. This backports the GET-path lines from #1710.

What

v1.x @ ff07b00 This PR
auth() options after a GET 401 with resource_metadata and scope resourceMetadataUrl: undefined, scope: undefined advertised URL and scope
Authorization redirect (end-to-end repro below) https://mcp.example.com/authorize https://auth.example.com/oauth2/authorize
finishAuth() token request POST https://mcp.example.com/token POST https://auth.example.com/oauth2/token
401 on POST (send()) reads the header unchanged
GET 401 without WWW-Authenticate keeps values from an earlier 401 unchanged (same values, measured)

How

_startOrAuthSse() now stores resourceMetadataUrl and scope before calling _authThenStart(). It is the guarded block SSEClientTransport already uses on v1.x (src/client/sse.ts L183-186) and the one #1710 added to this method on main (e563e63b). scope is assigned the way send() does it on v1.x; the scope union on main belongs to v2's step-up flow. No other file changes besides the test and a changeset.

start() makes no request on v1.x, so this branch is reached from resumeStream(), send() with a resumptionToken, the standalone GET after notifications/initialized, and SSE reconnects (all four measured below). If the first 401 a transport sees comes from one of those, discovery runs without the advertised URL.

I found no other open PR for this on v1.x; #1472 targeted main and was closed in favor of #1710.

Tests

One regression test next to the existing 403 upscoping test, in the same style (spy on auth, assert its options). It goes through resumeStream() because start() alone never fetches on v1.x.

# new test kept, source change reverted (v1.x @ ff07b00)
 FAIL  test/client/streamableHttp.test.ts > StreamableHTTPClientTransport > passes resource_metadata from WWW-Authenticate to auth on 401 during GET SSE request
AssertionError: expected "auth" to be called with arguments: [ { …(9) }, ObjectContaining{…} ]
  1st auth call:
-   ObjectContaining {
-     "resourceMetadataUrl": "http://example.com/resource",
-     "scope": "new_scope",
+   {
+     "fetchFn": [Function fetch],
+     "resourceMetadataUrl": undefined,
+     "scope": undefined,
+     "serverUrl": "http://localhost:1234/mcp",
      Tests  1 failed | 38 passed (39)

# with the fix
test/client/streamableHttp.test.ts   Tests  39 passed (39)
npm test                             Test Files  56 passed (56)   Tests  1853 passed (1853)
npm run test:e2e                     Test Files  25 passed (25)   Tests  1135 passed (1135)
npm run lint / npm run typecheck / npm run build: exit 0
End-to-end repro (real auth(), mocked network, not committed)

The GET returns 401 with WWW-Authenticate: Bearer resource_metadata="https://auth.example.com/.well-known/oauth-protected-resource/tenant-a". That document names https://auth.example.com as the authorization server. Every other URL returns 404. The client calls resumeStream('evt-1'), then finishAuth('code-123').

===== v1.x @ ff07b00 =====
authorize redirect: https://mcp.example.com/authorize
--- requests during resumeStream ---
GET https://mcp.example.com/mcp
GET https://mcp.example.com/.well-known/oauth-protected-resource/mcp
GET https://mcp.example.com/.well-known/oauth-protected-resource
GET https://mcp.example.com/.well-known/oauth-authorization-server
GET https://mcp.example.com/.well-known/openid-configuration
--- requests during finishAuth ---
GET https://mcp.example.com/.well-known/oauth-protected-resource/mcp
GET https://mcp.example.com/.well-known/oauth-protected-resource
GET https://mcp.example.com/.well-known/oauth-authorization-server
GET https://mcp.example.com/.well-known/openid-configuration
POST https://mcp.example.com/token
finishAuth threw: HTTP 404: Invalid OAuth error response: SyntaxError: Unexpected token 'o', "not found" is not valid JSON. Raw body: not found

===== with this PR =====
authorize redirect: https://auth.example.com/oauth2/authorize
--- requests during resumeStream ---
GET https://mcp.example.com/mcp
GET https://auth.example.com/.well-known/oauth-protected-resource/tenant-a
GET https://auth.example.com/.well-known/oauth-authorization-server
--- requests during finishAuth ---
GET https://auth.example.com/.well-known/oauth-protected-resource/tenant-a
GET https://auth.example.com/.well-known/oauth-authorization-server
POST https://auth.example.com/oauth2/token

The other three entry points, with auth spied. The 401 carries resource_metadata="https://auth.example.com/prm", scope="s1". For after-initialized, notifications/initialized gets a 202 and the GET that follows gets the 401. For reconnect, that GET returns an SSE stream that closes and the reconnect GET gets the 401.

===== v1.x @ ff07b00 =====
send-resumptionToken: requests=GET,GET auth calls=1 resourceMetadataUrl=undefined scope=undefined
after-initialized: requests=POST,GET,GET auth calls=1 resourceMetadataUrl=undefined scope=undefined
reconnect: requests=POST,GET,GET,GET auth calls=1 resourceMetadataUrl=undefined scope=undefined
===== with this PR =====
send-resumptionToken: requests=GET,GET auth calls=1 resourceMetadataUrl=https://auth.example.com/prm scope=s1
after-initialized: requests=POST,GET,GET auth calls=1 resourceMetadataUrl=https://auth.example.com/prm scope=s1
reconnect: requests=POST,GET,GET,GET auth calls=1 resourceMetadataUrl=https://auth.example.com/prm scope=s1

Header-less GET 401 after a POST 401 that carried resource_metadata="https://auth.example.com/prm", scope="a", same output before and after:

GET-401-no-header auth args: resourceMetadataUrl=https://auth.example.com/prm scope=a (auth calls=2)

AI disclosure: Claude (via Claude Code) assisted with the reproduction, the patch and this description.

…T SSE 401

StreamableHTTPClientTransport._startOrAuthSse() went straight to
_authThenStart() on a 401, so auth() and a later finishAuth() ran
discovery without the resource_metadata URL the server advertised.
When protected resource metadata is only reachable through that URL,
discovery falls back to the MCP server origin as the authorization
server, and the authorization redirect and token request go there.

Store resourceMetadataUrl and scope first, as send() and
SSEClientTransport already do on v1.x and as main does since modelcontextprotocol#1710.

Fixes modelcontextprotocol#1450

Co-Authored-By: Claude <noreply@anthropic.com>
@kimnamu
kimnamu requested a review from a team as a code owner October 6, 2026 00:00
@changeset-bot

changeset-bot Bot commented Oct 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 17e1349

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

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 Oct 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@modelcontextprotocol/sdk@2962

commit: 17e1349

@claude claude Bot added the v1 Issues / PRs related to v1.x label Oct 6, 2026

This branch has not been deployed

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

Labels

v1 Issues / PRs related to v1.x

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant