Repository navigation
Description
What happened?
Summary
A tool handler can discover mid-execution that the user must re-authorize (not merely grant more scopes), and today it has no way to turn that into an HTTP 401 + WWW-Authenticate challenge. The SDK converts the throw into a 200 + isError tool result, so the client doesn't start its re-auth flow until a later request.
This is the 401 invalid_token counterpart of #2957 (mid-handler 403 insufficient_scope), which I raised there in #2957 (comment). Filing separately so it can be tracked whether or not it ends up sharing #2957's mechanism.
Use case
Servers that proxy an upstream API with delegated OAuth: the MCP access token maps to an upstream refresh token stored server-side. The upstream provider can revoke that authorization at any time — a per-user connection cap evicting the oldest connection, or the user disconnecting the app in the upstream provider's settings. The MCP access token stays valid; the connection is unusable until the user re-authorizes.
The server usually only learns this from the upstream call inside a tool (the cached upstream access token still looks valid; the upstream API rejects it; the refresh is then rejected). A pre-dispatch check (requireBearerAuth, scopeChallenge) can't see it without an extra upstream call on every request.
Current behaviour
McpServer's tools/call handler converts any throw into CallToolResult { isError: true } (only UrlElicitationRequired is rethrown, as a JSON-RPC error, on the 2025 era). So the response is HTTP 200 and the model sees an error it can't act on. The client is challenged only on its next request, once the server's pre-dispatch check sees the cleared upstream link.
Observed client behaviour when the server does answer 401 + WWW-Authenticate on that tools/call
With a workaround (below), on the first failing tool call:
| Client | What the user sees |
|---|---|
| Claude (web) | The tool card shows "Authentication required to use this tool" with a Connect button |
| Claude Code | The server is marked as needing authentication; the user re-authenticates via /mcp |
| MCP Inspector (v2.13, 2026-07-28 era) | Treated as auth_challenge / invalid_token; tries its refresh token, then "Session expired … re-authenticating" |
So clients already handle a 401 on a tools/call well — the gap is only that servers can't produce one from inside a tool.
Current workaround
Wrap the handler's fetch face (FetchLikeMcpHandler, accepted by toNodeHandler) for tools/call only, hold the complete Response, and swap in a 401 when the tool recorded a lost upstream link:
function reauthOnLostConnection(handler: FetchLikeMcpHandler, state: { lost: boolean }): FetchLikeMcpHandler {
return {
async fetch(request, options) {
const response = await handler.fetch(request, options);
const body = await response.arrayBuffer(); // hold the whole response
if (!state.lost) return new Response(body, response);
return new Response(JSON.stringify({ error: "invalid_token" }), {
status: 401,
headers: { "Content-Type": "application/json", "WWW-Authenticate": challengeHeader },
});
},
};
}It works on both protocol eras, but only because the server never sends anything mid-request (no progress or other notifications), it discards the SDK's result after the fact, and every server with delegated upstream auth has to rediscover it.
Proposal
A typed error a tool handler can throw, e.g.:
throw new AuthChallengeError({ status: 401, error: "invalid_token", errorDescription: "Upstream session expired, re-authentication required" });
// and, for #2957: { status: 403, error: "insufficient_scope", scopes: [...] }that tools/call rethrows (like UrlElicitationRequired), and the HTTP entry (createMcpHandler / the transports) answers with that status and a WWW-Authenticate header built the same way as requireBearerAuth's (including resource_metadata), on both protocol eras. If a message has already been sent on the response, fall back to an in-band JSON-RPC error, as discussed in #2957.
Retry safety: the handler should only throw this when the upstream call was rejected before processing (e.g. an upstream 401), so the client can safely retry the tool call after re-authorizing.
Alternatives considered
- URL-mode elicitation: the spec's tool for "authorize with a third party". It fits when upstream authorization is separate from MCP authorization; it doesn't fit servers where re-running MCP OAuth is the upstream re-link, and it depends on per-client elicitation support, whereas a 401 is handled by every client that supports MCP authorization.
- Checking upstream before every request: one extra upstream call per request against the upstream's rate limits.
What did you expect?
A typed error a tool handler can throw — e.g. AuthChallengeError({ status: 401, error: "invalid_token", errorDescription }), and for #2957 { status: 403, error: "insufficient_scope", scopes } — that tools/call rethrows (like UrlElicitationRequired), and the HTTP entry (createMcpHandler / the transports) answers with that status and a WWW-Authenticate header built the same way as requireBearerAuth's (including resource_metadata), on both protocol eras. If a message has already been sent on the response, fall back to an in-band JSON-RPC error, as discussed in #2957.
Retry safety: a handler should only throw this when the upstream call was rejected before processing (e.g. an upstream 401), so the client can safely retry the tool call after re-authorizing.
Code to reproduce
// Today: a tool that finds the user's upstream authorization revoked can only fail the call.
server.registerTool("list_items", { description: "…" }, async () => {
const res = await fetch(upstreamUrl, { headers: { Authorization: `Bearer ${upstreamToken}` } });
if (res.status === 401 && !(await refreshUpstream())) {
// Upstream revoked the user's authorization. The best we can do is throw,
// which the SDK turns into HTTP 200 + { isError: true } — not a 401 challenge.
throw new Error("Upstream authorization revoked; re-authorization required");
}
return { content: [{ type: "text", text: await res.text() }] };
});
// Workaround: wrap the handler's fetch face for tools/call, hold the whole response,
// and swap in a 401 when the tool recorded a lost upstream link.
function reauthOnLostConnection(handler: FetchLikeMcpHandler, state: { lost: boolean }): FetchLikeMcpHandler {
return {
async fetch(request, options) {
const response = await handler.fetch(request, options);
const body = await response.arrayBuffer();
if (!state.lost) return new Response(body, response);
return new Response(JSON.stringify({ error: "invalid_token" }), {
status: 401,
headers: { "Content-Type": "application/json", "WWW-Authenticate": challengeHeader },
});
},
};
}SDK version
@modelcontextprotocol/server 2.3.1 (still present in 2.4.0), @modelcontextprotocol/node 2.1.1
Area
Auth
Activity
media-ctrl commentedon Nov 11, 2026
Agreed that this is not the same case as #2957, and the difference matters for what a client should do rather than only for how the server signals it.
The two recoveries are not interchangeable. A 403
insufficient_scopeis incremental consent: same grant, wider scope, the user approves and the call can be retried with the same identity. A 401invalid_tokenafter an upstream revocation is grant invalidation: the authorization behind the token is gone, so there is nothing to retry with. A client that treats the second as the first will request scopes it already has, and if its transport retries once on 401 it will loop, because the new access token is minted from a connection that is still revoked.On the implementation, the escape hatch already exists one branch above the conversion. At
packages/server/src/server/mcp.ts:317aProtocolErrorwithProtocolErrorCode.UrlElicitationRequiredis re-thrown instead of being wrapped, which is how that case avoids becoming anisErrorresult at:341. A second error class on that same branch, carrying the resource metadata URL and an optional scope, would need no new plumbing and stays backwards compatible because every other throw keeps the existing path at:320.For the transport side, the window is open at the point the handler throws: nothing has been written to the response yet, so a 401 with a
WWW-Authenticatechallenge can still be emitted before the JSON-RPC envelope is committed. That is the same window the mid-handler scope challenge uses, which is why I would expect the two to share the mapping rather than have separate ones.Two design cautions from the delegated-OAuth case you describe. First, the challenge should mean "re-authorize", not "retry". Including an
error_descriptionthat names the upstream connection as revoked is what lets a client tell the user something actionable instead of showing a bare 401. Second, do not forward the upstream provider's error text, since that is where account identifiers and provider-specific detail leak into a result the client may display or log.One open question that decides how useful this is in practice: should the server be required to distinguish "upstream token expired, refresh succeeded" from "upstream grant revoked, refresh rejected"? The first is invisible and recoverable, the second needs the user. Today both surface as invalid_token from the same call site, and a server that cannot tell them apart will trigger re-authorization on transient upstream failures, which trains users to click through consent prompts.
0xamlab commentedon Nov 11, 2026
Confirmed on
main@919bb8ewith a live upstream-401/failed-refresh reproduction. Both protocol eras returned HTTP 200,CallToolResult.isError: true, and noWWW-Authenticate. The 2026 path did so before commit (JSON) and after a progress notification (SSE); the 2025 fallback was already SSE in both cases. A pre-dispatch invalid-token control returned 401 correctly. The handler throw is consumed atpackages/server/src/server/mcp.ts:316-321, sopackages/server/src/server/middleware/bearerAuth.ts:203-221is not reached. Could I be assigned to help align the typed auth/public API and #2957’s committed-response behavior before opening a PR?buptliuhs commentedon Nov 11, 2026
@media-ctrl
Thanks, agreed: 403 widens an existing grant, while 401 here means the grant is gone.
On your open question, the server can tell the two cases apart, and only the second ever reaches the client:
expires_inand refreshes it before it expires, so calls don't fail on expiry. It only happens when the token stops working before its expiry, for example because it was refreshed out of band or for a reason specific to that OAuth service. In that case the upstream API returns 401, and the server makes one extra call to the upstream OAuth server's token endpoint, refreshes, and retries once. The client never sees it.invalid_grant): the server answers 401 +WWW-Authenticatewith its ownerror_description. It never forwards the upstream's text, which in our case echoes the rejected token.Transient upstream failures (5xx, timeouts) stay ordinary tool errors and never trigger a challenge. A rejected refresh is never retried, and re-authorization always goes through the upstream's sign-in again, so there's no loop.
Today we get the 401 out of a tool call by buffering the whole response and swapping the
isErrorresult for the challenge. That only works because we're stateless and never stream mid-request. A second error class on the branch that already re-throwsUrlElicitationRequired, sharing theWWW-Authenticatemapping with #2957, would remove the need for that.buptliuhs commentedon Nov 11, 2026
@0xamlab Thanks for reproducing this on current
main. Happy for you to take it, if the maintainers agree.One thing to settle first: what happens once the response is already committed (streaming has started)? Before that, an opt-in error class re-thrown next to
UrlElicitationRequiredcould produce the 401. After it, the status can't change, so that case probably needs to be decided together with #2957.We work around this today by buffering the response and swapping the
isErrorresult for a 401, which only works because we never stream mid-request. Happy to test a branch against it.media-ctrl commentedon Nov 11, 2026
Thanks, that closes my question. If the two cases are distinguishable server-side and only a genuine revocation reaches the client, the client contract can be stated simply: 401 with a challenge means re-authorize, 403
insufficient_scopemeans request wider scope on the same grant.One request from the operator side: make the discriminator machine-readable on the challenge, not only in
error_description. Clients parse free text badly, and the failure mode there is a client retrying a grant that no longer exists. Anerrorcode plus the protected-resource metadata pointer covers it.On the committed-response question: for a server that does not stream partial content mid-call, an opt-in error class re-thrown next to
UrlElicitationRequiredremoves the buffering workaround entirely. Ours falls in that group, so I am happy to test a branch against a real upstream revocation if that helps.0xamlab commentedon Nov 11, 2026
Agreed. I’ll align with #2957 on one
AuthChallengeErrorcarryingstatus, RFC 6750error, optionalscopes, and a sanitized optional description.resource_metadatashould come from the verified auth context, not arbitrary handler input. Before commitment, reusebearerAuthChallengeResponseto send 401/403 +WWW-Authenticate: 401/invalid_tokenmeans re-authorize; 403/insufficient_scopemeans widen the grant. After commitment, emit an in-band JSON-RPC error with the same discriminator and metadata indata; never rewrite the HTTP status or imply automatic retry. I’ll wait for maintainer alignment on that committed-path contract before opening a PR.media-ctrl commentedon Nov 11, 2026
Agreed on all four parts of that contract. Two implementation notes from a server that delegates OAuth to a set of upstream providers, then I will take you up on the test offer.
Resource metadata has to come from the token that is actually in play, not from server config. A server that fronts several upstreams has several authorization servers, and the one that must be re-authorized is the one that minted the current access token. Deriving
resource_metadatafrom the verified auth context is the only version of that which does not send the user to the wrong sign-in page. It also means the value has to be allowed to be absent, because not every upstream publishes RFC 9728 metadata and a fabricated pointer is worse than none.Keep
scopesoff aninvalid_tokenchallenge. On a 403 it tells the client what to request. On a 401 the grant is gone, and a scope list next to it invites exactly the failure this issue is about: widening a grant that no longer exists. Same reason I would constrainstatusto the two values, so a403can never arrive carryinginvalid_tokenor the reverse.On the committed path, the discriminator is the whole contract. A client that receives an in-band error after streaming has started cannot present a challenge, so the only correct behaviour is to stop and surface a re-authorization prompt, never retry. That means the in-band form needs the same machine-readable pair as the pre-commit one, under
data, and it needs to be a stable documented value rather than something a client matches against the description string. Worth saying in the spec text that the in-band error is terminal for that call, because the reflex in most client SDKs is to treat a JSON-RPC error as retryable.Test offer stands. Ours is stateless and never streams mid-call, so we can produce a mid-session revocation from the upstream side on demand (access token revoked out of band, refresh rejected with
invalid_grant) and check both the pre-commit 401 and the committed in-band path. Say when there is a branch and I will run it against that exact case and report what the client actually sees, including whether the token is re-minted from a connection that is still revoked.