When an MCP server lies about who issued your token
Debugging an RFC 9207 issuer mismatch that blocked Spaceship's MCP server in Claude Code, and the config escape hatch that fixes any server with the same bug.
I added Spaceship's MCP server to Claude Code, hit /mcp, clicked Authenticate, and got this:
Spaceship MCP Server
Status: needs authentication
Auth: not authenticated
URL: https://mcp.spaceship.com/mcp
Issuer mismatch in authorization response (RFC 9207):
expected "https://mcp.spaceship.com",
received "https://id.service.spaceship.com"The browser login worked. Spaceship redirected back happily. The client threw the whole thing away at the last step.
That error is worth understanding, because the same failure will hit you on other MCP servers, and the fix generalizes.
What the client was checking
RFC 9207 exists to stop a mix-up attack. If your client talks to more than one authorization server, an attacker who controls one of them can hand you an authorization code and try to get you to redeem it at a different, honest server's token endpoint. So the spec adds an iss parameter to the authorization response: the server states, in the redirect, who it is. The client compares that against the issuer it discovered during metadata discovery. If they differ, stop.
Claude Code implements this exactly. The check is unconditional, and there is no flag to turn it off, which is correct behaviour.
Why Spaceship failed it
Four curl calls tell the whole story. This sequence is the general recipe for debugging any MCP OAuth failure, so it is worth keeping.
Start at the protected resource metadata (RFC 9728), which is what the WWW-Authenticate header on a 401 points you to:
curl -s https://mcp.spaceship.com/.well-known/oauth-protected-resource{
"resource": "https://mcp.spaceship.com/",
"authorization_servers": ["https://mcp.spaceship.com"],
"scopes_supported": ["openid", "offline_access", "mcp.spaceship.com"]
}It names itself as its own authorization server. So ask that server who it is:
curl -s https://mcp.spaceship.com/.well-known/oauth-authorization-server{
"issuer": "https://mcp.spaceship.com",
"authorization_endpoint": "https://www.spaceship.com/connect/authorize",
"token_endpoint": "https://id.service.spaceship.com/connect/token",
"registration_endpoint": "https://mcp.spaceship.com/register"
}There it is. It claims issuer: https://mcp.spaceship.com, but the authorize and token endpoints belong to a different host. mcp.spaceship.com is a thin metadata shim in front of Spaceship's real identity provider, and it delegates the actual protocol endpoints straight through without proxying them.
Ask the real one:
curl -s https://id.service.spaceship.com/.well-known/openid-configuration{
"issuer": "https://id.service.spaceship.com",
"authorization_endpoint": "https://id.service.spaceship.com/connect/authorize",
"token_endpoint": "https://id.service.spaceship.com/connect/token",
"authorization_response_iss_parameter_supported": true
}authorization_response_iss_parameter_supported: true is the detonator. The real IdP is a good citizen: it stamps iss=https://id.service.spaceship.com onto every authorization response. The shim in front of it advertises a different issuer and never rewrites that value on the way back. Two spec-compliant halves that are incompatible when bolted together.
Worth saying plainly: this is a bug on Spaceship's side, not the client's. A server that publishes an issuer it does not control is misrepresenting itself. The proper fix is one of two things. Either proxy /connect/authorize and /connect/token under mcp.spaceship.com and rewrite iss to match the advertised issuer, or stop claiming a fake issuer and point authorization_servers at https://id.service.spaceship.com, where the metadata already tells the truth.
The fix on the client side
Claude Code has an escape hatch that most people never touch. An MCP server entry accepts an oauth block with clientId, authServerMetadataUrl, scopes, and callbackPort. When authServerMetadataUrl is set, the client fetches that document directly and skips the RFC 9728 discovery chain entirely.
So point it at the issuer that actually signs the response:
claude mcp remove spaceship
claude mcp add-json spaceship '{
"type": "http",
"url": "https://mcp.spaceship.com/mcp",
"oauth": {
"clientId": "spaceship.mcp",
"authServerMetadataUrl": "https://id.service.spaceship.com/.well-known/openid-configuration",
"scopes": "openid offline_access mcp.spaceship.com"
}
}'
claude mcp login spaceshipExpected issuer is now https://id.service.spaceship.com, the redirect carries iss=https://id.service.spaceship.com, and the check passes. Login works.
Two details make this stick.
The client ID is hardcoded on purpose. The real IdP publishes no registration_endpoint, so dynamic client registration has nothing to talk to once you bypass the shim. That looks fatal until you POST to the shim's registration endpoint and read what comes back:
curl -s -X POST https://mcp.spaceship.com/register \
-H 'Content-Type: application/json' \
-d '{"client_name":"Claude Code","redirect_uris":["http://localhost:54545/callback"],"token_endpoint_auth_method":"none"}'{ "client_id": "spaceship.mcp", "redirect_uris": ["http://localhost:54545/callback"], ... }It is not real DCR. It echoes your metadata back and hands everyone the same fixed public client, spaceship.mcp. So pinning it in config loses nothing.
Scopes are pinned too, because the real IdP's metadata omits scopes_supported and the values you need only appear in the 401 challenge:
www-authenticate: Bearer resource_metadata="...", scope="openid offline_access mcp.spaceship.com"One honest caveat. Setting authServerMetadataUrl skips the discovery chain that cryptographically ties a resource server to its authorization server, so you are asserting by hand that id.service.spaceship.com may issue tokens for mcp.spaceship.com. Here that assertion costs nothing: the shim's own metadata already names that exact token endpoint, so it is the same trust anchor either way, and both hosts sit under spaceship.com. Do not paste an unrelated issuer into that field.
The transferable part
MCP is young enough that a lot of servers are OAuth shims in front of an existing IdP, and this specific seam is where they crack. When an MCP server refuses to authenticate, walk the metadata chain by hand before assuming your client is broken. The 401's WWW-Authenticate header, the protected resource document, the authorization server document, and the real IdP's OIDC config are four curl calls that will usually name the culprit outright.
And when the culprit is the server, authServerMetadataUrl plus a pinned clientId will usually get you connected while you wait for them to fix it.