Works around known issues in Claude Code's HTTP transport:
- Bearer token not sent — Claude Code ignores
Authorizationheader 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
scopeparameter 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-scopeand omits the parameter entirely when unset — never an emptyscope= - Proxy settings ignored — Claude Code does not respect
NO_PROXY(#34804); mcp-stdio inherits proxy settings from httpx prompt=consentforced on every authorize request — Claude Code v2.1.109 hardcodesprompt=consenton 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 omitsprompt=from the authorize request, letting the authorization server decide whether the consent UI is needed based on the existing consent statetools/listpagination ignored — Claude Code sends only the firsttools/listrequest and silently discardsnextCursor, so tools beyond page 1 are invisible (breaks MCP gateways and large tool catalogs) (#39586); mcp-stdio followsnextCursortransparently acrosstools/list,resources/list,resources/templates/list, andprompts/list, returning a single merged response (Streamable HTTP transport only; the legacy SSE transport is not auto-paginated)- 403
insufficient_scopestep-up never runs — when an MCP server requires broader scopes for a specific tool and returns 403 with aWWW-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-serverreturns 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-filteronly for debugging raw upstream traffic. - Tool discovery blocked by 405 on GET health check — Claude Code treats a GET
Method Not Allowedfrom an HTTP MCP server as a hard failure at session startup and silently skips tool loading even when the subsequent POSTinitializesucceeds, 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 — whoseidisnull— to the pendingtools/listPOST, breaking stateless servers like Linear MCP #67194; the mirror server-side fix to stop serving an idle GET stream understateless_http=Trueis 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 uncorrelatableid:nullerror to a pending request. - Token refresh not triggered when access token expires on long-lived transport — Claude Code's
headersHelperis 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, callsrefresh_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-Idnot echoed on requests afterinitialize— the session id returned oninitializeis not sent back on subsequent requests, so session-aware servers (e.g. mcp-grafana, mcp-go) rejecttools/listwith "Invalid session ID" even though the connection shows as established (#70386); mcp-stdio captures theMcp-Session-Idresponse header and injects it on every subsequent request per the Streamable HTTP spec.- Relative
endpointURL in the SSE handshake not resolved — when a legacy SSE server returns a relative path (e.g./message?sessionId=…) in theendpointevent, the flow never reachesinitialize(#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
-32000error ("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 /registeron each authentication orphans the previously issuedclient_idand 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 7591client_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_tokencannot be used as the Bearer token — servers that validate an OIDCid_tokenat the edge (e.g. behind Google Cloud IAP) reject the opaque access token that is sent instead (#70047); mcp-stdio can present theid_tokenas the Bearer via--oauth-use-id-token. - Tokens without
expires_indiscarded 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 noAuthorizationheader at all (#26281); mcp-stdio treats a token response withoutexpires_inas non-expiring and keeps sending it until the server itself returns 401.
- 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_KEEPALIVEalone on Windows). Disable either with--sse-read-timeout 0or--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 withserver_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'scode_verifieron disk, causingInvalid code_verifieron token exchange (mcp-remote#251); mcp-stdio keeps the PKCEcode_verifieras 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 readRetry-Afteron 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 parsesRetry-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 toMAX_RETRIEStimes; 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
selectResourceURLnormalises the URL by rewriting query parameter values into the path, so any server that uses?key=...-style authentication (e.g. Zoho MCP) fails discovery withProtected resource ... does not match expected ...(mcp-remote#244); mcp-stdio builds the well-known URL withurlunsplit((..., 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 PRMresourcecomparison. registration_endpointignored, hardcoded/registerused instead — when a server's RFC 8414 metadata publishes a non-rootregistration_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 readsregistration_endpointfrom the discovery document and calls that URL verbatim, per RFC 7591 + RFC 8414. The hardcoded/registerpath 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-streammissing on POST to streamable HTTP servers — mcp-remote does not setAccept: application/json, text/event-streamon 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 aheadersoverride (mcp-remote#242); mcp-stdio always sendsAccept: application/json, text/event-streamon 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-
BearerWWW-Authenticate— the MCP TypeScript SDK'sStreamableHTTPClientTransportonly falls back to well-known PRM discovery when the 401 response has noWWW-Authenticateheader at all; aNegotiateor 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-serverdirectly, so the fallback is not gated on theWWW-Authenticatescheme 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-serverand 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 theContent-Typeheader and parsesapplication/x-www-form-urlencodedviaurllib.parse.parse_qs, so GitHub MCP and similar servers work without extra configuration. - No proactive token refresh window — mcp-remote (and
adaptOAuthProviderin 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'sensure_token()performs proactive refresh: a cached access token is treated as expired when its expiry is within--oauth-refresh-leewayseconds (default 60, configurable via flag orMCP_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 aSet-Cookieresponse header are discarded; subsequent requests land on a different backend node, breaking server-side session state (mcp-remote#168). mcp-stdio reuses a singlehttpx.Clientinstance across all requests within a session; httpx automatically storesSet-Cookievalues 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. resourceindicator gets a trailing slash appended — TypeScript SDK normalises the resource URL vianew URL(...).href, convertinghttps://api.example.comtohttps://api.example.com/; Atlassian authv2 and similar servers reject this withInvalidTargetError: 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 passesresource=server_urlverbatim 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 withtoken_endpoint_auth_method: noneand sendsclient_id/client_secretin the POST body; authorization servers that publish onlyclient_secret_basicintoken_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 readstoken_endpoint_auth_methods_supportedfrom RFC 8414 AS metadata, picks the best supported method (none→client_secret_post→client_secret_basic), registers with that method via DCR, and applies it consistently across code exchange, token refresh, and Device Authorization Grant polling —client_secret_basicsends credentials asAuthorization: Basic base64(percent_encode(client_id):percent_encode(client_secret))per RFC 6749 §2.3.1. - RFC 8707
resourceparameter not configurable, breaking AS that reject it — mcp-remote always includesresource=<server_url>in OAuth requests with no way to disable it; authorization servers that reject the parameter (e.g. Microsoft Entra ID v2 withapi://scopes returnsAADSTS9010010) cannot be used at all (mcp-remote#218); mcp-stdio supports--no-resource-indicator, which omits theresourceparameter 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_invalue 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 computesexpires_at = now + expires_inwhen persisting the token and, inensure_token(), treats a cached token as expired once it is within--oauth-refresh-leewayseconds of that time, refreshing with the stored refresh token before the access token lapses.
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-Versionheader not sent on subsequent requests — the MCP Streamable HTTP spec (rev 2025-06-18) requires the client to sendMCP-Protocol-Version: <negotiated-version>on every request after initialization, and servers that enforce it return400 Bad Requestwithout it; the header and the bodyprotocolVersioncan also disagree on initialize (typescript-sdk#2108, python-sdk#2618); mcp-stdio capturesresult.protocolVersionfrom theInitializeResultand 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+2029to their\uXXXXform before writing to stdout — a lossless transformation that decodes to the identical character. tools/callwitharguments: nullrejected —params.argumentsis an optional object, but some clients (Go/Java/C# serializers) emitnullfor an empty map; strict SDK servers validate the field asoptional()— permitting missing but rejecting null — and return-32603(typescript-sdk#2012); mcp-stdio rewrites atools/callrequest's nullargumentsto{}before forwarding (opt out with--no-normalize-arguments).
- CRLF translation on stdio — Python's default
TextIOWrapperrewrites\nto\r\non Windows, corrupting the NDJSON wire format used by MCP. mcp-stdio reconfiguressys.stdin/sys.stdoutto bare LF mode so messages stay spec-compliant regardless of host OS (cf. modelcontextprotocol/python-sdk#2433 for the same class of bug instdio_server).