MCP HTTP-Streamable Server: Init-Handshake fails on reconnect

Status Fixed / completed
Maintainer reply ✓ Yes — localden
Activity 8 comments · opened May 4, 2026 · closed May 23, 2026
💡 Likely answer: A maintainer (localden, collaborator) responded on this thread — see the highlighted reply below.

MCP HTTP-Streamable Server: Init-Handshake fails on reconnect

Description

When a local MCP server (Streamable HTTP transport, @modelcontextprotocol/sdk) is restarted while a Code-CLI session is active, the Code-CLI client keeps the old mcp-session-id in internal state and continues sending tool calls with this ID. After restart, the server no longer recognizes the ID and returns:

Streamable HTTP error: Error POSTing to endpoint: event: message
data: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Server not initialized"},"id":null}

Expected: On "Server not initialized" response, the client should automatically send a fresh initialize request, rebuild the session, and retry the original tool call.

Actual: Client returns the error to the caller (LLM tool-use). Only a full Code-CLI restart creates a new session — the /mcp slash-command reload does not solve it.

Reproduction

  1. Start Code-CLI session with a configured Streamable-HTTP MCP server
  2. Call any MCP tool (server registers session)
  3. Restart the server process (e.g. systemctl restart wiki-mcp.service)
  4. Call MCP tool again
  5. Expected: success after automatic re-initialize
  6. Actual: "Bad Request: Server not initialized"

Setup

  • Code-CLI version: latest (Opus 4.7 1M-context build, 2026-05)
  • MCP-Server: @modelcontextprotocol/sdk StreamableHTTPServerTransport, deployed as own systemd daemon behind nginx
  • Configured via Anthropic Custom-Connector (claude.ai web UI) — verified claude mcp list lists it as claude.ai <name>: ✓ Connected

Relation to existing issues

  • #43177 "MCP stdio servers never auto-reconnect after disconnect" — same family but stdio transport. This report is HTTP-Streamable (StreamableHTTPServerTransport).
  • #42509 "mcp-proxy.anthropic.com returns 502 after initial handshake" — overlaps Class A symptom but with proxy-side 502. This report's Class A is the JSON-RPC "Server not initialized" from the SDK, plus Class B "Server already initialized" triggered by Anthropic Custom-Connector backend, neither of which #42509 covers.

Why this matters

Server restarts happen during code updates and config patches. Today every restart forces a full Code-CLI restart (losing prompt cache, requiring re-orientation). The MCP standard (JSON-RPC 2.0 + initialize handshake) allows clean re-initialization in principle.

Workaround

Full Code-CLI restart (/exit + new launch). Chat context survives in transcript JSONL. Practical sequence in shared multi-client setup: systemctl restart <mcp-server> → immediately claude mcp list (race) before other clients reconnect.

Possible fixes (for discussion)

  1. Client-Side: On "Server not initialized" response, discard the session-id, send fresh initialize, retry the tool call
  2. SDK Server-Side relaxation: StreamableHTTPServerTransport accepts arbitrary client session-id and initializes lazily on first request (own server-side mitigation tried — non-trivial because of internal state machine)
  3. Protocol clarification: HTTP 410 Gone on unknown session-id instead of JSON-RPC error — gives client a clear signal to re-initialize

Live evidence — two symptom classes on the same setup

Setup with two MCP servers (wiki-mcp and knowledge-mcp) on the same Streamable-HTTP-SDK base, configured via Anthropic Custom-Connector. Two related symptom classes observed repeatedly:

Class A — "Server not initialized" after server restart (Code-CLI client)

Code-CLI with HTTP-MCP server connection. Server restart → tool calls fail with "Server not initialized". Reproducible (2026-05-03, 0830–0852 CEST). Code-CLI restart workaround confirmed.

Class B — "Server already initialized" on connector add / reconnect (Anthropic Custom-Connector backend)

Other path: claude.ai Custom-Connector is added, OAuth flow succeeds, but the backend's first MCP initialize request returns:

HTTP/2 400
content-type: application/json
{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}

Reproducibly triggered when the MCP server (StreamableHTTPServerTransport) still holds a session from the previous client. The Anthropic Custom-Connector backend retries multiple times — all attempts fail with the same error. Connector is never registered.

Live evidence (timestamps CEST):

  • 2026-05-03 14:50 — first knowledge-mcp connector-add attempts fail with verify(knowledge) failed: aud (nginx njs layer). After custom-audience workaround the auth passes, immediately followed by "Server already initialized". Workaround: systemctl restart knowledge-mcp → successful connect.
  • 2026-05-03 20:25 — connector ran stably since 14:53. After ~5h35min server idle (no MCP call), reuse attempt fails with auth error ofid_f46ca96c23d9fd4d. Workaround systemctl restart knowledge-mcp → green.
  • 2026-05-04 04:00–09:11 — secondary client (local OpenClaw gateway with @modelcontextprotocol/sdk@1.29.0) tries auto-reconnect every 10–15 min via health-monitor. 56 consecutive "Server already initialized" errors in /tmp/openclaw/openclaw-2026-05-04.log, all on knowledge-mcp. Server-side: knowledge-mcp.service was up the whole time (Active: active (running) since 2026-05-03 20:28:31 CEST; 14h ago).
  • 2026-05-04 11:22 — direct race-test: sudo systemctl restart knowledge-mcp on the server, then within 1s claude mcp list from Code-CLI → connector flips to ✓ Connected. Direct curl probe a second later with the same Bearer token returns "Server already initialized" — proving that the server post-restart only allows one client to complete initialize, all subsequent clients are locked out until next restart.

Hypothesis

Server-state (StreamableHTTPServerTransport's internal session-map) accumulates or invalidates without client-side resync:

  • Class A: Server forgot the session, client still holds it → "not initialized"
  • Class B: Server holds a session, client (or Anthropic Custom-Connector backend) has none → "already initialized"

Both classes are resolved by server restart → server restart cleans the session-map and allows a fresh init. But the restart only "buys" one fresh client connection — the next client to attempt initialize immediately gets locked out again.

Appendix — Server-side mitigation (limited effect)

Tried in our own server, in StreamableHTTPServerTransport, to reuse the client session-id instead of generating a new one:

sessionIdGenerator: () => sessionId || randomUUID()

Helps with fresh connections after restart, but does not solve the reconnect problem — the transport state machine expects an initialize handshake, and a tool call with an unknown session-id arrives before it.

View original on GitHub ↗

8 Comments

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/55228
  2. https://github.com/anthropics/claude-code/issues/55943
  3. https://github.com/anthropics/claude-code/issues/42509

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

livsol123 · 3 months ago

Update — 2026-05-05: Confirmed this bug is not server-specific.

Reproduced today on a second independent local MCP server (wiki-mcp, separate from the original knowledge-mcp report) using @modelcontextprotocol/sdk's StreamableHTTPServerTransport. After systemctl restart wiki-mcp, the Claude Code client returned the same "Bad Request: Server not initialized" error on the next tool call — identical failure mode. Two independent servers, same client, same symptoms. The bug is in the Claude Code MCP client (likely a stale session-id cache that survives the upstream server's restart), not in any individual server.

Differential observation: a parallel Node test client using @modelcontextprotocol/sdk/client directly (no stale state) connected to the same restarted wiki-mcp server and initialized cleanly on the first request. This supports the hypothesis that the client-side StreamableHTTPClientTransport doesn't invalidate its session-id when receiving a "Server not initialized" response.

---

Re: the auto-closure duplicate suggestion — not a duplicate of #55228 (which describes a 5-attempt reconnect cap as the visible failure mode), but related. Both may be aspects of the same HTTP Streamable transport fragility on the client side, with this issue (#55970) capturing the init-handshake state layer and #55228 capturing the retry-cap behavior. #55943 is OAuth-specific (different surface); #42509 is mcp-proxy 502 (different transport path). Worth triaging separately.

livsol123 · 3 months ago

Update 2026-05-07 — additional finding from continued reproduction

While debugging a recurring ✗ Failed to connect against one of our local MCP daemons (knowledge-mcp), we identified a second, orthogonal root cause that produces the same surface symptom as this issue but is in fact a server-side bug — not the client-side reconnect issue described in the original report.

Two orthogonal classes

| Class | Root cause | Fix scope |
|---|---|---|
| (A) Daemon single-instance bug | MCP server built with one global StreamableHTTPServerTransport instance instead of a per-mcp-session-id map. First client initialize marks the transport as initialized; every subsequent initialize (e.g. after Code-CLI restart) is rejected with {"error":{"code":-32600,"message":"Invalid Request: Server already initialized"}}. Daemon also returns plain JSON instead of SSE. | Server-side. Fixed today by switching to per-session pattern (see below). |
| (B) Code-CLI stale session-id cache after server restart | Code-CLI client retains the old mcp-session-id internally; after server restart the server no longer knows the id; client emits tool calls with the stale id and gets Server not initialized; client does not auto-emit a fresh initialize. | This issue (#55970). Still open. |

Why I'm reporting class (A) here

We initially mis-attributed all ✗ Failed to connect cases on knowledge-mcp to class (B). It turned out only wiki-mcp had the (B) symptom; knowledge-mcp had (A) on top of (potentially) (B). After fixing (A), knowledge-mcp: ✓ Connected on first attempt — so (A) was a real, distinct bug masking (B)-style observations on that daemon.

That said, (B) is unchanged on wiki-mcp, which already uses the per-session pattern. After systemctl restart wiki-mcp mid-session, Code-CLI still returns Server not initialized on the next tool call until a full /exit + claude restart. So the original report stands — Code-CLI does not auto-re-initialize on stale-session-id rejection.

Server-side fix (class A — for reference, not part of this issue)

Anti-pattern in our knowledge-mcp daemon (server.js):

let sessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => sessionId || randomUUID(),
});
await server.connect(transport);                     // ← single global

const httpServer = createServer(async (req, res) => {
  // ...
  await transport.handleRequest(req, res);           // ← all requests share it
});

Fixed by mirroring our wiki-mcp daemon's per-session pattern:

const sessions = new Map();
function createMcpServer() { /* ... server.tool(...) calls inside ... */ return server; }

const httpServer = createServer(async (req, res) => {
  // ... auth check ...
  const sessionId = req.headers["mcp-session-id"] || null;
  let entry = sessionId ? sessions.get(sessionId) : null;
  if (!entry) {
    const newEntry = { transport: null, mcp: createMcpServer() };
    newEntry.transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => sessionId || randomUUID(),
      onsessioninitialized: (id) => sessions.set(id, newEntry),
    });
    newEntry.transport.onclose = () => {
      const sid = newEntry.transport.sessionId;
      if (sid) sessions.delete(sid);
    };
    await newEntry.mcp.connect(newEntry.transport);
    entry = newEntry;
  }
  // ... body parse ...
  await entry.transport.handleRequest(req, res, body);
});

After this patch, two consecutive initialize requests with different Mcp-Session-Id headers both succeed with correct SSE responses. No "already initialized" rejection.

Suggestion for the SDK / docs

If the official position is that StreamableHTTPServerTransport is a single-session abstraction, this is fine — but the SDK or docs could be more explicit: a Streamable-HTTP MCP server must maintain a per-mcp-session-id transport map; sharing one transport across requests is not just a performance choice, it deadlocks reconnect entirely. A canonical multi-session example in the SDK README would have saved us the diff.

For class (B) specifically (Code-CLI side), the proposed client-side fix in the original report — on Server not initialized discard the cached session-id and emit a fresh initialize — would resolve our remaining wiki-mcp reconnect symptom.

Happy to provide repro snippets for either class if helpful.

jmealo · 3 months ago

I believe that I'm hitting this too.

ptomsich · 3 months ago

Confirming the same root cause on a Rust remote MCP server (rmcp 1.6). Our setup produces a slightly different on-the-wire signature -- worth adding to the report so the proxy-side fix covers both variants:

Different status, same bug. Our server returns plain HTTP 404 (no JSON-RPC body) when a request arrives with an Mcp-Session-Id it doesn't recognize -- per the MCP 2025-03-26 Streamable-HTTP transport spec, that 404 is the explicit signal for "session gone, please reinitialize." The Claude-Code proxy doesn't follow that signal; instead it surfaces:

{"jsonrpc":"2.0","id":<n>,"error":{"code":-32600,"message":"Anthropic Proxy: Invalid content from server","data":null}}

to the client and gets stuck. So the same client-side defect manifests as -32000 "Server not initialized" against @modelcontextprotocol/sdk JS servers (your case) and as -32600 "Invalid content from server" against rmcp Rust servers (ours). Both are the proxy failing to discard the stale session id and reinit.

Concrete evidence -- from our server's per-request logs after a v0.8.1 -> v0.8.2 package upgrade restarted the process. Four consecutive tools/call POSTs from the same client, all carrying a session id minted by the pre-upgrade process:

2026-05-15T14:13:56Z mcp request session_id="704c3cdb-..." jsonrpc_method="tools/call" jsonrpc_id="4" body_len=348  status=404 elapsed_ms=0
2026-05-15T14:14:32Z mcp request session_id="704c3cdb-..." jsonrpc_method="tools/call" jsonrpc_id="5" body_len=5497 status=404 elapsed_ms=0
2026-05-15T14:14:35Z mcp request session_id="704c3cdb-..." jsonrpc_method="tools/call" jsonrpc_id="6" body_len=4688 status=404 elapsed_ms=0
2026-05-15T14:14:47Z mcp request session_id="704c3cdb-..." jsonrpc_method="tools/call" jsonrpc_id="7" body_len=348  status=404 elapsed_ms=0

All four return status=404 elapsed_ms=0 -- deterministic, sub-millisecond, server-side. Body size and parallelism are not relevant (348 bytes fail identically to 5497 bytes). The proxy surfaces all four as the -32600 error to the user.

One difference in workaround: in our setup /mcp reconnect (the slash-command) does recover the connection -- it forces the proxy to drop its cached Mcp-Session-Id and re-issue initialize. You report /mcp reload doesn't help for the JS SDK case, only a full claude restart does. Worth a separate look: either /mcp reconnect semantics differ between custom-connector and CLI-configured remote servers, or the JS SDK fails the subsequent initialize for an unrelated reason once it's already issued one.

Suggested fix (same as yours): in the proxy's POST /mcp retry path, treat the following two responses identically -- both mean "session gone":

  1. HTTP 404 on a request carrying an Mcp-Session-Id (MCP 2025-03-26 spec).
  2. JSON-RPC error body with code -32000 and "Server not initialized" (JS SDK behavior).

In either case: discard the cached session id, run initialize against the same endpoint, replay the original tool call. Filed as #59410 with full context before finding this issue; closing it as a duplicate.

CodeReclaimers · 3 months ago

Confirming on a Python mcp SDK (FastMCP) server runtime — and adding a new datum: the failure can occur without a server restart.

Hitting the same client-side defect against a different server stack:

  • Server: Python mcp==1.26.0 (mcp.server.fastmcp.FastMCP over streamable_http_app()), local HTTP, token-gated.
  • Client: Claude Code 2.1.143, Linux.
  • Wire format on failure: plain HTTP 404 body {"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Session not found"}} — emitted at mcp/server/streamable_http_manager.py:280-297 when an incoming Mcp-Session-Id is not in _server_instances. This matches @ptomsich's rmcp 404 variant and is the exact MCP 2025-03-26 spec signal for "session gone, reinitialize."

New observation: server-restart is not necessary to trigger the client-side stall. Every prior repro on this thread (and on the just-closed-as-dup #60949 / #59442) starts with systemctl restart/process restart/deploy. In my case the server process had continuous uptime of 5+ days (single PID, no restarts, journal continuous) when the client's session went stale mid-flight. Timeline from the client transcript:

  • 13:55:48Z Claude Code session starts, MCP initialize succeeds, Mcp-Session-Id cached.
  • 13:56:33Z13:59:24Z Four successful tools/call round-trips (list_projects, get_project_status, write_session_note ×2).
  • 14:00:01Z Next list_projects returns HTTP 404 / "Session not found". Every subsequent call from this Claude Code instance returns the same. The session is permanently bricked for the remaining 5+ days the user keeps it open.

Server-side: no Cleaning up crashed session ... log entry was emitted in that window (the only path that removes an entry from _server_instances, at streamable_http_manager.py:266-271, logs unconditionally when it fires). So either (a) the entry was evicted without going through that path, or (b) the client started sending an Mcp-Session-Id the server never issued. Either way, the recovery contract is the same — on 404 / code -32600 "Session not found", drop the cached id, re-initialize, retry once — and that is the contract Claude Code is still not honoring.

Other tracking:

  • #60949 and #59442 closed onto this issue today (per @localden's consolidation note on #60949).
  • #56456 is the mirror-image leak — the client never DELETEs its session on shutdown, so _server_instances accumulates dead entries indefinitely on the server side. Likely the same code path.
  • /mcp reconnect does not actually reinitialize the HTTP transport in 2.1.143 — only full process exit recovers (confirming #60949's observation on a separate runtime).
localden collaborator · 3 months ago

Thanks for the report. The client now detects when a Streamable HTTP server no longer recognizes the session (after a server restart) and automatically re-initializes instead of surfacing the error. This covers both the spec-standard 404 response and the 400 "Server not initialized" response that some server implementations return.

For the long-term fix in the TypeScript SDK, see modelcontextprotocol/typescript-sdk#2125.

github-actions[bot] · 1 month ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.