anthropics / anthropics/claude-code
MCP HTTP: one transient 404 during a tool call permanently disconnects the server for the rest of the session, while /mcp still shows it connected
- Vorherrschende Sprache
- Python
- Sterne
- 145k
- Forks
- 23.1k
- PR-Merge-Kennzahlen
- PR-Kennzahlen ausstehend
Beschreibung
## Summary
If an MCP tool call to a remote Streamable HTTP server gets one HTTP **404**, the client treats it as an expired session and re-initializes once. If that re-initialize also gets a 404, the result is `ENDPOINT_NOT_FOUND`, and the server is lost for the rest of the session:
- Every later call fails immediately with `MCP server "" is not connected`. **The network is never contacted again**, even after the server is back.
- `/mcp` and the connector status still show the server as **connected**, with its full tool list.
- The desktop reconnect action refuses with "only a failed server can be reconnected".
The outage only has to last about 100 ms (two requests). A reverse proxy briefly without a route produces exactly that: Traefik with the Docker provider answers `404 page not found` while a container is being recreated. We hit this 4 times in 3 days in production, and every time the call landed inside a container recreation window. Autonomous sessions stay blocked until a person notices.
**Versions:** Claude Code 2.1.266 (the build bundled with Claude desktop 1.52386.3); the same logic is present in 2.1.270. macOS.
## Reproduction
The server is stateless and sends no `Mcp-Session-Id`. It has one tool, `ping`. From the 2nd `tools/call` onward, for 1.5 s, it answers every POST with a chosen status and a `text/plain` body.
```
python3 server.py 18762 404 server.log # or 503 / 502 / ok
echo '{"mcpServers":{"probe":{"type":"http","url":"http://127.0.0.1:18762/mcp"}}}' > mcp.json
claude -p "Call mcp__probe__ping FOUR times, one at a time, continuing even if a call fails. Then print one line per call with the result or error verbatim." \
--strict-mcp-config --mcp-config mcp.json --allowedTools mcp__probe__ping
```
Results, with the server log as ground truth:
| Status inside the 1.5 s window | Call 2 | Calls 3 and 4 (server already recovered) | Requests reaching the server after the failure |
|---|---|---|---|
| none (control) | `pong 2` | `pong 3`, `pong 4` | yes |
| **404** | error | **`MCP server "probe" is not connected`** | **none** |
| 503 | `Service Unavailable` | `pong 3`, `pong 4` | yes |
| 502 | `Bad Gateway` | `pong 3`, `pong 4` | yes |
Client log for the 404 case:
```
Calling MCP tool: ping
HTTP connection dropped after 8s uptime
Tool 'ping' failed after 0s: Error POSTing to endpoint: 404 page not found
MCP session expired during tool call (stale session), clearing connection cache for re-initialization
Retrying tool 'ping' after session recovery
Initializing HTTP transport to http://127.0.0.1:18762/mcp
initialize POST rejected (Error POSTing to endpoint: 404 page not found); trying legacy HTTP+SSE
legacy HTTP+SSE not confirmed (SSE error: Non-200 status code (405)); surfacing the POST failure
Connection failed after 32ms (ENDPOINT_NOT_FOUND): MCP endpoint not found at http://127.0.0.1:18762
```
server.py (stdlib only)
```python
#!/usr/bin/env python3
# Fake stateless Streamable HTTP MCP server (no Mcp-Session-Id).
# usage: server.py ; mode = ok | 404 | 503 | 502
# From the 2nd tools/call on, every POST during WINDOW seconds gets with a text/plain body.
import json, sys, time, threading
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
PORT, MODE, LOG = int(sys.argv[1]), sys.argv[2], sys.argv[3]
WINDOW = 1.5
state = {"calls": 0, "fail_until": 0.0}
lock = threading.Lock()
TEXT = {"404": "404 page not found\n", "503": "Service Unavailable\n", "502": "Bad Gateway\n"}
def log(line):
with open(LOG, "a") as f:
f.write(f"{time.strftime('%H:%M:%S')}.{int(time.time()*1000)%1000:03d} {line}\n")
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def reply(self, status, body, ctype="application/json"):
data = body.encode()
self.send_response(status); self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
def do_GET(self):
log(f"GET {self.path} -> 405")
self.reply(405, json.dumps({"jsonrpc": "2.0", "error": {"code": -32000, "message": "Method not allowed."}, "id": None}))
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
try: msg = json.loads(raw)
except Exception: msg = {}
method = msg.get("method", "?") if isinstance(msg, dict) else "batch"
with lock:
if method == "tools/call":
state["calls"] += 1
if MODE != "ok" and state["calls"] == 2:
state["fail_until"] = time.time() + WINDOW
failing = time.time() < state["fail_until"]
if failing:
log(f"POST {method} -> {MODE} (window)")
return self.reply(int(MODE), TEXT[MODE], "text/plain; charset=utf-8")
mid = msg.get("id") if isinstance(msg, dict) else None
if method == "initialize":
res = {"protocolVersion": msg.get("params", {}).get("protocolVersion", "2025-06-18"),
"capabilities": {"tools": {}}, "serverInfo": {"name": "probe", "version": "0.0.1"}}
elif method.startswith("notifications/"):
log(f"POST {method} -> 202"); return self.reply(202, "")
elif method == "tools/list":
res = {"tools": [{"name": "ping", "description": "Returns pong and the call number.",
"inputSchema": {"type": "object", "properties": {}}}]}
elif method == "tools/call":
res = {"content": [{"type": "text", "text": f"pong {state['calls']}"}]}
else:
res = {}
log(f"POST {method} -> 200")
self.reply(200, json.dumps({"jsonrpc": "2.0", "id": mid, "result": res}))
log(f"=== mode={MODE} port={PORT} window={WINDOW}s")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
```
## Where it comes from (reading the bundled 2.1.266 client)
1. `isMcpSessionExpiredError` classifies **any** HTTP 404 on a tool call as an expired session (`if(n===404)return!…`). It does this even when the transport never received a session id, so there was no session to expire.
2. The recovery path (`MCP session expired during tool call … clearing connection cache`) re-initializes exactly once, inside the same outage. A 404 on that `initialize` from an `http` server with `sessionId === undefined` is then classified as `ENDPOINT_NOT_FOUND`.
3. The `connectToServer` result is memoized without a TTL. Nothing on this path evicts the `failed` entry: `clearServerCache` runs only on explicit reconnect or toggle, and `onclose` is detached before cleanup. So `ensureConnectedClient` keeps throwing `is not connected` without dialing.
4. `mcp.clients` in app state, which `/mcp`, `mcp_status` and the desktop read, is never updated on this path and stays `type: "connected"`. The desktop reconnect gate checks that stale status, so it refuses.
502, 503 and 504 do not go through `isMcpSessionExpiredError`. The call fails, the connection stays usable, and the next call succeeds, as shown in the table. Only the HTTP transport is affected; stdio servers respawn on `onclose`.
## Expected
- **No session id, no expired session.** When the transport has no session id, a 404 should be reported as a failed request, not as session expiry.
- **Mid-session connection failures should not be permanent.** For a server that connected successfully earlier in the session, retry with backoff (as is already done for transient errors on initial connect), or let the next tool call re-dial instead of replaying a memoized failure.
- **One source of truth for status.** When the connection memo holds `failed`, `mcp.clients` should say `failed` too, so `/mcp` shows the truth and the reconnect action works.
## Workaround for server operators
Never let the endpoint answer 404 while the backend is only temporarily absent. Put a low-priority fallback route in front that answers 503, or deploy with no window.
Beitragsleitfaden
Für dieses Repository ist kein Beitragsleitfaden indexiert
Bewertung
Dieses Issue wurde noch nicht bewertet.