@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.
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="…" (per RFC 6750 section 3.1) and Claude will prompt the user to re-consent.
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.
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 attempts CIMD when the authorization-server metadata includes
client_id_metadata_document_supported: true. Also include "none" in token_endpoint_auth_methods_supported: a URL-identified client is public by definition, so the token endpoint must accept PKCE-only requests without a client secret. If client_id_metadata_document_supported is absent, 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.