Skip to content

Commit 12323d5

Browse files
committed
fix(client): reinitialize expired streamable HTTP sessions
1 parent 0dbac72 commit 12323d5

1 file changed

Lines changed: 22 additions & 19 deletions

File tree

tests/interaction/transports/test_client_transport_http.py

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@
1414
import mcp_types as types
1515
import pytest
1616
from inline_snapshot import snapshot
17-
from mcp_types import INVALID_REQUEST, CallToolResult, ErrorData, ListToolsResult, TextContent, Tool
17+
from mcp_types import CallToolResult, ListToolsResult, TextContent, Tool
1818
from starlette.types import Receive, Scope, Send
1919

20-
from mcp import MCPError
2120
from mcp.client.client import Client
2221
from mcp.client.streamable_http import streamable_http_client
2322
from mcp.server import Server, ServerRequestContext
@@ -215,38 +214,42 @@ async def record(request: httpx2.Request) -> None:
215214
assert resumption_gets == []
216215

217216

218-
@requirement("client-transport:http:404-surfaces")
219-
async def test_a_404_mid_session_surfaces_as_a_session_terminated_error() -> None:
220-
"""A 404 in response to a request after initialization is reported to the caller as an MCP error.
217+
@requirement("client-transport:http:session-404-reinitialize")
218+
async def test_a_404_mid_session_reinitializes_before_retrying_the_request() -> None:
219+
"""Spec-mandated: a request carrying an expired session id initializes a fresh session and retries once.
221220
222-
The spec says the client MUST start a new session in this situation; the SDK instead surfaces a
223-
`Session terminated` error to the caller. The spec's MUST is tracked at
224-
client-transport:http:session-404-reinitialize; this test pins the SDK's current behaviour.
221+
The injected 404 applies only to the first established ``tools/list`` request. Recovery initialization
222+
reaches the real server without an MCP session header, then the retry succeeds through the new session.
225223
"""
226224
server = _tooled_server()
227225
real_app = server.streamable_http_app(transport_security=NO_DNS_REBINDING_PROTECTION)
228-
initialize_seen = anyio.Event()
226+
expired_once = False
229227

230-
async def first_post_then_404(scope: Scope, receive: Receive, send: Send) -> None:
231-
if scope["type"] == "http" and scope["method"] == "POST" and initialize_seen.is_set():
232-
await send({"type": "http.response.start", "status": 404, "headers": []})
233-
await send({"type": "http.response.body", "body": b""})
234-
return
228+
async def expire_first_established_tools_list(scope: Scope, receive: Receive, send: Send) -> None:
229+
nonlocal expired_once
235230
if scope["type"] == "http" and scope["method"] == "POST":
236-
initialize_seen.set()
231+
headers = dict(scope["headers"])
232+
if headers.get(b"mcp-session-id") is not None and not expired_once:
233+
expired_once = True
234+
await send({"type": "http.response.start", "status": 404, "headers": []})
235+
await send({"type": "http.response.body", "body": b""})
236+
return
237237
await real_app(scope, receive, send)
238238

239239
async with (
240240
server.session_manager.run(),
241-
httpx2.AsyncClient(transport=StreamingASGITransport(first_post_then_404), base_url=BASE_URL) as http_client,
241+
httpx2.AsyncClient(
242+
transport=StreamingASGITransport(expire_first_established_tools_list),
243+
base_url=BASE_URL,
244+
) as http_client,
242245
):
243246
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
244247
with anyio.fail_after(5): # pragma: no branch
245248
async with Client(transport, mode="legacy") as client: # pragma: no branch
246-
with pytest.raises(MCPError) as exc_info: # pragma: no branch
247-
await client.list_tools()
249+
result = await client.list_tools()
248250

249-
assert exc_info.value.error == snapshot(ErrorData(code=INVALID_REQUEST, message="Session terminated"))
251+
assert expired_once is True
252+
assert [tool.name for tool in result.tools] == ["echo"]
250253

251254

252255
def _blocking_server(started: anyio.Event, cancelled: anyio.Event) -> Server:

0 commit comments

Comments
 (0)