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.