HTTP MCP OAuth: valid token silently corrupted by status check (wrong redirectUri, empty accessToken)
Summary
For a remote HTTP MCP server added with claude mcp add --transport http <name> <url> --callback-port <port> --client-id <id>, the OAuth token record in ~/.claude/.credentials.json gets silently corrupted (empty accessToken, expiresAt: 0, and redirectUri reverted to a stale/wrong port) every time the server's connection status is checked (claude mcp list, or any tool-discovery pass that re-checks MCP health) — even immediately after a fully successful browser OAuth flow.
Environment
- Claude Code version: 2.1.246
- OS: Linux (WSL2),
6.6.87.1-microsoft-standard-WSL2 - Transport: HTTP MCP server behind a Keycloak (OIDC) authorization server, PKCE + dynamic-ish local callback
Steps to reproduce
claude mcp add --transport http wsi-ces-governance https://mcp.timebank.tw/mcp --callback-port 8766 --client-id claude-code-local-mcp-client- Confirm at the identity provider (Keycloak) that this client is registered with exactly one redirect URI:
http://localhost:8766/callback. - In a session, call the server's
authenticatetool. It returns an authorization URL withredirect_uri=http%3A%2F%2Flocalhost%3A8766%2Fcallback. - Open that URL, complete login/consent. Browser redirects to
http://localhost:8766/callback?code=...&state=...and (when a local listener catches it) shows "Authentication successful / Connected." - Immediately inspect
~/.claude/.credentials.json→mcpOAuth["<name>|<hash>"]: at this pointredirectUricorrectly readshttp://localhost:8766/callback, butaccessTokenis already an empty string andexpiresAtis0. - Run
claude mcp list. Output:
````
[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or provider not round-tripping the value). SEP-2352 isolation is inactive for this read; ensure your provider round-trips the issuer field.
<name>: <url> (HTTP) - ! Needs authentication
- Re-inspect
~/.claude/.credentials.json: the record has now been rewritten withredirectUri: http://localhost:3118/callback(a port never used anywhere in this setup — not passed tomcp add, not present in any config, not registered at the identity provider) andaccessTokenstill empty. - Manually deleting this
mcpOAuthentry and repeating steps 3–7 reproduces the exact same corrupted record every time, including the same non-existent port3118.
Expected behavior
- After a successful OAuth authorization + token exchange,
claude mcp list/ tool discovery should read the real access token from disk without mutating it, and the server's real tools should become available. - If a status/health check needs to re-verify or refresh a token, it should not silently overwrite a valid (or freshly-written) token record with an empty one.
Actual behavior
- The stored token is corrupted (wiped) by what appears to be the status-check/health-check code path itself, every single time that path runs — independent of whether the underlying OAuth flow actually succeeded.
- The
redirectUriwritten back ishttp://localhost:3118/callback, which does not correspond to anything in the local config (--callback-port 8766was passed explicitly atmcp addtime) or the identity provider's registered redirect URIs. This suggests a hardcoded/leftover default value is being used somewhere in the health-check or token-refresh code path, unconditionally overwriting the good record.
Ruled out
- Identity-provider misconfiguration: confirmed (via the IdP admin API) that the OAuth client for this server is registered with exactly one redirect URI,
http://localhost:8766/callback, matching what's passed via--callback-port. - Stale local cache: deleting the corrupted
mcpOAuthentry and~/.claude/mcp-needs-auth-cache.jsonbefore retrying does not prevent the same corruption from reappearing.
Impact
Any HTTP MCP server requiring a fixed/pre-registered OAuth redirect URI (a common requirement for OIDC providers like Keycloak, which reject dynamic/arbitrary ports) can never complete authentication in Claude Code — the token is destroyed by the CLI's own status-check logic before it can be used, even though the browser-side OAuth flow itself completes successfully.