Follow Discord
Sweep 25 Sep 2026 · 19:33Z Build v2.1.283 504 read Stable v2.1.274 Latest v2.1.283 Next v2.1.283 Feeds RSS JSON llms.txt llms-full.txt Unofficial
One change · claude-docs

Lazy authentication for MCP servers changedconnectors/building/lazy-authentication

Nearest release: v2.1.283, published under an hour after upstream edited the page. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.

Upstream edited this page at 25 Sep 2026 18:00 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 25 Sep 2026 18:07 UTC.

Upstream edited
Recorded here
Lines+198added
Lines−124removed
From line 2 where the diff opens
First seen 14 Aug 2026 this site's first read of the page
Recorded edits2to this page, all time

## See what the user experiences ## Build lazy authentication on your server ### Decide which tools need the user's account ### Answer a protected call with 401 before the MCP SDK runs #### Don't wrap the refusal in a 200 tool error ### Serve the discovery documents ### Identify Claude with a client ID metadata document #### Match loopback redirect URIs without the port ### Ask for more scope with 403 ### Allow for discovery caching ## Test the lazy-auth path ## Next steps ## Return 401, not a tool error ## Gate at the HTTP layer ## Serve the discovery documents ## OAuth discovery caching ## Step-up authorization ## Identify the client with CIMD ## Try it ## Adapting to your server

The whole hunk

from line 2, old and new numbered
/
lines
from line 2
22 
33> Let users call public tools immediately and defer OAuth until a protected tool is actually invoked.
44 
5Not 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](https://modelcontextprotocol.io/specification/latest/basic/authorization).
5A connector that uses OAuth sign-in normally asks each person to sign in as soon as they add it. With lazy authentication, sometimes called mixed auth, your MCP server lets Claude connect, list tools, and call the tools that don't need the person's account right away, and asks for sign-in only when Claude calls a tool that does. A product catalog can be browsed anonymously, for example, while an order history can't.
66 
7In 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.
7When Claude reaches a protected tool, it shows the sign-in prompt inline in the conversation, and after the person signs in it retries the same tool call. The challenge your server sends to trigger that prompt follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/latest/basic/authorization).
88 
9This page is for developers whose MCP server has some tools that work without the user's identity. It walks through excerpts from an example Express server, a single `src/index.ts` file built on `@modelcontextprotocol/sdk` over Streamable HTTP, to show what your own server needs at each step. For the OAuth requirements every server must meet regardless of when it asks for sign-in, see [Authentication for connectors](/docs/connectors/building/authentication).
10 
11## See what the user experiences
12 
13A user of a lazy-auth connector goes through these steps:
14 
151. They add your connector and start using it. Claude calls your public tools with no sign-in prompt.
162. They ask for something that needs their account. Claude calls the protected tool, your server refuses it, and an inline **Connect** card appears in the conversation.
173. They click **Connect** and sign in to your service in a popup.
184. Claude retries the same tool call automatically with the new token, and the turn continues with no context lost.
19 
920<Note>
10 If the user's organization has [Enterprise Managed Auth](/docs/connectors/building/enterprise-managed-auth) configured for your connector, the `401` triggers a silent token exchange instead of the **Connect** card. The tool call is retried automatically and the user sees no prompt.
21 If the user's organization has [Enterprise Managed Auth](/docs/connectors/building/enterprise-managed-auth) configured for your connector, the same refusal triggers a silent token exchange instead of the **Connect** card. Claude retries the tool call automatically and the user sees no prompt.
1122</Note>
1223 
13The examples below are drawn from a single-file Express app using `@modelcontextprotocol/sdk` over Streamable HTTP.
24## Build lazy authentication on your server
1425 
15## Return 401, not a tool error
26Your MCP server and your authorization server each have a part in making lazy authentication work.
1627 
17The only detail that matters is **how** the server refuses an unauthenticated call to a protected tool.
28### Decide which tools need the user's account
1829 
19It must fail the **HTTP request** with `401 Unauthorized` and a [`WWW-Authenticate`](https://datatracker.ietf.org/doc/html/rfc6750#section-3) header:
30Split your tools into the ones anyone can call and the ones that act on the signed-in user's data. Claude can call the first group before sign-in, so keep it to tools that are safe without an identity, such as browsing a public catalog.
2031 
21```http theme={null}
22HTTP/1.1 401 Unauthorized
23WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp", scope="orders:read"
32In the example server, `list_products` is public and `get_my_orders` is protected. The protected names go in a `PROTECTED_TOOLS` set that the [HTTP handler's gate](#answer-a-protected-call-with-401-before-the-mcp-sdk-runs) checks. List your own protected tools there.
2433 
25{"error":"invalid_token","error_description":"Authentication required for this tool"}
26```
34### Answer a protected call with 401 before the MCP SDK runs
2735 
28The 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.
36Claude starts sign-in only when the HTTP request itself fails with `401 Unauthorized` and a [`WWW-Authenticate`](https://datatracker.ietf.org/doc/html/rfc6750#section-3) header. A tool handler can't produce that response: once the MCP SDK is running a tool, whatever the handler returns is wrapped in a `200`. The check has to run in your HTTP handler, on the parsed JSON-RPC body, before the request reaches the SDK.
2937 
30It must **not** return a successful HTTP response wrapping a tool error:
38On your server, when a request is a `tools/call` for a protected tool and carries no valid bearer token:
3139 
40* Respond with HTTP status `401`
41* Set a `WWW-Authenticate: Bearer` header whose `resource_metadata` parameter points at your [protected resource metadata](#serve-the-discovery-documents) document
42* Optionally add a `scope` parameter naming the minimum scopes your protected tools need
43* Return from the handler before calling the MCP SDK, and keep the check before `transport.handleRequest` even if your server uses stateful Streamable HTTP sessions
44 
45The response Claude expects looks like this:
46 
3247```http theme={null}
33HTTP/1.1 200 OK
48HTTP/1.1 401 Unauthorized
49WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required for this tool", resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp", scope="orders:read"
3450 
35{"jsonrpc":"2.0","result":{"isError":true,"content":[{"type":"text","text":"Please sign in"}]},"id":1}
51{"error":"invalid_token","error_description":"Authentication required for this tool"}
3652```
3753 
38<Warning>
39 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.
40</Warning>
54The `401` status and the `WWW-Authenticate` header carry the signal, and the body is advisory. The `scope` parameter tells Claude which scopes to request during authorization. If you omit it, Claude requests every scope 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.
4155 
42The `resource_metadata` parameter in the `WWW-Authenticate` header points at the server's [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) 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.
56In the example server's `POST /mcp` handler, the load-bearing lines are the `PROTECTED_TOOLS` set, the `WWW_AUTHENTICATE` header value with its `resource_metadata` URL, and the gate that sends the `401` and returns before the SDK transport is created. Those lines are highlighted:
4357 
44## Gate at the HTTP layer
58```ts src/index.ts {1,22-26,33-41} theme={null}
59const PROTECTED_TOOLS = new Set(["get_my_orders"]); // tools that need the user's account
4560 
46Because 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.
47 
48The 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:
49 
50```ts src/index.ts theme={null}
51const PROTECTED_TOOLS = new Set(["get_my_orders"]);
52 
5361function callsProtectedTool(body: unknown): boolean {
5462 const messages = Array.isArray(body) ? body : [body];
5563 for (const msg of messages) {
from line 75
6775 return false;
6876}
6977 
78// Claude starts sign-in only on HTTP 401 with this header.
79// resource_metadata is where Claude looks up your authorization server.
7080const WWW_AUTHENTICATE =
7181 `Bearer error="invalid_token", ` +
7282 `error_description="Authentication required for this tool", ` +
from line 87
7787 const token = extractBearer(req);
7888 const authed = isTokenValid(token);
7989 
80 // Lazy-auth gate: fail with 401 BEFORE the MCP layer sees the request.
81 // initialize, tools/list, and public tool calls fall through.
90 // The gate. initialize, tools/list, and public tool calls fall through.
8291 if (!authed && callsProtectedTool(req.body)) {
8392 res
8493 .status(401)
from line 96
8796 error: "invalid_token",
8897 error_description: "Authentication required for this tool",
8998 });
90 return;
99 return; // Return here, before the SDK, or the refusal turns into a 200 tool result.
91100 }
92101 
93 // Otherwise: stateless Streamable HTTP handling.
94102 const transport = new StreamableHTTPServerTransport({
95103 sessionIdGenerator: undefined,
96104 enableJsonResponse: true,
from line 122
114122});
115123```
116124 
117`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.
125`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 carries it, public or protected, and the gate does nothing.
118126 
119The 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](#step-up-authorization) below for what scopes Claude requests on re-consent and how the challenge is cached.
127The example server's `isTokenValid()` is a stub. In your server, replace it with real verification: either check the JWT signature, that `iss` matches your authorization server, that `aud` equals the `resource` value you advertise in your protected resource metadata, and `exp`, or use [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) token introspection against your identity provider.
120128 
121## Serve the discovery documents
129#### Don't wrap the refusal in a 200 tool error
122130 
123After a 401, Claude fetches the URL from `resource_metadata` to learn which authorization server to use:
131A successful HTTP response that wraps a tool error doesn't start sign-in. This is the shape to avoid:
124132 
125```ts src/index.ts theme={null}
133```http theme={null}
134HTTP/1.1 200 OK
135 
136{"jsonrpc":"2.0","result":{"isError":true,"content":[{"type":"text","text":"Please sign in"}]},"id":1}
137```
138 
139<Warning>
140 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, and 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](#ask-for-more-scope-with-403), and 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.
141</Warning>
142 
143### Serve the discovery documents
144 
145After the `401`, Claude needs to find out where to send the user to sign in, and nothing about your server is hard-coded in Claude. It fetches the `resource_metadata` URL from your `WWW-Authenticate` header, which serves your protected resource metadata: a small [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) JSON document that names your MCP endpoint as the `resource` and lists the authorization server that issues tokens for it. Claude then fetches that authorization server's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata to find its `/authorize` and `/token` endpoints.
146 
147On your server:
148 
149* Serve the protected resource metadata at `/.well-known/oauth-protected-resource/<your-mcp-path>`, the path-suffixed form clients try first when your MCP URL has a path such as `/mcp`, and at `/.well-known/oauth-protected-resource`
150* Set `resource` to your MCP endpoint URL and `authorization_servers` to your real issuer
151 
152In the example server, one function builds the protected resource metadata document and two routes serve it. The `resource` and `authorization_servers` fields and the path-suffixed route are highlighted:
153 
154```ts src/index.ts {3-4,15} theme={null}
126155function protectedResourceMetadata() {
127156 return {
128 resource: `${BASE_URL}/mcp`,
129 authorization_servers: [BASE_URL],
157 resource: `${BASE_URL}/mcp`, // must match the MCP URL the user adds in Claude
158 authorization_servers: [BASE_URL], // your issuer; the example is its own
130159 bearer_methods_supported: ["header"],
131160 };
132161}
from line 164
135164 res.json(protectedResourceMetadata());
136165});
137166 
138// Path-suffixed variant per RFC 9728 section 3.1 — clients try this first when
139// the resource URL has a path component (/mcp).
167// Path-suffixed variant per RFC 9728 section 3.1. Clients try this first
168// when the resource URL has a path component (/mcp).
140169app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
141170 res.json(protectedResourceMetadata());
142171});
143172```
144173 
145Claude then fetches the authorization server's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata to find the `/authorize` and `/token` endpoints.
174The example server acts as its own authorization server with stub `/authorize` and `/token` handlers. When you point `authorization_servers` at your real issuer, delete those stubs.
146175 
147## OAuth discovery caching
176### Identify Claude with a client ID metadata document
148177 
149Claude 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.
178Before your authorization server shows a consent screen, it has to know which app is asking. The example identifies Claude with a Client ID Metadata Document (CIMD), defined in [draft-ietf-oauth-client-id-metadata-document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/).
150179 
151The 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.
180With CIMD, Claude's `client_id` is an HTTPS URL, and your authorization server fetches it during `/authorize` to read Claude's registration details. That means you don't register Claude ahead of time, keep a client database, or run a Dynamic Client Registration (DCR) endpoint.
152181 
153## Step-up authorization
182Your `/authorize` endpoint should:
154183 
155The scope-upgrade case at the end of [Gate at the HTTP layer](#gate-at-the-http-layer) is the MCP specification's [Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#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`](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) challenge:
184* Fetch the `client_id` URL and check that the document's own `client_id` field equals that URL
185* Check the requested `redirect_uri` exactly against the document's `redirect_uris`, matching loopback URIs as [Match loopback redirect URIs without the port](#match-loopback-redirect-uris-without-the-port) describes. You can additionally require non-loopback `redirect_uris` to share the `client_id` URL's origin
186* On the consent screen, name the host of the `client_id` URL as the app asking for access, not the document's `client_name`, because the document is self-asserted
156187 
157```http theme={null}
158HTTP/1.1 403 Forbidden
159WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write"
160```
188Your authorization server advertises CIMD support in its own metadata, the [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) document Claude fetches after reading `authorization_servers`. Serve it at the RFC 8414 well-known path, which for the example's issuer is `/.well-known/oauth-authorization-server`. In the example, the two values Claude checks before it uses CIMD are highlighted:
161189 
162Claude prompts the user to re-authorize and, on consent, retries the same tool call with the new token.
163 
164**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](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors) 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.
165 
166If 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`.
167 
168<Note>
169 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](#oauth-discovery-caching) 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.
170</Note>
171 
172## Identify the client with CIMD
173 
174The sample does **not** implement Dynamic Client Registration. Instead it advertises support for **Client ID Metadata Documents** ([draft-ietf-oauth-client-id-metadata-document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)) in its authorization-server metadata:
175 
176```ts src/index.ts theme={null}
190```ts src/index.ts {9,11} theme={null}
177191function authorizationServerMetadata() {
178192 return {
179193 issuer: BASE_URL,
from line 196
182196 scopes_supported: ["profile", "orders:read"],
183197 response_types_supported: ["code"],
184198 grant_types_supported: ["authorization_code", "refresh_token"],
185 token_endpoint_auth_methods_supported: ["none"],
199 token_endpoint_auth_methods_supported: ["none"], // Claude's CIMD client is a public client
186200 code_challenge_methods_supported: ["S256"],
187 client_id_metadata_document_supported: true,
201 client_id_metadata_document_supported: true, // tells Claude it can send its client_id URL
188202 };
189203}
190204```
191205 
192With 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.
193 
194206<Note>
195 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](https://datatracker.ietf.org/doc/html/rfc7636)-only requests without a client secret. If either property is missing, Claude falls back to looking for a `registration_endpoint`.
207 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 with `token_endpoint_auth_method: "none"`, so the token endpoint must accept [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)-only requests without a client secret. If either property is missing, Claude falls back to looking for a `registration_endpoint`.
196208</Note>
197209 
198For 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](https://datatracker.ietf.org/doc/html/rfc8252#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.
210If you move to a real issuer, keep `client_id_metadata_document_supported: true` in that issuer's metadata if you want registration-free onboarding for Claude clients.
199211 
200## Try it
212#### Match loopback redirect URIs without the port
201213 
202<Steps>
203 <Step title="Run the server">
204 ```bash theme={null}
205 npm install
206 npm run build
207 npm start
208 ```
214Claude Code is a native client, and native apps bind an ephemeral port at runtime, so the `redirect_uri` Claude Code sends carries a port your `redirect_uris` check can't know in advance. When you compare a requested `redirect_uri` against the document:
209215 
210 The server listens on `http://localhost:3000/mcp`.
211 </Step>
216* Compare loopback IP values such as `http://127.0.0.1/…` and `http://[::1]/…` with the port ignored, per [RFC 8252 section 7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3)
217* Apply the same port-agnostic match to `http://localhost/…`. RFC 8252 section 8.3 discourages `localhost`, but Claude Code declares it in its CIMD, so accept it for compatibility
212218 
213 <Step title="Call a public tool without auth: 200">
219### Ask for more scope with 403
220 
221When a signed-in user calls a tool that needs a scope their token lacks, your server can ask Claude to re-authorize them instead of failing the call. This is the MCP specification's [Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow). From the same gate in your HTTP handler, return `403 Forbidden` with a [`WWW-Authenticate`](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) challenge that names the scopes:
222 
223```http theme={null}
224HTTP/1.1 403 Forbidden
225WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write"
226```
227 
228Claude prompts the user to re-authorize and, on consent, retries the same tool call with the new token. The scopes Claude requests on that re-authorization are the union of two sources:
229 
230* **Your `403` challenge**: the scopes named in its `scope` parameter. List every scope the user still needs alongside the newly required ones, not only the one that's missing, because scopes the user picked up in an earlier step-up aren't reliably carried forward into the next one. This is the [MCP spec's recommended approach](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors), and it avoids depending on the client to remember the rest
231* **Your discovery scope**: the `scope` parameter on your initial `401` `WWW-Authenticate` response, or your protected resource metadata's `scopes_supported` if you don't send one
232 
233If 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`.
234 
235### Allow for discovery caching
236 
237After you change `scopes_supported` or any other discovery field, allow about five minutes before Claude uses the new values. Claude caches 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.
238 
239How the cache is keyed and refreshed decides when a change reaches your users:
240 
241* **One cache entry per server URL**: all Claude users connecting to the same server URL share a single entry, and distinct URLs, such as staging versus production, cache independently. There is no per-user expiry to wait for
242* **Lazy refresh**: the first authorization that successfully re-runs discovery after the window has elapsed picks up the new value, and it then propagates to everyone
243* **Stale on failure**: 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 only delays the change
244 
245## Test the lazy-auth path
246 
247You can confirm the public and protected paths with `curl` and Claude Code against your own server before connecting it to Claude as a custom connector, then check the sign-in prompt in a conversation. The commands below use `http://localhost:3000/mcp` as the server URL and the example's tool names; substitute your own.
248 
249<Steps>
250 <Step title="Call a public tool without a token">
251 In a terminal, send a `tools/call` for a public tool with no `Authorization` header, with `-i` so the status line prints:
252 
214253 ```bash theme={null}
215 curl -s http://localhost:3000/mcp \
254 curl -si http://localhost:3000/mcp \
216255 -H 'Content-Type: application/json' \
217256 -H 'Accept: application/json, text/event-stream' \
218257 -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_products","arguments":{}}}'
219258 ```
259 
260 The server answers `200 OK` with the JSON-RPC tool result and no `WWW-Authenticate` header.
220261 </Step>
221262 
222 <Step title="Call a protected tool without auth: 401">
263 <Step title="Call a protected tool without a token">
264 Send the same request for a protected tool:
265 
223266 ```bash theme={null}
224267 curl -si http://localhost:3000/mcp \
225268 -H 'Content-Type: application/json' \
from line 270
227270 -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_my_orders","arguments":{}}}'
228271 ```
229272 
230 Note the `WWW-Authenticate` header in the response.
273 The server answers `401 Unauthorized` with a `WWW-Authenticate` header carrying `resource_metadata`. If you see `200` with `isError: true` instead, your server is returning a tool error rather than failing the HTTP request.
231274 </Step>
232275 
233 <Step title="Add it as a custom connector in Claude">
234 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 **Customize > Connectors**, select **Add custom connector** and enter the tunnel's `/mcp` URL. See [Testing your connector](/docs/connectors/building/testing) for details.
276 <Step title="Check both paths from Claude Code">
277 If you have [Claude Code](https://code.claude.com/docs/en/setup) installed and signed in, you can see how a Claude client treats each path before you set up a tunnel, because Claude Code connects to a `localhost` server directly. In your terminal, from your server's project folder, add the server:
235278 
236 Ask Claude to list products (no prompt), then ask for your orders — the inline **Connect** card appears, and after authenticating the same call completes.
279 ```bash theme={null}
280 claude mcp add --transport http authtest http://localhost:3000/mcp
281 ```
282 
283 Then check the connection:
284 
285 ```bash theme={null}
286 claude mcp list
287 ```
288 
289 The list shows `authtest: http://localhost:3000/mcp (HTTP) - ✔ Connected` with no sign-in, because connecting and listing tools never reach the gate. Next, have Claude call the public tool with [`claude -p`](https://code.claude.com/docs/en/headless), which sends one prompt and prints the answer:
290 
291 ```bash theme={null}
292 claude -p "List the products from the authtest server." --allowedTools mcp__authtest__list_products
293 ```
294 
295 Claude reports the products and no sign-in prompt appears. Run the same command asking for your orders with `--allowedTools mcp__authtest__get_my_orders`. The tool result Claude receives is `MCP server "authtest" needs you to sign in again (run /mcp to re-authenticate)` rather than order data.
296 
297 To sign in from Claude Code, run `/mcp` in an interactive session as [Authenticate with remote MCP servers](https://code.claude.com/docs/en/mcp#authenticate-with-remote-mcp-servers) describes. When you're done testing, run `claude mcp remove authtest`.
237298 </Step>
238</Steps>
239299 
240The sample's README includes a longer `curl` walkthrough that drives the stub `/authorize` and `/token` endpoints directly.
300 <Step title="Add the server to Claude as a custom connector">
301 Claude reaches custom connectors from Anthropic's infrastructure, so `localhost` isn't reachable directly. To add a local server:
241302 
242## Adapting to your server
303 1. Expose the server over a public HTTPS tunnel, such as `cloudflared tunnel --url http://localhost:3000` or `ngrok http 3000`. Keep the tunnel up only while you test, because it exposes your local server publicly and the example's `/authorize`, `/token`, and `isTokenValid()` are stubs that treat anyone as signed in.
304 2. In Claude, go to [**Customize > Connectors**](https://claude.ai/customize/connectors) and select **Add custom connector**.
305 3. Enter the tunnel's `/mcp` URL.
243306 
244* List your protected tools in `PROTECTED_TOOLS`.
245* 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](https://datatracker.ietf.org/doc/html/rfc7662) token introspection against your IdP.
246* 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.
247* If your server uses stateful Streamable HTTP sessions, the gate still belongs in the `POST /mcp` handler, before `transport.handleRequest`.
307 See [Test a local server](/docs/connectors/building/testing#test-a-local-server) for details. If your app comes from the SDK's `createMcpExpressApp()`, that section also shows the `allowedHosts` option you need before requests through the tunnel succeed.
308 </Step>
309 
310 <Step title="Try both paths in a conversation">
311 1. Ask Claude to list products. No sign-in prompt appears.
312 2. Ask for your orders. The inline **Connect** card appears, and after you authenticate the same call completes.
313 </Step>
314</Steps>
315 
316## Next steps
317 
318* [Authentication for connectors](/docs/connectors/building/authentication): check the OAuth requirements your authorization server must meet, including redirect URIs and token refresh
319* [Enterprise Managed Auth](/docs/connectors/building/enterprise-managed-auth): let organizations with SSO answer the same `401` with a silent token exchange
320* [Test your connector](/docs/connectors/building/testing): expose the server through a tunnel and add it as a custom connector
321* [Troubleshoot your connector](/docs/connectors/building/troubleshooting): diagnose discovery and authorization failures
248322 
Feedback