Skip to content

[v2] Let a tool handler answer 401 invalid_token (re-authorization needed), not only a 403 scope challenge #3173

Open

Description

@buptliuhs

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

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Nov 11, 2026
  2. media-ctrl commented on Nov 11, 2026

    @media-ctrl

    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_scope is incremental consent: same grant, wider scope, the user approves and the call can be retried with the same identity. A 401 invalid_token after 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:317 a ProtocolError with ProtocolErrorCode.UrlElicitationRequired is re-thrown instead of being wrapped, which is how that case avoids becoming an isError result 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-Authenticate challenge 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_description that 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.

  3. 0xamlab commented on Nov 11, 2026

    @0xamlab

    Confirmed on main@919bb8e with a live upstream-401/failed-refresh reproduction. Both protocol eras returned HTTP 200, CallToolResult.isError: true, and no WWW-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 at packages/server/src/server/mcp.ts:316-321, so packages/server/src/server/middleware/bearerAuth.ts:203-221 is not reached. Could I be assigned to help align the typed auth/public API and #2957’s committed-response behavior before opening a PR?

  4. buptliuhs commented on Nov 11, 2026

    @buptliuhs
    Author

    @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:

    • Upstream token no longer valid, refresh succeeds: this shouldn't come up normally. The server tracks the upstream token's expires_in and 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.
    • Upstream grant revoked (refresh rejected with invalid_grant): the server answers 401 + WWW-Authenticate with its own error_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 isError result 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-throws UrlElicitationRequired, sharing the WWW-Authenticate mapping with #2957, would remove the need for that.

  5. buptliuhs commented on Nov 11, 2026

    @buptliuhs
    Author

    @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 UrlElicitationRequired could 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 isError result for a 401, which only works because we never stream mid-request. Happy to test a branch against it.

  6. media-ctrl commented on Nov 11, 2026

    @media-ctrl

    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_scope means 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. An error code 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 UrlElicitationRequired removes 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.

  7. 0xamlab commented on Nov 11, 2026

    @0xamlab

    Agreed. I’ll align with #2957 on one AuthChallengeError carrying status, RFC 6750 error, optional scopes, and a sanitized optional description. resource_metadata should come from the verified auth context, not arbitrary handler input. Before commitment, reuse bearerAuthChallengeResponse to send 401/403 + WWW-Authenticate: 401/invalid_token means re-authorize; 403/insufficient_scope means widen the grant. After commitment, emit an in-band JSON-RPC error with the same discriminator and metadata in data; never rewrite the HTTP status or imply automatic retry. I’ll wait for maintainer alignment on that committed-path contract before opening a PR.

  8. media-ctrl commented on Nov 11, 2026

    @media-ctrl

    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_metadata from 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 scopes off an invalid_token challenge. 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 constrain status to the two values, so a 403 can never arrive carrying invalid_token or 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions