anthropics / anthropics/claude-code
[BUG] MCP session-expiry recovery retries `tools/call` only; `readMcpResource` still fails once with `Connection closed` after a Streamable HTTP server restart
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
### 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-/*.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:
```python
# /// 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")
```
```json
// 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):
```powershell
$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: \"." \
--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)
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by reproducing the restart sequence with server.py, mcp.json, and the mcp-logs output. Read the existing tool-call retry wrapper alongside ReadMcpResourceTool.call() and ReadMcpResourceDirTool; compare how readResource and the Remote Control mcp_call path handle session expiry. Done means client-facing MCP requests recover once after expiry and report a useful failure when recovery also fails.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, python
- Domain
- api, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 54/100