Skip to main content

Documentation Index

Fetch the complete documentation index at: https://claude.com/docs/llms.txt

Use this file to discover all available pages before exploring further.

Not every tool on an MCP server needs the user’s identity. A product catalog can be browsed anonymously; an order history cannot. Lazy authentication (sometimes called mixed auth) lets a single server expose both: unauthenticated clients can connect, list tools, and call public ones, and the server only challenges for credentials when a protected tool is invoked. The challenge follows the MCP authorization specification. In Claude, the challenge surfaces as an inline Connect card in the conversation. The user authenticates in a popup, Claude retries the same tool call automatically with the new token, and the turn continues — no context is lost. The examples below are drawn from a single-file Express app using @modelcontextprotocol/sdk over Streamable HTTP.

Return 401, not a tool error

The only detail that matters is how the server refuses an unauthenticated call to a protected tool. It must fail the HTTP request with 401 Unauthorized and a WWW-Authenticate header:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp", scope="orders:read"

{"error":"invalid_token","error_description":"Authentication required for this tool"}
The body is advisory; the 401 status and WWW-Authenticate header carry the protocol signal. The optional scope parameter tells Claude which scopes to request during authorization — include the minimum your protected tools need. If you omit it, Claude requests the scopes your protected resource metadata advertises in scopes_supported (plus offline_access if your authorization server metadata lists it), which can produce an over-broad consent prompt. It must not return a successful HTTP response wrapping a tool error:
HTTP/1.1 200 OK

{"jsonrpc":"2.0","result":{"isError":true,"content":[{"type":"text","text":"Please sign in"}]},"id":1}
A 200 with isError: true is an application-level tool failure. Claude passes the error text to the model as the tool result and moves on — there is no auth prompt. Only a transport-level 401 causes Claude to pause the call, run the OAuth flow, and retry. A 403 triggers re-authentication only when accompanied by WWW-Authenticate: Bearer error="insufficient_scope" for scope step-up; any other 403 is surfaced as a terminal error. If users are seeing “please sign in” text in the chat instead of a Connect button, the server is returning the wrong one.
The resource_metadata parameter in the WWW-Authenticate header points at the server’s RFC 9728 Protected Resource Metadata (PRM), which in turn names the authorization server. That chain is how Claude discovers where to send the user without any of it being hard-coded in the client.

Gate at the HTTP layer

Because the refusal must be an HTTP status, the check has to happen before the JSON-RPC message reaches the MCP SDK. Once a tool handler is running, its return value is already destined to be wrapped in a 200 response. The sample inspects the parsed JSON-RPC body in the Express handler and short-circuits if the request is a tools/call for a protected tool and no valid bearer is present:
src/index.ts
const PROTECTED_TOOLS = new Set(["get_my_orders"]);

function callsProtectedTool(body: unknown): boolean {
  const messages = Array.isArray(body) ? body : [body];
  for (const msg of messages) {
    if (
      msg &&
      typeof msg === "object" &&
      (msg as { method?: unknown }).method === "tools/call"
    ) {
      const name = (msg as { params?: { name?: unknown } }).params?.name;
      if (typeof name === "string" && PROTECTED_TOOLS.has(name)) {
        return true;
      }
    }
  }
  return false;
}

const WWW_AUTHENTICATE =
  `Bearer error="invalid_token", ` +
  `error_description="Authentication required for this tool", ` +
  `resource_metadata="${BASE_URL}/.well-known/oauth-protected-resource/mcp", ` +
  `scope="orders:read"`;

async function handleMcpPost(req: Request, res: Response): Promise<void> {
  const token = extractBearer(req);
  const authed = isTokenValid(token);

  // Lazy-auth gate: fail with 401 BEFORE the MCP layer sees the request.
  // initialize, tools/list, and public tool calls fall through.
  if (!authed && callsProtectedTool(req.body)) {
    res
      .status(401)
      .set("WWW-Authenticate", WWW_AUTHENTICATE)
      .json({
        error: "invalid_token",
        error_description: "Authentication required for this tool",
      });
    return;
  }

  // Otherwise: stateless Streamable HTTP handling.
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  const mcp = buildMcpServer(authed ? "demo-user" : null);
  await mcp.connect(transport);
  await transport.handleRequest(req, res, req.body);
}

app.post("/mcp", (req, res) => {
  handleMcpPost(req, res).catch((err) => {
    console.error("mcp request error", err);
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: "2.0",
        error: { code: -32603, message: "Internal error" },
        id: null,
      });
    }
  });
});
initialize, tools/list, and calls to list_products never hit the gate, so the connector is fully usable before sign-in. When the user already has a valid token, every request — public or protected — carries it and the gate is a no-op. The same pattern covers scope upgrades: if the bearer is valid but lacks a required scope, return 403 Forbidden with WWW-Authenticate: Bearer error="insufficient_scope", scope="…" and Claude prompts the user to re-consent. See Step-up authorization below for what scopes Claude requests on re-consent and how the challenge is cached.

Serve the discovery documents

After a 401, Claude fetches the URL from resource_metadata to learn which authorization server to use:
src/index.ts
function protectedResourceMetadata() {
  return {
    resource: `${BASE_URL}/mcp`,
    authorization_servers: [BASE_URL],
    bearer_methods_supported: ["header"],
  };
}

app.get("/.well-known/oauth-protected-resource", (_req, res) => {
  res.json(protectedResourceMetadata());
});

// Path-suffixed variant per RFC 9728 section 3.1 — clients try this first when
// the resource URL has a path component (/mcp).
app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
  res.json(protectedResourceMetadata());
});
Claude then fetches the authorization server’s RFC 8414 metadata to find the /authorize and /token endpoints.

OAuth discovery caching

Claude caches the discovery documents — your protected resource metadata and the authorization-server metadata it points to — globally, keyed by URL, with a staleness window of about five minutes by default. All Claude users connecting to the same server URL share a single cache entry, and distinct server URLs (for example, staging versus production) cache independently. The refresh is lazy and best-effort: after you change scopes_supported (or any other discovery field), the new value is picked up by the first authorization that successfully re-runs discovery once the staleness window has elapsed, then propagates to everyone. There is no per-user expiry to wait for. If a refresh fails, Claude serves the stale entry and tries again on a later request, so an unreachable discovery endpoint doesn’t immediately break existing connections — it just delays the change.

Step-up authorization

The scope-upgrade case at the end of Gate at the HTTP layer is the MCP specification’s Step-Up Authorization Flow. When the bearer token is valid but missing a scope the requested tool needs, return 403 Forbidden with a WWW-Authenticate challenge:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write"
Claude prompts the user to re-authorize and, on consent, retries the same tool call with the new token. Which scopes Claude requests on re-authorization. Claude unions the scopes named in your 403 challenge with the scope your server advertises during discovery (the scope parameter on your initial 401 WWW-Authenticate response, or your protected resource metadata’s scopes_supported if you don’t send one). Scopes the user picked up in an earlier step-up aren’t reliably carried forward into the next one. To make sure the user keeps a permission they still need, follow the MCP spec’s recommended approach and include it in the 403 scope value alongside the newly required scopes — don’t return only the single missing scope and depend on the client to remember the rest. If your 403 carries error="insufficient_scope" but omits the scope parameter, Claude still recognizes step-up and runs its normal scope selection: the discovery-time WWW-Authenticate scope first, then your protected resource metadata’s scopes_supported, then the authorization server metadata’s scopes_supported.
The scope value from your 403 is cached per user, per server for up to fifteen minutes and consumed by the next re-authorization that user starts against your server. The cache holds the most recent challenge — a new 403 overwrites the previous one — and is cleared once it’s used. Combined with the global discovery cache above, a newly-added scope is available to step-up shortly after the discovery cache refreshes, typically within about five minutes of deploying the updated metadata.

Identify the client with CIMD

The sample does not implement Dynamic Client Registration. Instead it advertises support for Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document) in its authorization-server metadata:
src/index.ts
function authorizationServerMetadata() {
  return {
    issuer: BASE_URL,
    authorization_endpoint: `${BASE_URL}/authorize`,
    token_endpoint: `${BASE_URL}/token`,
    scopes_supported: ["profile", "orders:read"],
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    token_endpoint_auth_methods_supported: ["none"],
    code_challenge_methods_supported: ["S256"],
    client_id_metadata_document_supported: true,
  };
}
With CIMD the client_id is itself an HTTPS URL that dereferences to the client’s OAuth registration metadata. There is no per-client database and no POST /register round-trip: at /authorize, the server fetches the client_id URL, verifies the document is self-referential (its client_id field equals the URL it was served from), and checks the requested redirect_uri against the document’s redirect_uris. Because the document is self-asserted, the consent screen must display the host of the client_id URL (not the client_name field) as the relying party, and the listed redirect_uris should be required to be same-origin with the client_id URL.
Claude selects CIMD only when the authorization-server metadata advertises both client_id_metadata_document_supported: true and "none" in token_endpoint_auth_methods_supported. The second is required because Claude’s CIMD client authenticates as a public client (token_endpoint_auth_method: "none"), so the token endpoint must accept PKCE-only requests without a client secret. If either property is missing, Claude falls back to looking for a registration_endpoint.
For native clients, compare loopback IP redirect_uri values (http://127.0.0.1/…, http://[::1]/…) with the port ignored, per RFC 8252 section 7.3 — native apps bind an ephemeral port at runtime. RFC 8252 section 8.3 discourages http://localhost/…, but Claude Code declares it in its CIMD and binds an ephemeral port at runtime, so apply the same port-agnostic match to localhost for compatibility. The sample’s redirectUriAllowed() helper shows the comparison.

Try it

1

Run the server

npm install
npm run build
npm start
The server listens on http://localhost:3000/mcp.
2

Call a public tool without auth: 200

curl -s http://localhost:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_products","arguments":{}}}'
3

Call a protected tool without auth: 401

curl -si http://localhost:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_my_orders","arguments":{}}}'
Note the WWW-Authenticate header in the response.
4

Add it as a custom connector in Claude

Claude reaches custom connectors from Anthropic’s infrastructure, so localhost is not reachable directly. Expose the server over a public HTTPS tunnel (for example, cloudflared tunnel --url http://localhost:3000 or ngrok http 3000), then in Settings → Connectors → Add custom connector enter the tunnel’s /mcp URL. See Testing your connector for details.Ask Claude to list products (no prompt), then ask for your orders — the inline Connect card appears, and after authenticating the same call completes.
The sample’s README includes a longer curl walkthrough that drives the stub /authorize and /token endpoints directly.

Adapting to your server

  • List your protected tools in PROTECTED_TOOLS.
  • Replace isTokenValid() with real verification: JWT signature, iss matches your authorization server, aud equals the resource value you advertise in the PRM, and exp; or RFC 7662 token introspection against your IdP.
  • Point authorization_servers in the PRM at your real issuer and delete the stub /authorize and /token handlers. Keep client_id_metadata_document_supported: true in your issuer’s metadata if you want registration-free onboarding for Claude clients.
  • If your server uses stateful Streamable HTTP sessions, the gate still belongs in the POST /mcp handler, before transport.handleRequest.