Skip to main content
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"

{"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. 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"`;

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="…" (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 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.

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 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.
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.