[BUG] MCP session-expiry recovery retries `tools/call` only; `readMcpResource` still fails once with `Connection closed` after a Streamable HTTP server restart

Status Open
Reported on v2.1.274
Maintainer reply None cached
Activity 0 comments · opened Sep 17, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

#55970 added transparent recovery when a Streamable HTTP server no longer recognizes the client's Mcp-Session-Id: the client detects the 404, re-initializes, and retries the failed call once. That retry is wired into the MCP tool-call wrapper only. readMcpResource has no equivalent, so the first resource read after a server restart is surfaced to the model as:

Error: Connection closed

The reconnect itself succeeds in the background within ~60 ms, so the very next call works. The model has no way to know that and typically concludes the server is down.

This is a "first call after restart fails" bug, not a "server unreachable" bug. It hits hardest with servers whose instructions tell the model to read a resource first (MCP for Unity says to read mcpforunity://instances before calling tools), because the first request after every editor restart is then always a resource read.

Ordering of events, from the client debug log (mcp-logs-<server>/*.jsonl) after the server process was killed and relaunched on the same port a few minutes earlier:

04:38:06.127  HTTP connection dropped after 11111s uptime
04:38:06.128  Connection error: SSE stream disconnected: TypeError: The socket connection was closed unexpectedly.
04:38:08.632  Connection error: Maximum reconnection attempts (2) exceeded.
04:38:08.632  SSE GET-stream reconnection exhausted; leaving transport up (POST still works)
              ... server relaunched at ~04:39, nothing sent until: ...
04:43:18.326  Connection error: Error POSTing to endpoint: {"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Session not found"}}
04:43:18.326  MCP session expired (server no longer recognizes session ID), triggering reconnection
04:43:18.326  Closing transport (session expired)
04:43:18.328  HTTP transport closed/disconnected, attempting automatic reconnection
04:43:18.361  Successfully connected (transport: http) in 30ms
04:43:18.398  HTTP reconnection successful after 60ms (attempt 1)
04:43:33.039  Tool 'set_active_instance' completed successfully in 7ms      <- next call, fine

The request at 04:43:18.326 was readMcpResource. The 404 fires the session-expired handler, which closes the transport; the SDK's protocol layer then rejects the still-pending resources/read with -32000 Connection closed, and ReadMcpResourceTool rethrows it unchanged.

For comparison, the same situation on a tool call recovers (client log from an earlier session, same server):

05:27:33.315  Closing transport (session expired)
05:27:33.316  Tool 'execute_code' failed after 0s: Connection closed
05:27:33.316  MCP session expired during tool call (connection closed), clearing connection cache for re-initialization
05:27:33.318  Retrying tool 'execute_code' after session recovery
05:27:33.328  Successfully connected (transport: http) in 11ms
05:27:34.xxx  Tool 'execute_code' completed successfully in 1s

Where the gap is in the shipped build (2.1.274, reading the bundled JS):

  • The MCP tool call() wrapper runs for (attempt = 0; ; attempt++) { client = await ensureConnectedClient(...); try { callMCPTool(...) } catch (e) { if (e instanceof McpSessionExpiredError && attempt < 1) { log("Retrying tool ... after session recovery"); continue } ... } }.
  • callMCPTool is the only place that maps "404 on a session-bearing transport" and "-32000 Connection closed on http" to McpSessionExpiredError.
  • ReadMcpResourceTool.call() does ensureConnectedClient → client.readResource(...) and its catch handles only -32601 MethodNotFound and resource-not-found codes, then rethrows. ReadMcpResourceDirTool has the same shape (not exercised in the repro below).
  • ListMcpResourcesTool is not affected in practice: in the repro it answered from the resource-list cache filled at reconnect time and never sent a request.
  • The Remote Control mcp_call path explicitly logs mcp_session_recovery: session_expired_no_retry and returns "send mcp_reconnect and retry".

Secondary: the SSE GET-stream reconnection exhausted; leaving transport up (POST still works) branch (added for #62198) does not distinguish a 405/404 on the GET stream (server has no GET stream, keep going) from ConnectionRefused on the reconnect (server process is gone, the session cannot be valid anymore). In the second case the client could mark the session dead and re-initialize on the next request instead of after it.

What Should Happen?

All client-facing MCP requests (resources/read, resources/list on a cache miss, resources/templates/list, prompts/get, and the Remote Control mcp_call path) get the same one-shot session-recovery retry that tools/call has, so the first request after a server restart succeeds transparently.

If the retry also fails, the error should say the session expired and reconnect failed, not Connection closed.

Steps to Reproduce

Minimal server, no Unity needed. Python SDK v1 API (mcp>=1.16,<2), stateful Streamable HTTP, default settings:

# /// script
# dependencies = ["mcp>=1.16,<2"]
# ///
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("probe", host="127.0.0.1", port=18762)

@mcp.resource("probe://state")
def state() -> str:
    return "ok"

@mcp.tool()
def ping() -> str:
    return "pong"

mcp.run(transport="streamable-http")
// mcp.json
{"mcpServers":{"probe":{"type":"http","url":"http://127.0.0.1:18762/mcp"}}}

restart.ps1 (Windows; any equivalent "kill the listener on 18762, wait 6 s, start it again, wait 5 s" works):

$conn = Get-NetTCPConnection -LocalPort 18762 -State Listen -ErrorAction SilentlyContinue
foreach ($c in $conn) { Stop-Process -Id $c.OwningProcess -Force }
Start-Sleep -Seconds 6
Start-Process conhost.exe -ArgumentList '--headless','uv','run',"$PSScriptRoot\server.py"
Start-Sleep -Seconds 5
Write-Output "server restarted"

Start the server (uv run server.py), then run one headless session that restarts the server between calls:

claude -p "Execute this exact sequence. Do not skip steps and do not stop on errors. Do not analyze; only execute and report.
Step 1: readMcpResource({server:\"probe\", uri:\"probe://state\"}).
Step 2: Bash: pwsh -NoProfile -File ./restart.ps1
Step 3: readMcpResource({server:\"probe\", uri:\"probe://state\"}).
Step 4: readMcpResource({server:\"probe\", uri:\"probe://state\"}).
Step 5: Bash: pwsh -NoProfile -File ./restart.ps1
Step 6: call the tool mcp__probe__ping.
Finally print one line per step in the form \"Step N: <result or error text verbatim>\"." \
  --strict-mcp-config --mcp-config mcp.json --allowedTools "Bash,ReadMcpResourceTool,mcp__probe__ping"

Observed output (2.1.274):

Step 1: {"contents":[{"uri":"probe://state","mimeType":"text/plain","text":"ok"}]}
Step 2: Server restarted
Step 3: Connection closed
Step 4: {"contents":[{"uri":"probe://state","mimeType":"text/plain","text":"ok"}]}
Step 5: Server restarted
Step 6: {"result":"pong"}

Matching client log (mcp-logs-probe/*.jsonl). Step 3, the resource read, gets the 404, the transport is closed, the read is rejected, and nothing retries it:

05:06:28.623  SSE GET-stream reconnection exhausted; leaving transport up (POST still works)
05:06:41.066  Connection error: Error POSTing to endpoint: {"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Session not found"}}
05:06:41.066  MCP session expired (server no longer recognizes session ID), triggering reconnection
05:06:41.067  Closing transport (session expired)
05:06:41.069  http transport closed — reconnecting (attempt 1/5)
05:06:41.100  Reconnected (attempt 1)

Step 6, the tool call, takes the identical path and is retried once:

05:06:56.802  Connection error: Error POSTing to endpoint: {"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Session not found"}}
05:06:56.802  Closing transport (session expired)
05:06:56.803  Tool 'ping' failed after 0s: Connection closed
05:06:56.803  MCP session expired during tool call (connection closed), clearing connection cache for re-initialization
05:06:56.805  Retrying tool 'ping' after session recovery
05:06:56.828  Tool 'ping' completed successfully in 6ms

Side observation from the same log: the tool-call retry and the app-level auto-reconnect both initialize at the same instant, and the app-level one then logs Reconnect attempt 1 did not connect (failed); next in 1000ms and connects a second time one second later. Harmless here, but it means a successful recovery still produces two initialize handshakes and a spurious failed-attempt line.

Claude Code Version

2.1.274 (native build), repro above run on it. Same code present in 2.1.272 and 2.1.273. The tool-call retry shows up in logs from 2.1.241 onward; the resource path has never had it.

Platform

Windows 11. Not platform specific.

Related

  • #55970 — the fix that added the tool-call retry (closed 2026-05-23)
  • #59442 — duplicate of #55970, asked for exactly "re-establish the session and retry the failed call once"
  • #62198 — 404 on the optional GET stream misread as session expiry; the fix introduced the "leaving transport up" branch mentioned above
  • #94273 — a 404 during the re-initialize itself leaves the server permanently "not connected" (adjacent, open)

View original on GitHub ↗