MCP OAuth: refresh_token request omits `scope`, so the refreshed token can come back with the wrong scope and the server 401s

Status Open
Reported on v2.1.246
Maintainer reply None cached
Activity 0 comments · opened Aug 26, 2026

Summary

The refresh_token request sent by Claude Code's MCP OAuth client omits the scope
parameter
. Whenever the authorization server does not reproduce the original grant's scope
for a scope-less refresh, the refreshed access token comes back with a different scope from
the one the login obtained, the resource server rejects it, and Claude Code reports the MCP
server as needing authentication. A claude mcp login fixes it, and then it breaks again at
the next token expiry, so it presents as "this MCP server's auth keeps breaking, roughly
hourly".

Against Microsoft Entra, with a server whose client and resource are the same app
registration, the effect is reliable:

| how the token was obtained | scp claim in the token | resource server |
|---|---|---|
| interactive login (sends scope=<api>/<scope> offline_access) | mcp.access | 200 |
| Claude Code's refresh (sends no scope) | .{some-guid} | 401 |
| manual refresh with scope=<api-guid>/mcp.access | mcp.access | 200 |

The last row is the same refresh token and the same endpoint, differing only in that the
scope parameter is present. So this is not a token-lifetime, tenant, or server problem.

Direct evidence of the omission

I ran an MCP server that is also its own OAuth authorization server, logging the parameters of
every request. Nothing else changed between these lines:

GET  /authorize  scope="probe.access offline_access"   <- login: scope present
POST /token CODE scope=null                            -> 200
POST /mcp        -> 401                                (access token lapsed)
POST /token REFRESH  scope=null                        <- refresh: scope ABSENT
POST /mcp        -> 200                                (my test AS is lenient, so it recovered)

My local AS reissues the original scope for a scope-less refresh, so it recovered. Entra does
not: it falls back to a .default-style grant, yielding scp = .{guid} instead of the named
scope. A resource server that checks for its named scope then returns 401, correctly.

RFC 6749 §6 says scope on a refresh is optional and, if omitted, the issued token must have
the same scope as the original grant. Entra's behaviour here is arguably non-conformant, but
sending the scope explicitly is the portable fix and is what every other client I checked does.

Why this is easy to misdiagnose

Two related behaviours turn a wrong-scope 401 into something that looks like a broken token,
and they cost me most of a day:

  1. The failure surfaces as MCP server "X" requires re-authorization (token expired) and then

needs authentication, although the stored token is well within its lifetime. The debug log
is more honest: Server returned 401 after re-authentication (code:
CLIENT_HTTP_AUTHENTICATION)
. The user-facing message points at expiry, which is the one
thing that is fine.

  1. ~/.claude/mcp-needs-auth-cache.json is keyed by server name only, with no config hash,

and each failed attempt rewrites its timestamp. The entry carries ttlMs: 90000, but on a
machine with many concurrent claude sessions the sessions keep re-arming it for one
another, so the 90 s TTL never elapses and the server appears permanently disconnected. A
failure under one variant of a config also suppresses the server for sessions using a
different, healthy variant of the same name. (Headers are part of the credential cache key;
oauth.scopes is not, which is its own surprise.)

Separately, if a refresh genuinely fails with invalid_grant, the client deletes the stored
refresh token
and writes a marker with no TTL, so the server needs an interactive login even
after the token endpoint recovers, and it is never retried. Discarding the only recovery
credential on one HTTP error makes any transient blip permanent.

A plain connection failure writes no marker, so the suppression is specific to the auth path.

Reproduction

repro-server.mjs (attached below) is a dependency-free Node script that is both an MCP server
and its own OAuth AS. It logs the scope parameter of every /authorize and /token request,
and issues tokens it claims are valid for an hour but only honours for 20 s, so a client that
believes its token is good gets a surprise 401.

node repro-server.mjs &
claude mcp add-json probe '{"type":"http","url":"http://127.0.0.1:8899/mcp","oauth":{"scopes":"probe.access"}}'
claude mcp login probe        # /authorize auto-approves, no consent UI
sleep 25                      # let the 20 s token lapse
claude mcp list               # forces a refresh
# read the log: /authorize carries scope=..., POST /token REFRESH carries scope=null

To see the downstream 401 as well, make the resource endpoint require the scope: reject any
token whose recorded scope does not contain probe.access, and have /token treat a missing
scope as "grant nothing", which is what Entra effectively does for this shape of app.

Expected

  1. Send scope on the refresh_token request, using the value from the server's oauth.scopes

(or the scope recorded with the credential). This alone fixes the reported problem.

  1. Distinguish a scope/authorization 401 from an expiry 401 in the user-facing message. "Token

expired" when the token is valid sends people to the wrong place.

  1. Key the needs-auth marker by the same name|configHash used for the credential, do not

re-arm its TTL on repeated failures, and clear it when a refresh succeeds.

  1. Keep the refresh token after a failed refresh and retry on next use.

Environment

  • Claude Code 2.1.246
  • Node v22.23.1
  • Fedora 44, Linux 7.1.7
  • HTTP MCP server, oauth block in .mcp.json, Microsoft Entra as the authorization server,

client and resource being the same app registration, access-token lifetime ~60-90 min

<details>
<summary>repro-server.mjs</summary>

// Minimal MCP server that is also its own OAuth authorization server, so every
// token and every request is observable. No dependencies; Node 18+.
//   MODE=deny  -> answer the refresh with 400 invalid_grant (defect 1)
//   MODE=ok    -> refresh normally (defect 3)
import http from "node:http";
import { randomUUID } from "node:crypto";

const PORT = Number(process.env.PORT || 8899);
const ORIGIN = `http://127.0.0.1:${PORT}`;
const TTL_MS = Number(process.env.TTL_MS || 25000);        // real token validity
const ADVERTISED = Number(process.env.ADVERTISED || 3600); // what /token claims, so the
                                                           // client believes it is valid
const DENY = process.env.MODE === "deny";
const tokens = new Map(), refreshes = new Set();
let n = 0;
const log = (...a) => console.log(new Date().toISOString(), ...a);
const json = (res, code, obj) => {
  const b = JSON.stringify(obj);
  res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(b) });
  res.end(b);
};
const read = (req) => new Promise((r) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => r(d)); });
function mint() {
  const at = `at_${++n}`, rt = `rt_${n}_${randomUUID().slice(0, 6)}`;
  tokens.set(at, Date.now()); refreshes.add(rt);
  return { at, rt };
}

http.createServer(async (req, res) => {
  const url = new URL(req.url, ORIGIN), p = url.pathname;

  if (p === "/.well-known/oauth-authorization-server" || p === "/.well-known/openid-configuration")
    return json(res, 200, {
      issuer: ORIGIN, authorization_endpoint: `${ORIGIN}/authorize`,
      token_endpoint: `${ORIGIN}/token`, registration_endpoint: `${ORIGIN}/register`,
      response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"],
      code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"],
      scopes_supported: ["probe.access", "offline_access"],
    });

  if (p === "/register" && req.method === "POST") {
    const b = JSON.parse((await read(req)) || "{}");
    return json(res, 201, { client_id: "probe-client", redirect_uris: b.redirect_uris || [],
                            token_endpoint_auth_method: "none" });
  }

  if (p === "/authorize") {                        // auto-approve, no consent UI
    const rd = new URL(url.searchParams.get("redirect_uri"));
    rd.searchParams.set("code", "code_1");
    const st = url.searchParams.get("state"); if (st) rd.searchParams.set("state", st);
    log("GET /authorize  scope=" + JSON.stringify(url.searchParams.get("scope")) + "  resource=" + JSON.stringify(url.searchParams.get("resource")));
    res.writeHead(302, { location: rd.toString() }); return res.end();
  }

  if (p === "/token" && req.method === "POST") {
    const f = new URLSearchParams(await read(req));
    if (f.get("grant_type") === "refresh_token") {
      if (DENY) { log("POST /token refresh -> 400 invalid_grant  <-- the single failure"); 
                  return json(res, 400, { error: "invalid_grant" }); }
      const { at, rt } = mint(); log("POST /token REFRESH  scope=" + JSON.stringify(f.get("scope")) + "  -> 200 " + at);
      return json(res, 200, { access_token: at, token_type: "Bearer", expires_in: ADVERTISED, refresh_token: rt });
    }
    const { at, rt } = mint(); log("POST /token CODE     scope=" + JSON.stringify(f.get("scope")) + "  -> 200 " + at);
    return json(res, 200, { access_token: at, token_type: "Bearer", expires_in: ADVERTISED, refresh_token: rt });
  }

  if (p === "/mcp") {
    const t = (req.headers.authorization || "").replace(/^Bearer /, "");
    const born = tokens.get(t);
    if (!born || Date.now() - born >= TTL_MS) { log(req.method, "/mcp ->", 401, t || "(no token)"); 
                                                return json(res, 401, { error: "Unauthorized" }); }
    if (req.method !== "POST") { res.writeHead(405).end(); return; }
    const m = JSON.parse((await read(req)) || "{}");
    log("POST /mcp ->", m.method);
    if (m.method === "initialize")
      return json(res, 200, { jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2025-06-18",
        capabilities: { tools: {} }, serverInfo: { name: "probe", version: "1.0.0" } } });
    if (String(m.method || "").startsWith("notifications/")) { res.writeHead(202).end(); return; }
    if (m.method === "tools/list")
      return json(res, 200, { jsonrpc: "2.0", id: m.id, result: { tools: [{ name: "probe_ping",
        description: "Returns pong.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }] } });
    if (m.method === "tools/call")
      return json(res, 200, { jsonrpc: "2.0", id: m.id, result: { content: [{ type: "text", text: "pong" }] } });
    return json(res, 200, { jsonrpc: "2.0", id: m.id ?? null, result: {} });
  }
  json(res, 404, { error: "not_found" });
}).listen(PORT, "127.0.0.1", () => log(`up on ${ORIGIN}, MODE=${DENY ? "deny" : "ok"}, TTL_MS=${TTL_MS}`));

</details>

View original on GitHub ↗