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

Đang mở
#94,273 0 bình luận 0 reaction 0 người được giao Xem trên GitHub
area:mcp bug has repro platform:macos
Ngôn ngữ chính
Python
Star
145k
Fork
23.1k
Chỉ số merge pull request
Chỉ số pull request đang chờ

Mô tả

## 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.

Hướng dẫn đóng góp

Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.