Skip to content

Raise a distinct error when a Streamable HTTP session is lost (HTTP 404) #3799

Open

Description

@campoy

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 errors

Related issues, which this one doesn't replace:

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

Activity

  1. added
    enhancementRequest for a new feature that's not currently supported
    on Nov 2, 2026
  2. atmaxmoj commented on Nov 2, 2026

    @atmaxmoj

    I traced where the "session lost" information goes on main (d7845c0), since that decides where a SessionNotFoundError could be raised.

    • The transport knows it. A 404 on a request sent with a session ID is detected in streamable_http.py#L417-L424. It does not raise there. It writes a JSONRPCError into the read stream, either the server's body (L411-L412) or the "Session terminated" fallback (L427-L428).
    • The dispatcher only sees that JSONRPCError. It calls _resolve_pending(msg.id, msg.error) (jsonrpc_dispatcher.py#L573-L575), and the waiting request raises a plain MCPError from the ErrorData (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.
    • UrlElicitationRequiredError is 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 call UrlElicitationRequiredError.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 from ErrorData.

    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 SessionNotFoundError would 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.

  3. media-ctrl commented on Nov 4, 2026

    @media-ctrl

    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.py the 404 branch is:

    if response.status_code == 404:
        if self.session_id is None:
            error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found")
        else:
            error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")

    So "no session yet" already gets its own code (METHOD_NOT_FOUND, L418) while "had a session, lost it" falls through to INVALID_REQUEST (L424). A SessionNotFoundError only has to be raised in the else arm; 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 -32001 from the TS server and -32600 from the Python server. So the new exception has to be keyed on the HTTP status plus a non-None session_id, not on the parsed code, or the TS bodies slip through as plain MCPError.

    A gap the proposal does not cover, which matters for the durable-execution use case you are making the argument with: the entire >= 400 block is guarded by isinstance(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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequest for a new feature that's not currently supported

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions