Skip to content

Latest commit

 

History

History
64 lines (53 loc) · 26.6 KB

File metadata and controls

64 lines (53 loc) · 26.6 KB

Workarounds

Claude Code

Works around known issues in Claude Code's HTTP transport:

  • Bearer token not sent — Claude Code ignores Authorization header on tool calls (#28293, #33817)
  • Missing Accept header — servers return 406, misinterpreted as auth failure (#42470)
  • OAuth fallback loop — Claude Code enters OAuth discovery even when not needed (#34008, #39271)
  • Session lost after disconnect — mcp-stdio recovers MCP sessions automatically on 404 (#34498, #38631)
  • OAuth scope omitted — Claude Code sends no scope parameter in authorization requests, causing strict OAuth servers to reject the flow (#4540, and a v2.1.196 regression that sends no scope at all when none is configured, rejected by Microsoft Entra ID with AADSTS900144 #72440); mcp-stdio sends scopes via --oauth-scope and omits the parameter entirely when unset — never an empty scope=
  • Proxy settings ignored — Claude Code does not respect NO_PROXY (#34804); mcp-stdio inherits proxy settings from httpx
  • prompt=consent forced on every authorize request — Claude Code v2.1.109 hardcodes prompt=consent on the OAuth authorize URL, which blocks sign-in on Microsoft Entra ID tenants that disable user consent (a common enterprise policy) even when admin consent has already been granted tenant-wide (#49722); mcp-stdio omits prompt= from the authorize request, letting the authorization server decide whether the consent UI is needed based on the existing consent state
  • tools/list pagination ignored — Claude Code sends only the first tools/list request and silently discards nextCursor, so tools beyond page 1 are invisible (breaks MCP gateways and large tool catalogs) (#39586); mcp-stdio follows nextCursor transparently across tools/list, resources/list, resources/templates/list, and prompts/list, returning a single merged response (Streamable HTTP transport only; the legacy SSE transport is not auto-paginated)
  • 403 insufficient_scope step-up never runs — when an MCP server requires broader scopes for a specific tool and returns 403 with a WWW-Authenticate: Bearer error="insufficient_scope", scope="..." challenge, Claude Code only re-fetches Protected Resource Metadata and never requests a new token, so tiered-scope servers are unusable (#44652); mcp-stdio parses the challenge, runs an RFC 9470 step-up authorization with the union of cached and challenge scopes (reusing the cached client — no DCR retry), and retries the original call
  • OAuth discovery silently fails when /.well-known/oauth-authorization-server returns 404 — Claude Code does not fall back to default endpoint paths when the authorization server publishes Protected Resource Metadata but no Authorization Server Metadata, leaving the OAuth flow dead with no browser prompt (e.g. Snowflake Cortex MCP) (#31349); mcp-stdio first tries the RFC 8414 §3 path-inserted URL, then the host-root, and finally falls back to default paths (/authorize, /token, /register) so the flow proceeds without manual endpoint configuration
  • Late response to a cancelled tool call drops the stdio transport — when Claude Code cancels a request via notifications/cancelled, any late JSON-RPC response the server still sends for that id is treated as a framing error, the stdio transport is dropped, and the MCP server reconnects — each cycle costs 5–10 s and heavy cancel usage can put the server in a near-permanent reconnect loop. The MCP spec requires the canceller to silently ignore such responses (#51073); mcp-stdio tracks cancelled ids seen on stdin and drops any matching response on the wire before it reaches the downstream client, which also compensates for servers that violate the reciprocal receiver-side SHOULD (python-sdk#2480). Disable with --no-cancel-filter only for debugging raw upstream traffic.
  • Tool discovery blocked by 405 on GET health check — Claude Code treats a GET Method Not Allowed from an HTTP MCP server as a hard failure at session startup and silently skips tool loading even when the subsequent POST initialize succeeds, breaking spec-compliant stateless servers like Datadog MCP (the MCP Streamable HTTP spec requires 405 on GET when no SSE stream is offered) (#51721; a related variant mis-correlates the JSON-RPC error body of the 405 GET — whose id is null — to the pending tools/list POST, breaking stateless servers like Linear MCP #67194; the mirror server-side fix to stop serving an idle GET stream under stateless_http=True is tracked in python-sdk#2474). Claude Code sees mcp-stdio as a stdio MCP server, so the GET health-check path is never exercised — the POST-only relay is structurally immune to this failure mode, and it never cross-correlates an uncorrelatable id:null error to a pending request.
  • Token refresh not triggered when access token expires on long-lived transport — Claude Code's headersHelper is invoked only once at transport creation; when an OAuth access token expires mid-session (typical OIDC TTL is 5 minutes), subsequent tool calls carry the stale token and fail with 401 — and since v2.1.118 the fallback goes to OAuth re-auth rather than transport restart, permanently blocking sessions when the OAuth server does not support dynamic client registration (#53267); mcp-stdio detects each 401 in the relay loop, calls refresh_cached_token() with the stored refresh token, and retries before returning an error — token refresh happens mid-session without any transport restart.
  • Access token not refreshed automatically before expiry — OAuth-based connectors surface "Connection expired" on a recurring basis even though a valid refresh token is stored; a manual reconnect performs the refresh-token exchange in a fraction of a second, confirming the refresh path works but is only invoked on demand rather than when the access token lapses (#65036); mcp-stdio refreshes reactively on every 401 and, independently, proactively in the background shortly before expiry (--oauth-refresh-leeway, opt out with --no-proactive-refresh), so long-running sessions stay connected without any user interaction.
  • Mcp-Session-Id not echoed on requests after initialize — the session id returned on initialize is not sent back on subsequent requests, so session-aware servers (e.g. mcp-grafana, mcp-go) reject tools/list with "Invalid session ID" even though the connection shows as established (#70386); mcp-stdio captures the Mcp-Session-Id response header and injects it on every subsequent request per the Streamable HTTP spec.
  • Relative endpoint URL in the SSE handshake not resolved — when a legacy SSE server returns a relative path (e.g. /message?sessionId=…) in the endpoint event, the flow never reaches initialize (#58517); mcp-stdio resolves a relative endpoint URL against the SSE base URL following the WHATWG SSE processing model, with a same-origin guard to avoid leaking credentials cross-origin.
  • In-flight tool calls hang forever after an SSE drop — when the SSE channel drops, the client logs the drop but neither fails nor retries the tool calls whose replies were pending on the dead stream, leaving them running indefinitely until a manual cancel (#60061); mcp-stdio tracks each request id POSTed on the current SSE stream and, on a stream drop, synthesizes a JSON-RPC -32000 error ("SSE stream disconnected before response arrived; please retry") for every id still awaiting its reply — so the client can retry instead of hanging — while auto-reconnecting the stream. Ids the client already cancelled are skipped, composing with the cancellation filter above.
  • Each terminal re-runs the full OAuth flow — credentials are keyed by the ephemeral localhost callback port chosen per process, so every new terminal is a cache miss that triggers a fresh browser round-trip and orphans the previous entry (#43000); mcp-stdio keys its token store by server URL in ~/.config/mcp-stdio/tokens.json, shared across terminals and sessions.
  • Dynamic Client Registration repeated on every authenticate — a fresh POST /register on each authentication orphans the previously issued client_id and its refresh token, contrary to the MCP authorization guidance to reuse registered credentials (#59460); mcp-stdio persists the client credentials alongside the tokens and reuses them on subsequent authentications, re-registering only when no client is cached or the stored client secret has passed its RFC 7591 client_secret_expires_at.
  • Rotated refresh token not persisted — with authorization servers that issue a new refresh token on every exchange, the rotated token is not written back, so a subsequent refresh replays the already-consumed one and the session drops (#66210); mcp-stdio stores the rotated refresh token on every refresh (keeping the prior one only as an in-flight fallback).
  • OIDC id_token cannot be used as the Bearer token — servers that validate an OIDC id_token at the edge (e.g. behind Google Cloud IAP) reject the opaque access token that is sent instead (#70047); mcp-stdio can present the id_token as the Bearer via --oauth-use-id-token.
  • Tokens without expires_in discarded early — spec-valid long-lived tokens (e.g. GitHub, Todoist) are assigned an arbitrary client-side lifetime and dropped, after which a request is sent with no Authorization header at all (#26281); mcp-stdio treats a token response without expires_in as non-expiring and keeps sending it until the server itself returns 401.

mcp-remote

  • OAuth discovery fails for auth server with path — mcp-remote does not implement the RFC 8414 §3 path insertion rule, causing OAuth metadata discovery to fail when the authorization server URL contains a path component (e.g. multi-tenant or realm-based servers) (mcp-remote#207); mcp-stdio constructs the correct well-known metadata URL.
  • OAuth discovery fails for MCP server behind path-based reverse proxy — when an MCP server is mounted under a sub-path (e.g. Tailscale serve, nginx location /mcp/), Protected Resource Metadata must be fetched at /.well-known/oauth-protected-resource/{path} per RFC 9728 §3.1, not at the host root (mcp-remote#249); mcp-stdio tries the path-aware URL first and falls back to host-root for compatibility.
  • Re-authentication loop when both tokens are rejected — after long inactivity or server-side token revocation, mcp-remote receives the authorization code at the localhost callback but does not exchange it for new tokens, leaving the client looping on the login screen (mcp-remote#256); mcp-stdio clears the stale cache after a failed refresh and drives the full authorization flow through code exchange.
  • SSE stream hangs forever on half-open TCP — long-running MCP tool calls over SSE can end up on a TCP connection that a proxy, NAT, or firewall silently drops; mcp-remote then blocks iter_lines() / reader.read() indefinitely because the transport has no idle timeout and no socket keepalive (mcp-remote#107, mcp-remote#226, typescript-sdk#1883, python-sdk#796). mcp-stdio applies two layers: a 300-second application-level read timeout (--sse-read-timeout, matches the Python SDK default) that triggers the existing reconnect loop, and TCP keepalive on the httpx transport (60s idle + 4×15s probes ≈ 120s half-open detection on Linux / macOS / FreeBSD / NetBSD; SO_KEEPALIVE alone on Windows). Disable either with --sse-read-timeout 0 or --no-tcp-keepalive.
  • Stale callback server causes EADDRINUSE on reconnect — mcp-remote caches the local OAuth callback port in a lockfile and crashes on the next invocation when a prior instance's listener is still bound (mcp-remote#253); mcp-stdio binds an ephemeral port via ("127.0.0.1", 0) for each authorization flow and releases it with server_close() before returning — no lockfile, no stale bind, EADDRINUSE is structurally impossible.
  • Duplicate processes corrupt PKCE code_verifier — when Claude Desktop spawns two concurrent mcp-remote processes for the same MCP server, both race through DCR + PKCE and overwrite each other's code_verifier on disk, causing Invalid code_verifier on token exchange (mcp-remote#251); mcp-stdio keeps the PKCE code_verifier as an in-memory local of the authorization function and never writes it to disk, so concurrent flows cannot corrupt each other's state.
  • HTTP 429 rate limiting not honoured — the MCP TypeScript SDK's StreamableHTTPClientTransport (which mcp-remote uses) does not read Retry-After on a 429 response; it surfaces a generic error, so any client without its own retry logic fails fast on any rate-limited server (typescript-sdk#1892). mcp-stdio parses Retry-After (RFC 9110 §10.2.3, formerly RFC 7231 §7.1.3 — delta-seconds or HTTP-date), sleeps for that long up to a 60-second cap, and retries the request up to MAX_RETRIES times; a missing header falls back to the same linear backoff used for transient errors, and an over-cap wait surfaces the 429 to the client so it can decide whether to retry later.
  • Protected Resource URL mismatch when MCP server URL has query parameters — the MCP TypeScript SDK's selectResourceURL normalises the URL by rewriting query parameter values into the path, so any server that uses ?key=...-style authentication (e.g. Zoho MCP) fails discovery with Protected resource ... does not match expected ... (mcp-remote#244); mcp-stdio builds the well-known URL with urlunsplit((..., parsed.query, "")) so the query component is preserved verbatim on the well-known URI per RFC 9728 §3.1, and passed straight through to the PRM resource comparison.
  • registration_endpoint ignored, hardcoded /register used instead — when a server's RFC 8414 metadata publishes a non-root registration_endpoint (e.g. /s2/oauth/register), mcp-remote ignores it and POSTs to a hardcoded {issuer}/register, returning 404 and blocking DCR on spec-compliant servers (mcp-remote#241); mcp-stdio reads registration_endpoint from the discovery document and calls that URL verbatim, per RFC 7591 + RFC 8414. The hardcoded /register path is only used as a last-resort Phase 3 fallback when both RFC 9728 PRM and RFC 8414 AS metadata are absent.
  • Accept: text/event-stream missing on POST to streamable HTTP servers — mcp-remote does not set Accept: application/json, text/event-stream on the outbound POST, so spec-compliant servers (e.g. MCP on AWS Lambda + lambda-web-adapter) respond with 406 Not Acceptable and the session never initializes without the user manually injecting a headers override (mcp-remote#242); mcp-stdio always sends Accept: application/json, text/event-stream on every POST, which satisfies the Streamable HTTP spec regardless of server implementation and avoids the 406.
  • OAuth discovery does not fall back when 401 carries a non-Bearer WWW-Authenticate — the MCP TypeScript SDK's StreamableHTTPClientTransport only falls back to well-known PRM discovery when the 401 response has no WWW-Authenticate header at all; a Negotiate or other non-Bearer challenge is bubbled up as a hard failure, so servers that expose both SPNEGO and OAuth (common in Windows-integrated enterprise deployments) cannot initiate the OAuth flow even though valid metadata is hosted at the spec-defined well-known URI (typescript-sdk#1946). mcp-stdio performs discovery pre-flight rather than on 401: it probes /.well-known/oauth-protected-resource (path-aware, then host-root) and /.well-known/oauth-authorization-server directly, so the fallback is not gated on the WWW-Authenticate scheme and the OAuth flow proceeds regardless of what authentication schemes the server additionally advertises.
  • Cannot connect to servers that only support static bearer tokens — mcp-remote always initiates an OAuth discovery handshake before any tool call; servers that authenticate via static bearer tokens (e.g. Zabbix MCP Server) respond with 404 on /.well-known/oauth-authorization-server and the proxy gives up before the bearer-auth path is ever reached (zabbix-mcp-server#36); mcp-stdio connects directly with --bearer-token YOUR_TOKEN http://your-server:8080/mcp, skipping OAuth entirely.
  • OAuth token exchange fails with URL-encoded responses — TypeScript SDK's token exchange assumes the response is always JSON; servers that return application/x-www-form-urlencoded (e.g. GitHub OAuth) cause a JSON parse error, blocking authentication (typescript-sdk#759); mcp-stdio's _parse_token_response() checks the Content-Type header and parses application/x-www-form-urlencoded via urllib.parse.parse_qs, so GitHub MCP and similar servers work without extra configuration.
  • No proactive token refresh window — mcp-remote (and adaptOAuthProvider in TypeScript SDK) only refreshes the access token after a 401 has already fired, with no early-refresh leeway. ASes that issue refresh tokens whose lifetime is barely longer than the access token's leave no margin for clock skew, so a refresh attempt can race the token expiry and fail (mcp-remote#252, typescript-sdk#1954). mcp-stdio's ensure_token() performs proactive refresh: a cached access token is treated as expired when its expiry is within --oauth-refresh-leeway seconds (default 60, configurable via flag or MCP_OAUTH_REFRESH_LEEWAY), so the refresh hits well before the AS revokes the token.
  • Session affinity cookies not forwarded across requests — mcp-remote creates a new HTTP client per request, so load-balancer session cookies (e.g. AWSALB, AWSALBCORS) returned in a Set-Cookie response header are discarded; subsequent requests land on a different backend node, breaking server-side session state (mcp-remote#168). mcp-stdio reuses a single httpx.Client instance across all requests within a session; httpx automatically stores Set-Cookie values and re-sends them on subsequent requests to the same origin, so ALB and similar sticky-session cookies are forwarded transparently without any extra configuration.
  • No OAuth support in headless/SSH environments — mcp-remote's OAuth flow requires opening a browser window, making it unusable in SSH sessions, CI/CD pipelines, or other browserless environments; there is no Device Authorization Grant support (mcp-remote#228). mcp-stdio supports RFC 8628 Device Authorization Grant via --oauth-device: it displays a short user code and verification URI on stderr so the user can authenticate from any browser, while the device polls the token endpoint in the background.
  • resource indicator gets a trailing slash appended — TypeScript SDK normalises the resource URL via new URL(...).href, converting https://api.example.com to https://api.example.com/; Atlassian authv2 and similar servers reject this with InvalidTargetError: Incorrect resource parameters (typescript-sdk#1968, mcp-remote#261, and the same normalization class in Claude Code breaking Microsoft Entra ID with AADSTS9010010 claude-code#52871); mcp-stdio passes resource=server_url verbatim in both code exchange and refresh requests — Python's URL handling does not add trailing slashes.
  • DCR hardcodes token_endpoint_auth_method: none, breaking confidential-client servers — mcp-remote always registers with token_endpoint_auth_method: none and sends client_id/client_secret in the POST body; authorization servers that publish only client_secret_basic in token_endpoint_auth_methods_supported (e.g. Microsoft Entra ID v2, some enterprise OIDC providers) reject the resulting token request (mcp-remote#184, mcp-remote#217); mcp-stdio reads token_endpoint_auth_methods_supported from RFC 8414 AS metadata, picks the best supported method (noneclient_secret_postclient_secret_basic), registers with that method via DCR, and applies it consistently across code exchange, token refresh, and Device Authorization Grant polling — client_secret_basic sends credentials as Authorization: Basic base64(percent_encode(client_id):percent_encode(client_secret)) per RFC 6749 §2.3.1.
  • RFC 8707 resource parameter not configurable, breaking AS that reject it — mcp-remote always includes resource=<server_url> in OAuth requests with no way to disable it; authorization servers that reject the parameter (e.g. Microsoft Entra ID v2 with api:// scopes returns AADSTS9010010) cannot be used at all (mcp-remote#218); mcp-stdio supports --no-resource-indicator, which omits the resource parameter from all OAuth requests (authorization URL, token exchange, refresh, and Device Authorization Grant). The flag is persisted in the token store so mid-session refreshes and step-up flows behave consistently without extra configuration.
  • Access token expiry never tracked, so refresh never fires proactively — mcp-remote stores the expires_in value from the token response but does not derive an absolute expiry, so it cannot tell when a token is about to expire and instead waits for a 401, then launches a fresh browser authorization flow that the host client may kill mid-flight (mcp-remote#273, mcp-remote#97); mcp-stdio computes expires_at = now + expires_in when persisting the token and, in ensure_token(), treats a cached token as expired once it is within --oauth-refresh-leeway seconds of that time, refreshing with the stored refresh token before the access token lapses.

MCP SDKs

Cross-cutting interoperability issues in the MCP TypeScript / Python SDKs that surface through servers and clients built on them; mcp-stdio normalizes them on the wire.

  • MCP-Protocol-Version header not sent on subsequent requests — the MCP Streamable HTTP spec (rev 2025-06-18) requires the client to send MCP-Protocol-Version: <negotiated-version> on every request after initialization, and servers that enforce it return 400 Bad Request without it; the header and the body protocolVersion can also disagree on initialize (typescript-sdk#2108, python-sdk#2618); mcp-stdio captures result.protocolVersion from the InitializeResult and injects the header on every subsequent Streamable HTTP request, eliminating the mismatch surface.
  • Tool result hangs the client on raw U+2028 / U+2029 — these characters are legal unescaped inside JSON strings but are JavaScript line terminators, so some clients treat them as line breaks and hang or mis-frame the response (typescript-sdk#2155); mcp-stdio escapes raw U+2028/U+2029 to their \uXXXX form before writing to stdout — a lossless transformation that decodes to the identical character.
  • tools/call with arguments: null rejectedparams.arguments is an optional object, but some clients (Go/Java/C# serializers) emit null for an empty map; strict SDK servers validate the field as optional() — permitting missing but rejecting null — and return -32603 (typescript-sdk#2012); mcp-stdio rewrites a tools/call request's null arguments to {} before forwarding (opt out with --no-normalize-arguments).

Windows

  • CRLF translation on stdio — Python's default TextIOWrapper rewrites \n to \r\n on Windows, corrupting the NDJSON wire format used by MCP. mcp-stdio reconfigures sys.stdin/sys.stdout to bare LF mode so messages stay spec-compliant regardless of host OS (cf. modelcontextprotocol/python-sdk#2433 for the same class of bug in stdio_server).