MCP headers config ignored when server has OAuth discovery
The credential is sitting in your client config and never reaches the wire: the client found OAuth discovery metadata on your server first, and switched to the OAuth flow instead of sending the header you set.
- MCP Specification
- 2026-07-28
Is this you?
Ask your server the two questions a client asks before it decides which credential to use. If either answer points at OAuth, the client has a discovery document to follow and your configured header loses.
- This error
WWW-Authenticate: Bearer resource_metadata="…", or any200or302from a well-known path, while your client is configured with a header. That is a discovery document the client will follow instead of your credential. A302deserves a second look: a catch-all route or an SPA fallback that redirects unknown paths reads as discovery to a client even though you never implemented OAuth.- Then the fix is the next section.
- Working
- For a server you intend to authenticate with a static header: no
WWW-Authenticateline at all, or one with noresource_metadataparameter, and404from all three well-known paths. Re-run the POST with your header: anything other than a401— a JSON-RPC result, or a400about a missing session ID — means the credential was accepted. For a server you intend to run the spec OAuth flow on, the mirror image is healthy —resource_metadatapresent and200from the protected-resource path. - Then this is not your problem — try the error reference.
5 other strings clients print for this
- [Bug] MCP Server Authorization Header Not Recognized, Falls Back to OAuth
- it refuses to obey an Authorization header now and tries to use oauth
- .mcp.json ignores headers field for HTTP MCP servers
- MCP server with basic auth headers works in user mcp.json but triggers OAuth flow when configured via extension .mcp.json
- Error: HTTP 403: Invalid OAuth error response: SyntaxError: JSON Parse error: Unrecognized token '<'.
Fix it
Pick one credential per route, and make the server advertise only that one. For a route authenticated by a static header, answer an unauthenticated request with a bare 401 carrying no resource_metadata parameter, and return 404 from /.well-known/oauth-protected-resource, from that path with your MCP route appended, and from /.well-known/oauth-authorization-server. With no discovery document to find, a client that probes falls back to the header you configured. If the OAuth flow is what you actually want, keep the metadata and take the header out of the client config instead; one path cannot reliably offer both.
Have your agent fix this
Copies a prompt that sends your coding agent to this page, then has it find and apply the right fix for your project.
The cause depends on your platform. Pick yours.
Where are you fixing this?
Cursor
If you connect from Cursor and your server publishes OAuth discovery metadata
- 1 Cursor spells a static credential as
headerson anmcpServersentry with aurl, and resolves${env:NAME}incommand,args,env,urlandheaders. That part is documented and correct — the problem is what wins. - 2 Cursor staff confirmed the precedence on the bug report: when the server answers the OAuth discovery endpoints, Cursor starts the OAuth flow before it sends the POST that would have carried your header, and there is no logic to skip OAuth when
Authorizationis already set. Staff re-confirmed it reproducing on 3.5.17 on 2026-05-22, with no fix shipped; re-test on your build before assuming it still bites. - 3 The downstream symptom is often a registration error rather than an auth error —
Incompatible auth server: does not support dynamic client registration— because Cursor is now running a flow you never asked for. - 4 The workaround Cursor staff endorsed is server-side: return 404 for every OAuth discovery path on the route that expects a static header. The snippet below is adapted from the Caddyfile in the thread.
- 5 If what you actually want is OAuth and the failure is registration rather than the header, add an
authobject withCLIENT_ID(andCLIENT_SECRET) to skip Dynamic Client Registration.
VS Code
If you configure the server in VS Code
- 1 Put the credential in
.vscode/mcp.jsonor the user-profilemcp.json, underservers.<name>.headers, on an entry whose type ishttp. The documented form for a secret is aninputsentry referenced as${input:<id>}— VS Code prompts once and stores the value. - 2 Do not use the open-plugins
.mcp.jsonformat for a header-authenticated server. It has noinputssupport, which VS Code closed as out of scope because.mcp.jsonfollows the open-plugins spec, and until 1.124.0 it discarded staticheadersoutright — producing an OAuth registration prompt for a server that worked fine from the usermcp.json. - 3 On 1.124.0 or later with the header still missing from the wire, run
Developer: Set Log Level > Traceand read the MCP output. That is the exact check VS Code used to verify the fix. - 4 Add an
oauthobject withclientIdonly when you actually want the OAuth flow; it is a separate field fromheaders, not a fallback for it.
Claude Code
If you connect from Claude Code
- 1 Add the header at the CLI with
claude mcp add --transport http <name> <url> --header "Authorization: Bearer your-token";-tand-Hare the short forms. In.mcp.jsonthe same thing isheaderson an entry whose type ishttp, and${VAR}or${VAR:-default}expand from the environment. - 2 The documented precedence is the opposite of Cursor's: if you configured
headers.Authorizationand the server rejects it, Claude Code reports the connection as failed instead of falling back to OAuth. A configured header that is not working therefore means a bad token or the wrong endpoint — or remove the header if you meant to use OAuth. - 3 An entry with a
urland no type is read as a stdio server and skipped, withMCP server "<name>" has a "url" but no "type"; add "type": "http" (or "sse" / "ws") to this entry. A header on a skipped server is never sent at all. - 4 Reports that the header never reaches the wire have come in twice, and they ended differently:
anthropics/claude-code#33817, forheaders, was fixed and closed in April 2026, so upgrade before you debug it. #64894, forheadersHelper, was closed as stale in July 2026 without a fix. Read your own server log to see which requests actually arrive before you go looking for a server-side cause. - 5 For a token that must be minted per connection use
headersHelper, a command that prints a JSON object of headers to stdout. Dynamic headers override staticheadersof the same name.
Claude custom connectors
If the client is Claude, Claude Desktop, or Cowork connecting as a custom connector
- 1 A custom connector does take static headers — the Request headers section of the Add custom connector dialog, in beta at the time of writing and rolled out per customer. An organisation administrator enters the value once, Claude sends it verbatim on every request, and the name has to come from an allowlist that includes
authorization,x-api-keyandx-auth-token. Up to four. - 2 Headers and OAuth are not exclusive: a header configured on an OAuth connection is sent alongside the bearer token, which is how a routing or tenant header reaches a gateway.
Authorizationis the documented exception — "OAuth owns that header, so it cannot be configured as a request header on an OAuth connection". That is this error, in the one place the vendor writes it down: if the server advertises discovery, the connector authenticates with OAuth and yourAuthorizationvalue is not sent. - 3 So pick one per connection. For a shared service account, stop advertising OAuth discovery on that route and configure
authorizationas a request header. For per-user identity, use OAuth and move the static credential to a different header name. - 4 Claude sends the value exactly as entered and adds no scheme, so an
authorizationheader needsBearertyped in front of the token, including the space. - 5 The connection is made from Anthropic's cloud, not from the machine running the app, so an internal server also has to be reachable from there.
MCP Python SDK
If your server is built on the MCP Python SDK and you want it to accept static keys
- 1
token_verifier=andauth=AuthSettings(...)are what publish discovery. Passing them mounts/.well-known/oauth-protected-resource/mcpand makes an unauthenticated request answer401withWWW-Authenticate: Bearer error="invalid_token", …, resource_metadata="…". That pointer is exactly what clients follow instead of your header. - 2 Drop both. They have to travel together — pass one without the other and the constructor raises
ValueError— so an API-key check belongs in ASGI middleware in front of the MCP app, with noauth=at all. - 3 Answer an unauthenticated request with a plain
401, and if you sendWWW-Authenticateat all, sendBearerwith noresource_metadataparameter. - 4 The class is
MCPServerin v2 (2.0.0rc1) andFastMCPin the 1.x line, which is still the recommended production release; thetoken_verifier=/auth=AuthSettings(...)pair is the same in both.
MCP TypeScript SDK
If your server is built on the MCP TypeScript SDK
- 1 Two calls put you in the discovery flow.
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)onrequireBearerAuthis what stampsresource_metadataonto the 401 challenge, and the SDK docs say plainly that this challenge is what starts a client's OAuth flow. - 2
mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl })is what serves the documents, at/.well-known/oauth-protected-resource/mcpand a root/.well-known/oauth-authorization-servermirror for clients that probe the origin directly. Mount neither on a route that expects a static key — verify the key in your own middleware and return a 401 with noresource_metadata. - 3 For a server that must serve both, keep the metadata router and the token-gated route on one path and the key-checked route on another. Then confirm the root authorization-server mirror is not answering for the key-checked path, because a root document is found for every path on the host.
- 4 On web-standard hosts — Workers, Deno, Bun, Hono — the same two pieces are
requireBearerAuthfrom@modelcontextprotocol/serverandoauthMetadataResponse.
Something else
Every client that can present a static header has to choose between it and whatever discovery says, and no spec text settles the order — so fix this on the server, not in the client. Decide per route: a route authenticated by a static header publishes no protected-resource metadata and puts no resource_metadata on its 401, and a route running the spec OAuth flow does both. Then check whatever sits in front of the server — a proxy, an ingress rule, framework middleware — is not serving a root /.well-known/oauth-protected-resource document, because a root document is found for every path on the host.
Why it happens
Discovery is keyed on your server, not on your config. A WWW-Authenticate challenge carrying resource_metadata, or a 200 from /.well-known/oauth-protected-resource, tells a client to treat the server as an OAuth protected resource and go get a token — and the spec (revision 2025-11-25) says nothing at all about a static header the user configured, so each client picks its own precedence. Some probe discovery before they ever send the request your header would have ridden on; the server log shows it plainly as GET /.well-known/oauth-authorization-server, GET /.well-known/oauth-protected-resource/mcp → 200, and no POST /mcp carrying Authorization anywhere. The mirror image is the same bug from the other end: a server that has run on header auth for months breaks the day someone adds spec auth to it, because the new metadata is now the first thing every client finds.
The exchange
Each step in the exchange, in order.
- MCP client Authorization header configured
- MCP server publishes OAuth metadata
-
A client may probe the well-known paths before it sends anything else. A 200 from either of them is all it takes.
MCP client to MCP server
GET /.well-known/oauth-protected-resource/mcp -
MCP server to MCP client
200 OK {"authorization_servers":["https://auth.example.com"]} -
The client held the credential the whole time. The spec says nothing about whether a user-configured header outranks discovered metadata, so each client decides for itself — and the ones that probe first never send the POST the header would have ridden on.
Inside MCP client
Treats the server as an OAuth protected resource headers.Authorization never sentFlow stops here
What lands on you next
The lesson underneath this bug is that a route gets one credential story. Which raises the question of what the API behind it should see — and that is not the credential the client sent.
-
Our upstream API wants its own key, not the caller's token.
-
mcp-token-exchange-inboundThe spec forbids forwarding an inbound token with a MUST NOT. Inbound auth headers are stripped and an independent upstream credential is minted per user or per gateway.
-
One upstream only takes a static bearer key. Does that break the pattern?
-
set-upstream-api-key-inboundNo. The route strips the inbound token and injects the upstream key, so a key-only server sits behind the same sign-in as the rest.
-
Which of our tools should this caller see at all?
-
mcp-capability-filter-inboundAn allow-list per route, enforced before the request is forwarded — a read-only view and a full-power view of one upstream are two routes, not two code paths.
-
Which credential did that call actually use?
-
capability_invocationEach event names the caller, the capability, the upstream and the outcome, so a credential problem resolves to a request rather than to a guess.
All of these attach to one MCP route's policies.inbound — the same policy engine on the way in and on the way out.
Stop debugging auth on every MCP server
A gateway in front of your MCP servers handles discovery, audience binding, and token validation once — for every server and every client.