sdk-cli mode: Streamable HTTP MCP GET-stream aborts ~200-500ms after connect, tools vanish before first call

Status Open
Reported on v2.1.203
Maintainer reply None cached
Activity 0 comments · opened Aug 18, 2026

Summary

In "sdk-cli" mode (--input-format stream-json --output-format stream-json, the mode used by SDK-driven/headless Claude Code automation), the Streamable HTTP client transport for a remote MCP server aborts its GET notification stream ~200-500ms after a successful connection, before any tool call can complete — even though the connection, authentication, and initialize handshake all succeed cleanly. The same server, same tools, and same OAuth tokens work perfectly in plain interactive cli mode. This makes the affected MCP server's tools permanently unusable from any headless/automated Claude Code session.

Environment

  • Claude Code version: 2.1.203 (confirmed via User-Agent: claude-code/2.1.203 (sdk-cli) in client logs)
  • Platform: Linux, Node v26.3.0 (per client's own debug log)
  • MCP server: a self-hosted Streamable HTTP MCP server (@modelcontextprotocol/sdk's StreamableHTTPServerTransport), OAuth 2.0 + PKCE, reachable over the public internet via HTTPS

Reproduction

Run Claude Code exactly as an SDK/automation harness would:

echo '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Call <a tool from the affected MCP server> and return the raw result."}]}}' \
  | claude --input-format stream-json --output-format stream-json --verbose --permission-mode dontAsk \
      --allowedTools "mcp__<server>__<tool>" -p

Result: the system/init event reports the MCP server as "status":"connected", but the very next ToolSearch call for that tool returns "No matching deferred tools found" — the tool has already vanished from the index by the time the model tries to use it, seconds into the very same turn.

The corresponding MCP client debug log (~/.cache/claude-cli-nodejs/<project>/mcp-logs-plugin-<name>/*.jsonl) shows:

14:03:25.969 | HTTP transport options: {"url":"https://<server>/mcp","headers":{"User-Agent":"claude-code/2.1.203 (sdk-cli)","Accept-Encoding":"identity"},"hasAuthProvider":true,"timeoutMs":60000}
14:03:25.984 | Has refresh token: true
14:03:26.196 | Successfully connected (transport: http) in 228ms
14:03:26.196 | Connection established with capabilities: {"hasTools":true, ...}
14:03:26.435 | HTTP connection dropped after 0s uptime
14:03:26.435 | Connection error: SSE stream disconnected: AbortError: The operation was aborted.
14:03:26.435 | Terminal connection error 1/3

~239ms after a clean, fully-authenticated connection, the GET/SSE notification stream gets an AbortError and the client gives up (only 1 of 3 configured reconnect attempts is logged before the tool disappears from ToolSearch for the rest of the turn).

What we ruled out (server-side and infra)

We control the affected MCP server's source and its reverse proxy, so we were able to rule out several server-side and network hypotheses over an extended investigation:

  1. Not an auth/token bug. Token/refresh-token handling is correct and verified (Has refresh token: true, Successfully connected, valid hasTools:true capabilities). A separate, now-fixed bug where invalid tokens returned bare HTTP 500 instead of 401/400 was found and fixed independently — this abort happens on a freshly and successfully authenticated connection, unrelated to that fix.
  2. Not HTTP/2-specific. The server negotiates HTTP/2 via its reverse proxy (Traefik, ALPN). We forced the reverse proxy to offer only HTTP/1.1 (tls.options.alpnProtocols: ["http/1.1"]) and confirmed via curl that the negotiated protocol was indeed HTTP/1.1 for this endpoint. The abort reproduced identically:

``
Successfully connected (transport: http) in 228ms
HTTP connection dropped after 0s uptime
Connection error: SSE stream disconnected: AbortError: The operation was aborted.
``
(Same ~200-500ms timing, same error, under both HTTP/1.1 and HTTP/2.)

  1. Not a missing "Connection: keep-alive" / h2 header violation. We already ship a server-side fix stripping the SDK's default Connection: keep-alive header from h2 responses (which RFC 7540 §8.1.2.2 forbids) — no change in behavior with or without it.
  2. Not "no bytes sent" / idle-stream heuristic. The server sends an SSE comment-line keep-alive (: ping\n\n) immediately on stream open and every 50ms for the first 3 seconds specifically to beat any such heuristic. The abort still happens ~200-500ms in, after at least one ping should have been delivered.
  3. Server does hold the connection open correctly. Correlating the reverse proxy's own access logs with the client's abort timestamp shows the proxy received a clean 200 and the connection was closed from the client side, not reset by the server or an intermediary.
  4. Not a general "sdk-cli mode can't hold a Streamable HTTP GET stream" issue. The exact same Claude Code binary, same -p/--input-format stream-json invocation, same OS/network, connecting instead to a trivial local reference MCP server (plain HTTP/1.1, no TLS, no reverse proxy, using the same @modelcontextprotocol/sdk's StreamableHTTPServerTransport with no custom modifications) — ToolSearch finds the tool and the call succeeds normally:

``
TOOL_USE: mcp__oauth-hypothesis-test-v2__ping_tool {}
TOOL_RESULT: [{'type': 'text', 'text': 'pong'}]
``

  1. Plain interactive cli mode against the SAME real server works. A long-running interactive session (User-Agent: ... (cli)) held the identical connection open for 6975+ seconds with normal use before an unrelated, much-later disconnect.

What this narrows the bug down to

The abort is specific to the combination of: (a) sdk-cli mode's Streamable HTTP client transport, and (b) something about this particular server's response — but not its HTTP version, its Connection header, or the presence/timing of keep-alive bytes, all of which we controlled for directly. It is not a general "can't hold a slow/silent HTTP stream" limitation of the sdk-cli client, since the same client mode is fine against a trivial reference server implemented with the same open-source MCP SDK transport class. We were not able to identify what specific difference in the two servers' responses triggers the client-side abort — that requires visibility into the sdk-cli transport's internal abort/watchdog logic, which is closed-source from our side.

Impact

Any MCP server accessed exclusively through headless/SDK-driven Claude Code sessions (CI pipelines, background agents, claude -p automation) that happens to trigger this condition is effectively unusable from those sessions — tools connect, report hasTools:true, and then disappear before any call can be made — while working completely normally in interactive sessions. This is easy to misdiagnose as a server auth/config bug (which is what led to an extended, ultimately inconclusive investigation on our end) rather than a client-side transport issue.

Ask

  • Any insight into what could cause the sdk-cli transport's Streamable-HTTP GET-stream AbortError at ~200-500ms post-connect, independent of HTTP version, Connection header, and keep-alive traffic, given it does not occur against a trivial reference server using the same SDK transport class.
  • Happy to provide further logs/traces or a minimal public reproduction server if useful.

View original on GitHub ↗