|
14 | 14 | import mcp_types as types |
15 | 15 | import pytest |
16 | 16 | 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 |
18 | 18 | from starlette.types import Receive, Scope, Send |
19 | 19 |
|
20 | | -from mcp import MCPError |
21 | 20 | from mcp.client.client import Client |
22 | 21 | from mcp.client.streamable_http import streamable_http_client |
23 | 22 | from mcp.server import Server, ServerRequestContext |
@@ -215,38 +214,42 @@ async def record(request: httpx2.Request) -> None: |
215 | 214 | assert resumption_gets == [] |
216 | 215 |
|
217 | 216 |
|
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. |
221 | 220 |
|
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. |
225 | 223 | """ |
226 | 224 | server = _tooled_server() |
227 | 225 | real_app = server.streamable_http_app(transport_security=NO_DNS_REBINDING_PROTECTION) |
228 | | - initialize_seen = anyio.Event() |
| 226 | + expired_once = False |
229 | 227 |
|
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 |
235 | 230 | 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 |
237 | 237 | await real_app(scope, receive, send) |
238 | 238 |
|
239 | 239 | async with ( |
240 | 240 | 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, |
242 | 245 | ): |
243 | 246 | transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) |
244 | 247 | with anyio.fail_after(5): # pragma: no branch |
245 | 248 | 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() |
248 | 250 |
|
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"] |
250 | 253 |
|
251 | 254 |
|
252 | 255 | def _blocking_server(started: anyio.Event, cancelled: anyio.Event) -> Server: |
|
0 commit comments