Repository navigation
Description
Description
When a request that carried an Mcp-Session-Id gets HTTP 404, the server has lost the session, and
the spec says the client must start a new one. The Streamable HTTP client raises a plain MCPError
for this, and its code and message come from whatever the response body says:
| 404 response body | What the client raises |
|---|---|
| Empty | MCPError(-32600, "Session terminated") (the client's own fallback) |
Python SDK server (StreamableHTTPSessionManager) |
MCPError(-32600, "Session not found") |
| TypeScript SDK server | MCPError(-32001, "Session not found") |
So a caller can only spot a lost session by matching message text. -32600 is also what an
ordinary invalid request returns, and -32001 is implementation-defined.
This matters to callers that handle a lost session differently from other errors. For example, a
durable-execution wrapper should retry a tool call on a new connection when the session is gone,
but must not retry an invalid request. Temporal's MCP integration treats -32600 as non-retryable
(_activities.py), so a call that hits a server restart fails at once instead of being
retried on a new session. Matching on "Session terminated" would still miss the Python and
TypeScript SDK servers, which send their own body.
Proposal: whenever a request that carried a session ID gets HTTP 404, raise a dedicated
MCPError subclass, such as SessionNotFoundError, whatever the body says. Keep the code and
message from the body (or today's fallback), so existing except MCPError handlers and code checks
keep working. UrlElicitationRequiredError is a precedent for a typed MCPError subclass.
try:
result = await session.call_tool("lookup", {})
except SessionNotFoundError:
... # the server lost the session: reconnect and retry
except MCPError:
... # other protocol errorsRelated issues, which this one doesn't replace:
- MCP client doesn't not initialize new session when getting 404 session not found #1676 asks the client to re-initialize on this 404, as the spec requires. That needs the same
"session lost" signal internally. Callers would still need it when re-initializing fails (a
second 404), and when the lost session held state they must know is gone. - Clarify JSON-RPC error code for 'session not found' responses #1821 asks which JSON-RPC code servers should send for "session not found". A client-side type
doesn't depend on that decision, and it also covers servers that send an empty body.
Reproduction: one file, needing only mcp and uvicorn, with no real MCP server. A small ASGI
app opens a session on initialize, then answers tools/call by tool name: a 404 with each of the
three bodies above, an ordinary -32600 error, or ok. It exits 0 when nothing distinguishes a
lost session.
repro.py
"""A lost Streamable HTTP session reaches the client as an ordinary MCPError.
Requires: mcp (tested with 2.3.0) and uvicorn. Run: python repro.py
A small ASGI server stands in for an MCP server with sessions. It answers
`initialize` with an `Mcp-Session-Id`, and then each `tools/call` by tool name:
- gone-empty: HTTP 404, empty body (the server lost the session)
- gone-python-sdk: HTTP 404 with the Python SDK server's body (-32600 "Session not found")
- gone-ts-sdk: HTTP 404 with the TypeScript SDK server's body (-32001 "Session not found")
- invalid: HTTP 200 with a JSON-RPC -32600 error (an ordinary invalid request)
- ok: a normal result
It prints what the client raises for each. Exits 0 when nothing distinguishes a
lost session: every 404 case raises plain MCPError, and the empty-body 404 has
the same type and code as the ordinary invalid request.
"""
import asyncio
import json
import socket
import uvicorn
from mcp import ClientSession, MCPError
from mcp.client.streamable_http import streamable_http_client
SESSION_LOST_BODIES = {
"gone-empty": None,
"gone-python-sdk": {"jsonrpc": "2.0", "id": "server-error", "error": {"code": -32600, "message": "Session not found"}},
"gone-ts-sdk": {"jsonrpc": "2.0", "id": None, "error": {"code": -32001, "message": "Session not found"}},
}
async def app(scope, receive, send):
async def respond(status, body=None, headers=()):
payload = b"" if body is None else json.dumps(body).encode()
hdrs = [(b"content-type", b"application/json"), *headers] if body is not None else list(headers)
await send({"type": "http.response.start", "status": status, "headers": hdrs})
await send({"type": "http.response.body", "body": payload})
if scope["type"] != "http":
return
if scope["method"] != "POST":
return await respond(405 if scope["method"] == "GET" else 200)
body, more = b"", True
while more:
message = await receive()
body += message.get("body", b"")
more = message.get("more_body", False)
rpc = json.loads(body)
method, rid = rpc.get("method"), rpc.get("id")
if method == "initialize":
result = {
"protocolVersion": rpc["params"]["protocolVersion"],
"capabilities": {"tools": {}},
"serverInfo": {"name": "fake", "version": "0"},
}
return await respond(200, {"jsonrpc": "2.0", "id": rid, "result": result}, [(b"mcp-session-id", b"session-1")])
if rid is None: # notifications
return await respond(202)
if method == "tools/list": # the client lists tools to check a call's result
tools = [{"name": "ok", "inputSchema": {"type": "object"}}]
return await respond(200, {"jsonrpc": "2.0", "id": rid, "result": {"tools": tools}})
if method == "tools/call":
name = rpc["params"]["name"]
if name in SESSION_LOST_BODIES:
return await respond(404, SESSION_LOST_BODIES[name])
if name == "invalid":
return await respond(200, {"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "Invalid request"}})
result = {"content": [{"type": "text", "text": "ok"}], "isError": False}
return await respond(200, {"jsonrpc": "2.0", "id": rid, "result": result})
return await respond(200, {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "Method not found"}})
async def call(url: str, tool: str):
async with streamable_http_client(url) as (read, write), ClientSession(read, write) as session:
await session.initialize()
try:
result = await session.call_tool(tool, {})
return ("result", result.content[0].text)
except MCPError as err:
return (type(err).__name__, err.code, err.error.message)
async def main():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
task = asyncio.create_task(server.serve())
while not server.started:
await asyncio.sleep(0.05)
url = f"http://127.0.0.1:{port}/mcp"
seen = {}
for tool in ["ok", "invalid", *SESSION_LOST_BODIES]:
seen[tool] = await call(url, tool)
print(f"{tool:16} -> {seen[tool]}")
server.should_exit = True
await task
assert seen["ok"] == ("result", "ok")
assert all(seen[t][0] == "MCPError" for t in SESSION_LOST_BODIES), "a lost session got its own type"
assert seen["gone-empty"][:2] == seen["invalid"][:2], "the empty-body 404 differs from an invalid request"
if __name__ == "__main__":
asyncio.run(main())With mcp 2.3.0 (the latest release):
python 3.13.16 mcp==2.3.0 uvicorn==0.54.0
$ python repro.py
ok -> ('result', 'ok')
invalid -> ('MCPError', -32600, 'Invalid request')
gone-empty -> ('MCPError', -32600, 'Session terminated')
gone-python-sdk -> ('MCPError', -32600, 'Session not found')
gone-ts-sdk -> ('MCPError', -32001, 'Session not found')
exit=0
The same on main at d7845c0:
python 3.13.16 mcp==2.3.1.dev17+d7845c07 uvicorn==0.54.0
ok -> ('result', 'ok')
invalid -> ('MCPError', -32600, 'Invalid request')
gone-empty -> ('MCPError', -32600, 'Session terminated')
gone-python-sdk -> ('MCPError', -32600, 'Session not found')
gone-ts-sdk -> ('MCPError', -32001, 'Session not found')
exit=0
References
- Streamable HTTP transport, session management:
"When a client receives HTTP 404 in response to a request containing anMCP-Session-Id, it
MUST start a new session by sending a newInitializeRequestwithout a session ID attached." src/mcp/client/streamable_http.pyonmain: the 404 fallback toINVALID_REQUEST
"Session terminated", and the use of a JSON-RPC error body when the response has one.- MCP client doesn't not initialize new session when getting 404 session not found #1676, Clarify JSON-RPC error code for 'session not found' responses #1821.
Activity
atmaxmoj commentedon Nov 2, 2026
I traced where the "session lost" information goes on
main(d7845c0), since that decides where aSessionNotFoundErrorcould be raised.streamable_http.py#L417-L424. It does not raise there. It writes aJSONRPCErrorinto the read stream, either the server's body (L411-L412) or the"Session terminated"fallback (L427-L428).JSONRPCError. It calls_resolve_pending(msg.id, msg.error)(jsonrpc_dispatcher.py#L573-L575), and the waiting request raises a plainMCPErrorfrom theErrorData(L460-L461). The message metadata is not passed along on this path. By the time an exception exists, "404 on a request that had a session" is gone. Only code, message and data remain, and those depend on the server, as the table in the issue shows.UrlElicitationRequiredErroris not quite the same precedent. It has its own wire code (URL_ELICITATION_REQUIRED), and the client does not raise it automatically either: a caller has to callUrlElicitationRequiredError.from_error(...). A lost session has no wire code (Clarify JSON-RPC error code for 'session not found' responses #1821), so the dispatcher cannot derive the type fromErrorData.So this needs a request-correlated signal that crosses the transport → dispatcher boundary. #1676 needs the same signal: this comment proposes "an SDK-internal, request-correlated session-expired signal" to re-initialize and retry once. Designing the two together seems worthwhile. #1676 would use the internal signal to re-initialize, and
SessionNotFoundErrorwould surface it when that is not possible, for example when the retry also gets a 404, as the issue already notes.AI disclosure: this analysis was produced with Claude Code (tracing the code paths and drafting this comment). The line references are against d7845c0.
media-ctrl commentedon Nov 4, 2026
The typed subclass is the right proposal, and the patch is smaller than it looks because the client already distinguishes 404 variants. In
src/mcp/client/streamable_http.pythe 404 branch is:So "no session yet" already gets its own code (
METHOD_NOT_FOUND, L418) while "had a session, lost it" falls through toINVALID_REQUEST(L424). ASessionNotFoundErroronly has to be raised in theelsearm; nothing in the body-derived path needs to change.One constraint on where you raise it. The parsed-JSON-RPC-error path runs first: if the 404 carries a parseable body, the client surfaces that error verbatim, which is why your table shows
-32001from the TS server and-32600from the Python server. So the new exception has to be keyed on the HTTP status plus a non-Nonesession_id, not on the parsed code, or the TS bodies slip through as plainMCPError.A gap the proposal does not cover, which matters for the durable-execution use case you are making the argument with: the entire
>= 400block is guarded byisinstance(message, JSONRPCRequest)(L392), and returns without touching the read stream otherwise. A 404 on a notification POST, or on the SSE GET, therefore produces no signal at all, and a wrapper stays unaware that its session is gone until its next request also fails. Since a lost session invalidates everything in flight, closing the read stream on that 404 would fail pending and subsequent calls together, which is the consistent behaviour a wrapper wants and is closer to what "session terminated" means.One line worth putting in the docstring for
SessionNotFoundError: a lost session does not imply the call did not execute. The server can complete a tool call and then fail to return the response, so retrying on this exception can double-execute unless the tool is idempotent. That distinction is the difference between a wrapper that recovers and one that double-charges a card.