[BUG] MCP OAuth --client-secret stored under wrong keychain key when HTTP server has custom headers — token exchange sent without secret

Open 💬 2 comments Opened Jun 11, 2026 by srivshr

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

An HTTP MCP server added via claude mcp add-json with BOTH a custom headers field AND a pre-configured OAuth client secret (--client-secret) fails after authentication. The browser OAuth login succeeds, but the token exchange is sent to the OAuth provider without a client_secret, so the provider rejects it (Google returns client_secret is missing.) and the server never receives an access token.

Originally hit with the Google Cloud SQL Admin MCP at https://sqladmin.googleapis.com/mcp using a custom x-goog-user-project header, but the defect is provider-agnostic — it triggers for any HTTP MCP server that combines a custom headers field with a pre-configured --client-secret.

Root cause (verified by inspecting the bundled binary in v2.1.173):

Per-server OAuth data in the "Claude Code-credentials" keychain item is keyed by: ${serverName}|${sha256(JSON.stringify({type, url, headers})).slice(0,16)}

  • The --client-secret storage path computes this key with an EMPTY headers object (headers:{}), so the secret is filed in mcpOAuthClientConfig under one key.
  • The runtime token-exchange path (clientInformation()mcpOAuthClientConfig[nX(serverName, serverConfig)]) computes the key with the server's ACTUAL headers included, producing a DIFFERENT key.
  • The lookup misses, so the request goes out with no client_secret.

I reproduced the hashing locally: hashing {type,url,headers:{...}} vs {type,url,headers:{}} yields two different 16-char suffixes, and these match the two distinct <serverName>|<hash> entries actually present in the keychain — the OAuth token state sits under the header-included key, while the client secret sits under the header-stripped key.

This is distinct from the v2.1.119 fix ("MCP OAuth client secret stored via --client-secret not being sent during token exchange for servers requiring client_secret_post"), which addresses sending the secret, notthe keychain key mismatch. The bug still reproduces on 2.1.173.

What Should Happen?

The client secret should be stored and looked up under the same key. Both the --client-secret storage path and the runtime token-exchange path should hash an identically-normalized server config (headers handled the same way in both), so the secret is found and included in the token exchange. Authentication should then complete and the server should receive an access token.

Error Messages/Logs

client_secret is missing.

Steps to Reproduce

Minimal repro (no OAuth login required — the mismatch is visible immediately):

  1. Add an HTTP MCP server with BOTH a custom header and a pre-configured secret:

claude mcp add-json repro '{"type":"http","url":"https://example.com/mcp","headers":{"x-custom-header":"value"},"oauth":{"clientId":"<CLIENT_ID>","callbackPort":12345,"scopes":"<SCOPE>"}}' --client-secret

  1. Enter any client secret when prompted.
  2. Inspect the keychain item where the secret was stored (macOS):

security find-generic-password -s "Claude Code-credentials" -a "$USER" -w | jq '.mcpOAuthClientConfig | keys'

The secret is stored under repro|<hashA>, where <hashA> = sha256(JSON.stringify({type,url,headers:{}})).slice(0,16) — i.e. computed with EMPTY headers.

  1. Confirm the runtime would look elsewhere: the OAuth token-exchange path keys by the same function but with the ACTUAL headers, i.e. sha256(JSON.stringify({type,url,headers:{"x-custom-header":"value"}})).slice(0,16), which is a different hash. So the lookup misses and the token request is sent without client_secret.

Real-world end-to-end repro (Google Cloud SQL Admin MCP):

  1. claude mcp add-json cloudsql '{"type":"http","url":"https://sqladmin.googleapis.com/mcp","headers":{"x-goog-user-project":"<GCP_PROJECT_ID>"},"oauth":{"clientId":"<GOOGLE_OAUTH_CLIENT_ID>","callbackPort":12345,"scopes":"https://www.googleapis.com/auth/cloudsql.readonly"}}' --client-secret
  2. Enter the OAuth client's secret when prompted.
  3. /mcp -> cloudsql -> authenticate -> complete Google login.
  4. Token exchange fails with client_secret is missing. (returned by Google's token endpoint), no access token stored.

Workaround: copy the clientSecret in mcpOAuthClientConfig from the header-stripped key to the header-included key, then re-authenticate WITHOUT re-adding the server. Auth then succeeds.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.173

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

iTerm2

Additional Information

Reproduced the key derivation independently: the composite key is sha256(JSON.stringify({type, url, headers})).slice(0,16), prefixed with the server name. The divergence is purely in the headers portion of the hashed config — header-less HTTP MCP servers are unaffected; the bug only triggers when a custom headers field is combined with a pre-configured --client-secret.

Suggested fix: normalize the server config identically in both the --client-secret storage path and the runtime token-exchange path (either both include headers, or both exclude them) so the composite key matches.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗