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.

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