|
34 | 34 | from mcp.client.streamable_http import ( |
35 | 35 | LAST_EVENT_ID, |
36 | 36 | MAX_RECONNECTION_ATTEMPTS, |
| 37 | + MCP_SESSION_ID, |
37 | 38 | RequestContext, |
38 | 39 | StreamableHTTPTransport, |
39 | 40 | streamable_http_client, |
@@ -135,11 +136,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: |
135 | 136 |
|
136 | 137 |
|
137 | 138 | @pytest.mark.anyio |
138 | | -@pytest.mark.parametrize("status", [401, 403, 500]) |
| 139 | +@pytest.mark.parametrize("status", [302, 401, 403, 500]) |
139 | 140 | async def test_resumption_get_http_error_resolves_caller_and_transport_survives(status: int) -> None: |
140 | 141 | """A non-2xx on the resumption GET resolves the waiting request with a JSON-RPC error |
141 | 142 | correlated to its id, and the transport stays usable for follow-up requests (SDK-defined; |
142 | 143 | #2110 — the status error used to escape into the task group and tear down every stream). |
| 144 | + An unfollowed redirect counts: its body is no event stream, so no response can arrive. |
143 | 145 | """ |
144 | 146 |
|
145 | 147 | def handler(request: httpx2.Request) -> httpx2.Response: |
@@ -174,6 +176,79 @@ def handler(request: httpx2.Request) -> httpx2.Response: |
174 | 176 | assert follow_up.message.id == 2 |
175 | 177 |
|
176 | 178 |
|
| 179 | +@pytest.mark.anyio |
| 180 | +async def test_resumption_get_404_with_session_reports_session_terminated() -> None: |
| 181 | + """A 404 on the resumption GET while a session id is held reports "Session terminated" |
| 182 | + (INVALID_REQUEST) to the waiter, the same session-expiry mapping as the POST path, so |
| 183 | + reconnect logic keyed on that error works across both (SDK-defined).""" |
| 184 | + |
| 185 | + def handler(request: httpx2.Request) -> httpx2.Response: |
| 186 | + if request.method == "GET" and LAST_EVENT_ID in request.headers: |
| 187 | + return httpx2.Response(404) |
| 188 | + if request.method == "DELETE": # session termination on close |
| 189 | + return httpx2.Response(200) |
| 190 | + body = json.loads(request.content) |
| 191 | + return httpx2.Response( |
| 192 | + 200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}, headers={MCP_SESSION_ID: "sess-1"} |
| 193 | + ) |
| 194 | + |
| 195 | + with anyio.fail_after(5): |
| 196 | + async with ( |
| 197 | + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, |
| 198 | + streamable_http_client("http://test/mcp", http_client=http) as (read, write), |
| 199 | + ): |
| 200 | + # An initialize round-trip stores the session id the server stamps on its response. |
| 201 | + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="initialize", params={}))) |
| 202 | + assert isinstance(await read.receive(), SessionMessage) |
| 203 | + |
| 204 | + await write.send( |
| 205 | + SessionMessage( |
| 206 | + message=JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/call", params={}), |
| 207 | + metadata=ClientMessageMetadata(resumption_token="token-1"), |
| 208 | + ) |
| 209 | + ) |
| 210 | + reply = await read.receive() |
| 211 | + assert isinstance(reply, SessionMessage) |
| 212 | + assert isinstance(reply.message, JSONRPCError) |
| 213 | + assert reply.message.id == 2 |
| 214 | + assert reply.message.error.code == INVALID_REQUEST |
| 215 | + assert reply.message.error.message == snapshot("Session terminated") |
| 216 | + |
| 217 | + |
| 218 | +@pytest.mark.anyio |
| 219 | +async def test_notification_with_resumption_token_is_posted_not_resumed() -> None: |
| 220 | + """A notification stamped with a resumption token is POSTed like any notification, and the |
| 221 | + write loop survives to serve the next request (SDK-defined: the token names an interrupted |
| 222 | + request's stream, so resumption applies to requests only).""" |
| 223 | + recorded: list[httpx2.Request] = [] |
| 224 | + |
| 225 | + def handler(request: httpx2.Request) -> httpx2.Response: |
| 226 | + recorded.append(request) |
| 227 | + body = json.loads(request.content) |
| 228 | + if "id" not in body: |
| 229 | + return httpx2.Response(202) |
| 230 | + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) |
| 231 | + |
| 232 | + with anyio.fail_after(5): |
| 233 | + async with ( |
| 234 | + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, |
| 235 | + streamable_http_client("http://test/mcp", http_client=http) as (read, write), |
| 236 | + ): |
| 237 | + await write.send( |
| 238 | + SessionMessage( |
| 239 | + message=JSONRPCNotification(jsonrpc="2.0", method="notifications/foo", params={}), |
| 240 | + metadata=ClientMessageMetadata(resumption_token="token-1"), |
| 241 | + ) |
| 242 | + ) |
| 243 | + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}))) |
| 244 | + reply = await read.receive() |
| 245 | + assert isinstance(reply, SessionMessage) |
| 246 | + assert isinstance(reply.message, JSONRPCResponse) |
| 247 | + assert reply.message.id == 1 |
| 248 | + # The stamped notification went out as a plain POST, not a resumption GET. |
| 249 | + assert [r.method for r in recorded] == ["POST", "POST"] |
| 250 | + |
| 251 | + |
177 | 252 | @pytest.mark.anyio |
178 | 253 | async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None: |
179 | 254 | """``initialize`` discards the cached protocol-version header; every other POST reads it. |
@@ -674,6 +749,74 @@ def handler(request: httpx2.Request) -> httpx2.Response: |
674 | 749 | assert reply.message.error.code == CONNECTION_CLOSED |
675 | 750 |
|
676 | 751 |
|
| 752 | +@pytest.mark.anyio |
| 753 | +async def test_resumption_stream_dying_mid_read_resolves_caller_and_transport_survives() -> None: |
| 754 | + """A resumption GET stream that dies mid-read resolves the waiter with CONNECTION_CLOSED |
| 755 | + and the transport stays usable for follow-up requests (SDK-defined; #2110 — the read error |
| 756 | + used to escape into the task group and tear down every stream).""" |
| 757 | + dying = _DyingSSEStream() |
| 758 | + |
| 759 | + def handler(request: httpx2.Request) -> httpx2.Response: |
| 760 | + if request.method == "GET" and LAST_EVENT_ID in request.headers: |
| 761 | + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=dying) |
| 762 | + body = json.loads(request.content) |
| 763 | + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) |
| 764 | + |
| 765 | + with anyio.fail_after(5): |
| 766 | + async with ( |
| 767 | + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, |
| 768 | + streamable_http_client("http://test/mcp", http_client=http) as (read, write), |
| 769 | + ): |
| 770 | + await write.send( |
| 771 | + SessionMessage( |
| 772 | + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}), |
| 773 | + metadata=ClientMessageMetadata(resumption_token="token-1"), |
| 774 | + ) |
| 775 | + ) |
| 776 | + reply = await read.receive() |
| 777 | + assert isinstance(reply, SessionMessage) |
| 778 | + assert isinstance(reply.message, JSONRPCError) |
| 779 | + assert reply.message.id == 1 |
| 780 | + assert reply.message.error.code == CONNECTION_CLOSED |
| 781 | + assert reply.message.error.message == snapshot("resumption stream ended without a response") |
| 782 | + |
| 783 | + # The transport survived: a plain follow-up request still round-trips. |
| 784 | + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={}))) |
| 785 | + follow_up = await read.receive() |
| 786 | + assert isinstance(follow_up, SessionMessage) |
| 787 | + assert isinstance(follow_up.message, JSONRPCResponse) |
| 788 | + assert follow_up.message.id == 2 |
| 789 | + |
| 790 | + |
| 791 | +@pytest.mark.anyio |
| 792 | +async def test_resumption_stream_clean_end_without_response_resolves_caller() -> None: |
| 793 | + """A resumption GET stream that closes cleanly without delivering a response (e.g. the |
| 794 | + server no longer holds the resumed request's events) resolves the waiter with an error |
| 795 | + instead of hanging it forever (SDK-defined; #2110).""" |
| 796 | + |
| 797 | + def handler(request: httpx2.Request) -> httpx2.Response: |
| 798 | + assert request.method == "GET" and LAST_EVENT_ID in request.headers |
| 799 | + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, content=b": nothing to replay\n\n") |
| 800 | + |
| 801 | + with anyio.fail_after(5): |
| 802 | + async with ( |
| 803 | + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, |
| 804 | + streamable_http_client("http://test/mcp", http_client=http) as (read, write), |
| 805 | + ): |
| 806 | + await write.send( |
| 807 | + SessionMessage( |
| 808 | + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}), |
| 809 | + metadata=ClientMessageMetadata(resumption_token="token-1"), |
| 810 | + ) |
| 811 | + ) |
| 812 | + reply = await read.receive() |
| 813 | + assert isinstance(reply, SessionMessage) |
| 814 | + assert isinstance(reply.message, JSONRPCError) |
| 815 | + assert reply.message.id == 1 |
| 816 | + assert reply.message.error.code == CONNECTION_CLOSED |
| 817 | + assert reply.message.error.message == snapshot("resumption stream ended without a response") |
| 818 | + |
| 819 | + |
677 | 820 | class _DeliverOnCommandSSEStream(httpx2.AsyncByteStream): |
678 | 821 | """Parks after opening, then delivers one JSON-RPC response when told.""" |
679 | 822 |
|
|
0 commit comments