Zuplo
On this page
MCP error

Client runs OAuth instead of your header

The string your client printed
MCP headers config ignored when server has OAuth discovery

Your credential never reaches the wire: the client found OAuth discovery metadata on the server first and started the OAuth flow instead of sending the header you configured.

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 specification's OAuth flow on, the mirror image is healthy — resource_metadata present and 200 from the protected-resource path.
Then this isn't 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 header-authenticated route, 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 nothing to discover, 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

Copy a prompt that sends your coding agent to this page to 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 Set the credential as headers on an mcpServers entry with a url. Cursor resolves ${env:NAME} in command, args, env, url, and headers. That part is documented and correct — the problem is what wins.
  2. 2 Expect discovery to win. 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, so re-test on your build before assuming it still bites.
  3. 3 Read the downstream symptom as this bug. It surfaces as a registration error rather than an auth error — Incompatible auth server: does not support dynamic client registration — because Cursor is running a flow you never asked for.
  4. 4 Return 404 for every OAuth discovery path on the route that expects a static header. This is the server-side workaround Cursor staff endorsed, 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 Don't 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 specification, 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 Read a failed connection as a bad credential, not as a fallback. Claude Code's 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. So a configured header that isn't working means a bad token or the wrong endpoint — and if you meant to use OAuth, remove the header.
  3. 3 Set "type": "http" on the entry. 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, and a header on a skipped server is never sent at all.
  4. 4 Upgrade before you debug further. Reports that the header never reaches the wire have come in twice and ended differently: anthropics/claude-code #33817, for headers, was fixed and closed in April 2026; #64894, for headersHelper, was closed as stale in July 2026 without a fix. Read your own server log to see which requests arrive before you look 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 Enter the credential in the Request headers section of the Add custom connector dialog, which is in beta and rolled out per customer. An organization 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 Keep headers and OAuth together where you can: 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 Type Bearer in front of the token, including the space. Claude sends the value exactly as entered and adds no scheme.
  5. 5 Make the server reachable from Anthropic's cloud, not only from the machine running the app — that is where the connection comes from.
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 Find the two arguments that publish discovery: token_verifier= and auth=AuthSettings(...). 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 Expect the same pair in either version. The class is MCPServer in v2, the current stable line, and FastMCP in 1.x, which now takes bug fixes and security patches only — pip install mcp gives you 2.x, so pin mcp>=1.28,<2 if you have not migrated. token_verifier= / auth=AuthSettings(...) behaves identically 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 Find the two calls that put you in the discovery flow. resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) on requireBearerAuth 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 Mount neither document on a route that expects a static key. mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl }), from @modelcontextprotocol/express, serves them at /.well-known/oauth-protected-resource/mcp and a root /.well-known/oauth-authorization-server mirror for clients that probe the origin directly. Verify the key in your own middleware instead, 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, and Hono — the same two pieces live in @modelcontextprotocol/server: requireBearerAuth under that same name, and oauthMetadataResponse in place of the router. Note v1 shipped as one @modelcontextprotocol/sdk package, still at 1.30.0, while v2 split into @modelcontextprotocol/client and @modelcontextprotocol/server.
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 specification 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 specification's 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.

The specification (revision 2025-11-25) says nothing about a static header the user configured, so each client sets its own precedence. Some probe discovery before they ever send the request your header would have ridden on, and the server log shows it: GET /.well-known/oauth-authorization-server, GET /.well-known/oauth-protected-resource/mcp → 200, and no POST /mcp carrying Authorization anywhere.

The same bug arrives from the other end too — a server that has run on header auth for months breaks the day someone adds specification auth to it, because that metadata is now the first thing every client finds.

The exchange

  • MCP client Authorization header configured
  • MCP server publishes OAuth metadata
  1. A client might 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 specification 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 (this step fails) 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

A route gets one credential story, which leaves the question of what reaches the API behind it — not the credential the client sent.

Our upstream API wants its own key, not the caller's token.

mcp-token-exchange-inbound

That's what it gets: inbound auth headers are stripped and an independent upstream credential is minted per user or per gateway. Forwarding the caller's token is what the specification forbids with a MUST NOT.

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 can this caller see at all?

mcp-capability-filter-inbound

Only what the route lists, enforced before the request is forwarded — so 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. Your MCP server keeps the code and the authentication it has today.

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.