Skip to content

Commit a90fa41

Browse files
committed
fix(client): raise typed error for lost HTTP sessions
1 parent 77001c9 commit a90fa41

5 files changed

Lines changed: 45 additions & 7 deletions

File tree

‎src/mcp/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@
6868
from .client.stdio import StdioServerParameters, stdio_client
6969
from .server.session import ServerSession
7070
from .server.stdio import stdio_server
71-
from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError
71+
from .shared.exceptions import (
72+
MCPDeprecationWarning,
73+
MCPError,
74+
SessionNotFoundError,
75+
UrlElicitationRequiredError,
76+
)
7277
from .shared.uri_template import InvalidUriTemplate, UriTemplate
7378

7479
__all__ = [
@@ -105,6 +110,7 @@
105110
"LoggingMessageNotification",
106111
"MCPDeprecationWarning",
107112
"MCPError",
113+
"SessionNotFoundError",
108114
"Notification",
109115
"PingRequest",
110116
"ProgressNotification",

‎src/mcp/client/streamable_http.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
sse_within_origin,
4141
stream_within_origin,
4242
)
43+
from mcp.shared.exceptions import _SESSION_NOT_FOUND_MARKER
4344
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
4445
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
4546
from mcp.shared.message import ClientMessageMetadata, SessionMessage
@@ -406,9 +407,16 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
406407
body = await response.aread()
407408
parsed = jsonrpc_message_adapter.validate_json(body, by_name=False)
408409
if isinstance(parsed, JSONRPCError):
410+
error = parsed.error
411+
if response.status_code == 404 and self.session_id is not None:
412+
error = ErrorData(
413+
code=error.code,
414+
message=error.message,
415+
data={_SESSION_NOT_FOUND_MARKER: True, "data": error.data},
416+
)
409417
# The server may have set `id: null` (request rejected before its
410418
# id was parsed); use this request's id so correlation works.
411-
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
419+
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=error)
412420
await ctx.read_stream_writer.send(SessionMessage(reply))
413421
return
414422
except (httpx2.StreamError, ValidationError):
@@ -421,7 +429,11 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
421429
# this method); "Session terminated" would be a lie here.
422430
error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found")
423431
else:
424-
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
432+
error_data = ErrorData(
433+
code=INVALID_REQUEST,
434+
message="Session terminated",
435+
data={_SESSION_NOT_FOUND_MARKER: True},
436+
)
425437
else:
426438
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
427439
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))

‎src/mcp/shared/exceptions.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
from mcp_types import INVALID_REQUEST, URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError
66

77

8+
# Internal transport marker used to preserve the distinction between a lost
9+
# Streamable HTTP session and an ordinary INVALID_REQUEST response.
10+
_SESSION_NOT_FOUND_MARKER = "__mcp_session_not_found__"
11+
12+
813
class MCPDeprecationWarning(UserWarning):
914
"""A custom deprecation warning for the MCP SDK.
1015
@@ -52,6 +57,10 @@ def __str__(self) -> str:
5257
return self.message
5358

5459

60+
class SessionNotFoundError(MCPError):
61+
"""Raised when a Streamable HTTP server no longer recognizes the session."""
62+
63+
5564
class NoBackChannelError(MCPError):
5665
"""Raised when a server-initiated request has no channel that can deliver it.
5766

‎src/mcp/shared/jsonrpc_dispatcher.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@
5252
coerce_request_id,
5353
run_notify_intercept,
5454
)
55-
from mcp.shared.exceptions import MCPError, NoBackChannelError
55+
from mcp.shared.exceptions import (
56+
MCPError,
57+
NoBackChannelError,
58+
SessionNotFoundError,
59+
_SESSION_NOT_FOUND_MARKER,
60+
)
5661
from mcp.shared.message import (
5762
ClientMessageMetadata,
5863
MessageMetadata,
@@ -458,6 +463,12 @@ async def send_raw_request(
458463
receive.close()
459464

460465
if isinstance(outcome, ErrorData):
466+
if isinstance(outcome.data, dict) and outcome.data.get(_SESSION_NOT_FOUND_MARKER):
467+
raise SessionNotFoundError(
468+
code=outcome.code,
469+
message=outcome.message,
470+
data=outcome.data.get("data"),
471+
)
461472
raise MCPError(code=outcome.code, message=outcome.message, data=outcome.data)
462473
return outcome
463474

‎tests/shared/test_streamable_http.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
from starlette.routing import Mount
4545
from starlette.types import Message, Scope
4646

47-
from mcp import MCPError
47+
from mcp import MCPError, SessionNotFoundError
4848
from mcp.client import ClientRequestContext, IncomingMessage
4949
from mcp.client.session import ClientSession
5050
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
@@ -1140,7 +1140,7 @@ async def test_streamable_http_client_session_termination(basic_app: Starlette)
11401140
):
11411141
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
11421142
# Attempt to make a request after termination
1143-
with pytest.raises(MCPError) as exc_info: # pragma: no branch
1143+
with pytest.raises(SessionNotFoundError) as exc_info: # pragma: no branch
11441144
await session.list_tools()
11451145
assert exc_info.value.error.code == INVALID_REQUEST
11461146
assert exc_info.value.error.message == "Session not found"
@@ -1201,7 +1201,7 @@ async def __aexit__(self, *args: Any) -> None:
12011201
):
12021202
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
12031203
# Attempt to make a request after termination
1204-
with pytest.raises(MCPError) as exc_info: # pragma: no branch
1204+
with pytest.raises(SessionNotFoundError) as exc_info: # pragma: no branch
12051205
await session.list_tools()
12061206
assert exc_info.value.error.code == INVALID_REQUEST
12071207
assert exc_info.value.error.message == "Session not found"

0 commit comments

Comments
 (0)