Zuplo
MCP error

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.

TerminalRun this to check — it changes nothing
# 1. What does an unauthenticated request advertise?
curl -sS -D - -o /dev/null -X POST https://your-server.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
  | grep -i '^www-authenticate'

# 2. Does discovery metadata exist at either location?
for p in /.well-known/oauth-protected-resource/mcp \
         /.well-known/oauth-protected-resource \
         /.well-known/oauth-authorization-server; do
  printf '%s -> ' "$p"
  curl -sS -o /dev/null -w '%{http_code}\n' "https://your-server.example.com$p"
done
This error
WWW-Authenticate: Bearer resource_metadata="…", or any 200 or 302 from 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. A 302 deserves 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-Authenticate line at all, or one with no resource_metadata parameter, and 404 from all three well-known paths. Re-run the POST with your header: anything other than a 401 — a JSON-RPC result, or a 400 about 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_metadata present and 200 from 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.

Cursor

If you connect from Cursor and your server publishes OAuth discovery metadata

  1. 1 Cursor spells a static credential as headers on an mcpServers entry with a url, and resolves ${env:NAME} in command, args, env, url and headers. That part is documented and correct — the problem is what wins.
  2. 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 Authorization is 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. 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. 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. 5 If what you actually want is OAuth and the failure is registration rather than the header, add an auth object with CLIENT_ID (and CLIENT_SECRET) to skip Dynamic Client Registration.
text
# Caddy: force the client off the discovery path for this route
@oauth_discovery path /.well-known/oauth-protected-resource* /.well-known/oauth-authorization-server* /.well-known/openid-configuration*
respond @oauth_discovery 404

@mcp path /mcp*
handle @mcp {
  reverse_proxy https://your-mcp-server.internal
}
Cursor: staff-confirmed bug report

VS Code

If you configure the server in VS Code

  1. 1 Put the credential in .vscode/mcp.json or the user-profile mcp.json, under servers.<name>.headers, on an entry whose type is http. The documented form for a secret is an inputs entry referenced as ${input:<id>} — VS Code prompts once and stores the value.
  2. 2 Do not use the open-plugins .mcp.json format for a header-authenticated server. It has no inputs support, which VS Code closed as out of scope because .mcp.json follows the open-plugins spec, and until 1.124.0 it discarded static headers outright — producing an OAuth registration prompt for a server that worked fine from the user mcp.json.
  3. 3 On 1.124.0 or later with the header still missing from the wire, run Developer: Set Log Level > Trace and read the MCP output. That is the exact check VS Code used to verify the fix.
  4. 4 Add an oauth object with clientId only when you actually want the OAuth flow; it is a separate field from headers, not a fallback for it.
JSONjson
{
  "inputs": [
    {
      "type": "promptString",
      "id": "api-token",
      "description": "MCP server API token",
      "password": true
    }
  ],
  "servers": {
    "internal-api": {
      "type": "http",
      "url": "https://mcp.internal.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${input:api-token}"
      }
    }
  }
}
VS Code: MCP configuration reference

Claude Code

If you connect from Claude Code

  1. 1 Add the header at the CLI with claude mcp add --transport http <name> <url> --header "Authorization: Bearer your-token"; -t and -H are the short forms. In .mcp.json the same thing is headers on an entry whose type is http, and ${VAR} or ${VAR:-default} expand from the environment.
  2. 2 The documented precedence is the opposite of Cursor's: if you configured headers.Authorization and 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. 3 An entry with a url and no type is read as a stdio server and skipped, with MCP 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. 4 Reports that the header never reaches the wire have come in twice, and they ended differently: anthropics/claude-code #33817, for headers, was fixed and closed in April 2026, so upgrade before you debug it. #64894, for headersHelper, 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. 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 static headers of the same name.
Terminalbash
claude mcp add --transport http internal-api https://mcp.internal.example.com/mcp \
  --header "Authorization: Bearer $INTERNAL_API_KEY"
Claude Code: MCP reference

Claude custom connectors

If the client is Claude, Claude Desktop, or Cowork connecting as a custom connector

  1. 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-key and x-auth-token. Up to four.
  2. 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. Authorization is 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 your Authorization value is not sent.
  3. 3 So pick one per connection. For a shared service account, stop advertising OAuth discovery on that route and configure authorization as a request header. For per-user identity, use OAuth and move the static credential to a different header name.
  4. 4 Claude sends the value exactly as entered and adds no scheme, so an authorization header needs Bearer typed in front of the token, including the space.
  5. 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.
Claude: authenticating with request headers

MCP Python SDK

If your server is built on the MCP Python SDK and you want it to accept static keys

  1. 1 token_verifier= and auth=AuthSettings(...) are what publish discovery. Passing them mounts /.well-known/oauth-protected-resource/mcp and makes an unauthenticated request answer 401 with WWW-Authenticate: Bearer error="invalid_token", …, resource_metadata="…". That pointer is exactly what clients follow instead of your header.
  2. 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 no auth= at all.
  3. 3 Answer an unauthenticated request with a plain 401, and if you send WWW-Authenticate at all, send Bearer with no resource_metadata parameter.
  4. 4 The class is MCPServer in v2 (2.0.0rc1) and FastMCP in the 1.x line, which is still the recommended production release; the token_verifier= / auth=AuthSettings(...) pair is the same in both.
python
# Publishes discovery — clients will follow it instead of your header
mcp = MCPServer(
    "Notes",
    token_verifier=StaticTokenVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.example.com"),
        resource_server_url=AnyHttpUrl("https://mcp.example.com/mcp"),
        required_scopes=["notes:read"],
    ),
)

# Static-key server: no auth=, no token_verifier=, no well-known route
mcp = MCPServer("Notes")
MCP Python SDK: authorization

MCP TypeScript SDK

If your server is built on the MCP TypeScript SDK

  1. 1 Two calls put you in the discovery flow. resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) on requireBearerAuth is what stamps resource_metadata onto the 401 challenge, and the SDK docs say plainly that this challenge is what starts a client's OAuth flow.
  2. 2 mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl }) is what serves the documents, at /.well-known/oauth-protected-resource/mcp and a root /.well-known/oauth-authorization-server mirror 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 no resource_metadata.
  3. 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. 4 On web-standard hosts — Workers, Deno, Bun, Hono — the same two pieces are requireBearerAuth from @modelcontextprotocol/server and oauthMetadataResponse.
TypeScriptts
// This is the line that advertises discovery on the 401
const auth = requireBearerAuth({
  verifier,
  requiredScopes: ['mcp'],
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});

// Static-key route: omit resourceMetadataUrl, and do not mount
// mcpAuthMetadataRouter on this path.
MCP TypeScript SDK: require authorization

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
  1. 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
  2. MCP server to MCP client

    200 OK {"authorization_servers":["https://auth.example.com"]}
  3. 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 sent

    Flow stops here

The MCP client probes the well-known protected-resource path before it sends anything else. The server answers 200 with discovery metadata, so the client treats the server as an OAuth protected resource and starts a token flow. The configured Authorization header is never sent, and the POST that would have carried it is never made.
Zuplo

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

The 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-inbound

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

An 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_invocation

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