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 usingDocumentation Index
Fetch the complete documentation index at: https://claude.com/docs/llms.txt
Use this file to discover all available pages before exploring further.
@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 with401 Unauthorized and a WWW-Authenticate header:
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:
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 a200 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
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 fromresource_metadata to learn which authorization server to use:
src/index.ts
/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 changescopes_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, return403 Forbidden with a WWW-Authenticate challenge:
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
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.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
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.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,issmatches your authorization server,audequals theresourcevalue you advertise in the PRM, andexp; or RFC 7662 token introspection against your IdP. - Point
authorization_serversin the PRM at your real issuer and delete the stub/authorizeand/tokenhandlers. Keepclient_id_metadata_document_supported: truein 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 /mcphandler, beforetransport.handleRequest.