|
8 | 8 |
|
9 | 9 | import base64 |
10 | 10 | import json |
| 11 | +import logging |
11 | 12 | from collections.abc import AsyncIterator, Callable, Mapping |
12 | 13 | from typing import Any |
13 | 14 |
|
|
19 | 20 | CLIENT_CAPABILITIES_META_KEY, |
20 | 21 | CLIENT_INFO_META_KEY, |
21 | 22 | CONNECTION_CLOSED, |
| 23 | + INTERNAL_ERROR, |
22 | 24 | INVALID_REQUEST, |
23 | 25 | METHOD_NOT_FOUND, |
24 | 26 | PROTOCOL_VERSION_META_KEY, |
|
31 | 33 | from starlette.types import Receive, Scope, Send |
32 | 34 |
|
33 | 35 | from mcp.client.streamable_http import ( |
| 36 | + HTTP_STATUS_KEY, |
34 | 37 | MAX_RECONNECTION_ATTEMPTS, |
35 | 38 | RequestContext, |
36 | 39 | StreamableHTTPTransport, |
@@ -130,6 +133,87 @@ def handler(request: httpx.Request) -> httpx.Response: |
130 | 133 | assert isinstance(reply, SessionMessage) |
131 | 134 | assert isinstance(reply.message, JSONRPCError) |
132 | 135 | assert reply.message.error.code == METHOD_NOT_FOUND |
| 136 | + assert reply.message.error.data == {HTTP_STATUS_KEY: 404} |
| 137 | + |
| 138 | + |
| 139 | +@pytest.mark.anyio |
| 140 | +@pytest.mark.parametrize("status", [401, 403, 500, 503]) |
| 141 | +async def test_a_non_2xx_status_reaches_the_caller_in_the_errors_data(status: int) -> None: |
| 142 | + """The originating HTTP status rides along in `ErrorData.data`. |
| 143 | +
|
| 144 | + The JSON-RPC code is INTERNAL_ERROR for every one of these, so it alone cannot tell a |
| 145 | + caller whether to retry. The status can: 401/403 are terminal, 5xx are worth retrying. |
| 146 | + """ |
| 147 | + |
| 148 | + def handler(request: httpx.Request) -> httpx.Response: |
| 149 | + return httpx.Response(status) |
| 150 | + |
| 151 | + with anyio.fail_after(5): |
| 152 | + async with ( |
| 153 | + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, |
| 154 | + streamable_http_client("http://test/mcp", http_client=http) as (read, write), |
| 155 | + ): |
| 156 | + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}))) |
| 157 | + reply = await read.receive() |
| 158 | + assert isinstance(reply, SessionMessage) |
| 159 | + assert isinstance(reply.message, JSONRPCError) |
| 160 | + assert reply.message.error.code == INTERNAL_ERROR |
| 161 | + assert reply.message.error.data == {HTTP_STATUS_KEY: status} |
| 162 | + |
| 163 | + |
| 164 | +@pytest.mark.anyio |
| 165 | +async def test_a_servers_own_jsonrpc_error_body_survives_the_non_2xx_status() -> None: |
| 166 | + """A JSON-RPC error in the body wins: the server said what went wrong, so don't overwrite its `data`.""" |
| 167 | + |
| 168 | + def handler(request: httpx.Request) -> httpx.Response: |
| 169 | + body = json.loads(request.content) |
| 170 | + error = {"code": INVALID_REQUEST, "message": "no", "data": {"detail": "from the server"}} |
| 171 | + return httpx.Response(400, json={"jsonrpc": "2.0", "id": body["id"], "error": error}) |
| 172 | + |
| 173 | + with anyio.fail_after(5): |
| 174 | + async with ( |
| 175 | + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, |
| 176 | + streamable_http_client("http://test/mcp", http_client=http) as (read, write), |
| 177 | + ): |
| 178 | + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}))) |
| 179 | + reply = await read.receive() |
| 180 | + assert isinstance(reply, SessionMessage) |
| 181 | + assert isinstance(reply.message, JSONRPCError) |
| 182 | + assert reply.message.error.data == {"detail": "from the server"} |
| 183 | + |
| 184 | + |
| 185 | +@pytest.mark.anyio |
| 186 | +async def test_a_non_2xx_to_a_notification_is_logged() -> None: |
| 187 | + """A notification has no waiter to resolve, so a rejected write can only be logged — but it must be. |
| 188 | +
|
| 189 | + Waiting on the log record itself (not on the POST) is what makes this deterministic: the |
| 190 | + warning is emitted after the response returns, so the handler is too early a signal. |
| 191 | + """ |
| 192 | + logged = anyio.Event() |
| 193 | + records: list[logging.LogRecord] = [] |
| 194 | + |
| 195 | + class _Capture(logging.Handler): |
| 196 | + def emit(self, record: logging.LogRecord) -> None: |
| 197 | + records.append(record) |
| 198 | + logged.set() |
| 199 | + |
| 200 | + def handler(request: httpx.Request) -> httpx.Response: |
| 201 | + return httpx.Response(500) |
| 202 | + |
| 203 | + transport_logger = logging.getLogger("mcp.client.streamable_http") |
| 204 | + capture = _Capture(level=logging.WARNING) |
| 205 | + transport_logger.addHandler(capture) |
| 206 | + try: |
| 207 | + with anyio.fail_after(5): |
| 208 | + async with ( |
| 209 | + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, |
| 210 | + streamable_http_client("http://test/mcp", http_client=http) as (_, write), |
| 211 | + ): |
| 212 | + await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress"))) |
| 213 | + await logged.wait() |
| 214 | + finally: |
| 215 | + transport_logger.removeHandler(capture) |
| 216 | + assert [r.getMessage() for r in records] == ["Server returned HTTP 500 to a JSONRPCNotification"] |
133 | 217 |
|
134 | 218 |
|
135 | 219 | @pytest.mark.anyio |
|
0 commit comments