Skip to content

Commit 732ebc4

Browse files
committed
Absorb #2926 (SEP-2577 deprecations); fix client 4xx id-correlation
After rebasing onto #2926: - Re-add # pyright: ignore[reportDeprecated] on the deprecated method calls in the reworked test_connection.py / test_server_context.py / test_stateless_mode.py (the rewrites had dropped main's suppressions) Fix to the client 4xx body-parsing patch (bb9b134): - A server's 4xx JSON-RPC error body may carry id:null (request rejected before its id was parsed). Surfacing that verbatim broke response correlation (client waits for id=N, gets id=null, hangs). Now use the parsed error data with the client's own request id. - Only surface the body when it's a JSONRPCError; anything else falls through to the status-derived stand-in. - Relax the two 'Session terminated' message-text assertions in test_streamable_http.py (the client now surfaces the server's actual message).
1 parent 99db895 commit 732ebc4

5 files changed

Lines changed: 17 additions & 12 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,15 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
324324
try:
325325
body = await response.aread()
326326
parsed = jsonrpc_message_adapter.validate_json(body, by_name=False)
327-
await ctx.read_stream_writer.send(SessionMessage(parsed))
328-
return
327+
if isinstance(parsed, JSONRPCError):
328+
# The server may have set `id: null` (request rejected before its
329+
# id was parsed); use this request's id so correlation works.
330+
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
331+
await ctx.read_stream_writer.send(SessionMessage(reply))
332+
return
329333
except (httpx.StreamError, ValidationError):
330-
logger.debug("Non-2xx body was not a valid JSON-RPC message; using fallback error")
334+
pass
335+
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
331336
if response.status_code == 404:
332337
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
333338
else:

tests/server/test_connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ async def test_connection_ping_sends_ping_on_standalone():
274274
async def test_connection_log_sends_logging_message_notification():
275275
out = StubOutbound()
276276
conn = Connection.for_loop(out)
277-
await conn.log("info", {"k": "v"}, logger="my.logger")
277+
await conn.log("info", {"k": "v"}, logger="my.logger") # pyright: ignore[reportDeprecated]
278278
method, params = out.notifications[0]
279279
assert method == "notifications/message"
280280
assert params is not None
@@ -287,7 +287,7 @@ async def test_connection_log_sends_logging_message_notification():
287287
async def test_connection_log_with_meta_includes_meta_in_params():
288288
out = StubOutbound()
289289
conn = Connection.for_loop(out)
290-
await conn.log("info", "x", meta={"traceId": "abc"})
290+
await conn.log("info", "x", meta={"traceId": "abc"}) # pyright: ignore[reportDeprecated]
291291
_, params = out.notifications[0]
292292
assert params is not None
293293
assert params["_meta"] == {"traceId": "abc"}

tests/server/test_server_context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async def test_context_log_sends_request_scoped_message_notification():
5858

5959
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
6060
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
61-
await ctx.log("debug", "hello")
61+
await ctx.log("debug", "hello") # pyright: ignore[reportDeprecated]
6262
return {}
6363

6464
async with running_pair(direct_pair, server_on_request=server_on_request, client_on_notify=c_notify) as (
@@ -80,7 +80,7 @@ async def test_context_log_includes_logger_and_meta_when_supplied():
8080

8181
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
8282
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
83-
await ctx.log("info", "x", logger="my.log", meta={"traceId": "t"})
83+
await ctx.log("info", "x", logger="my.log", meta={"traceId": "t"}) # pyright: ignore[reportDeprecated]
8484
return {}
8585

8686
async with running_pair(direct_pair, server_on_request=server_on_request, client_on_notify=c_notify) as (

tests/server/test_stateless_mode.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async def test_list_roots_raises_no_back_channel(no_channel_session: ServerSessi
6161
"""SDK-defined: `list_roots` has no `related_request_id` so it always rides
6262
the standalone channel, which raises here."""
6363
with pytest.raises(NoBackChannelError) as exc:
64-
await no_channel_session.list_roots()
64+
await no_channel_session.list_roots() # pyright: ignore[reportDeprecated]
6565
assert exc.value.method == "roots/list"
6666

6767

@@ -77,7 +77,7 @@ async def test_send_ping_raises_no_back_channel(no_channel_session: ServerSessio
7777
async def test_create_message_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
7878
"""SDK-defined: `create_message` without a related id rides the standalone channel and raises."""
7979
with pytest.raises(NoBackChannelError) as exc:
80-
await no_channel_session.create_message(
80+
await no_channel_session.create_message( # pyright: ignore[reportDeprecated]
8181
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
8282
max_tokens=100,
8383
)
@@ -144,7 +144,7 @@ async def test_loop_connection_outbound_does_not_raise_no_back_channel():
144144
conn = Connection.for_loop(standalone)
145145
assert conn.has_standalone_channel is True
146146
session = ServerSession(StubOutbound(), conn, standalone_outbound=conn.outbound)
147-
result = await session.list_roots()
147+
result = await session.list_roots() # pyright: ignore[reportDeprecated]
148148
assert isinstance(result, types.ListRootsResult)
149149
assert standalone.requests[0][0] == "roots/list"
150150

tests/shared/test_streamable_http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,7 +1045,7 @@ async def test_streamable_http_client_session_termination(basic_app: Starlette)
10451045
):
10461046
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
10471047
# Attempt to make a request after termination
1048-
with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch
1048+
with pytest.raises(MCPError, match="[Ss]ession.*terminated"): # pragma: no branch
10491049
await session.list_tools()
10501050

10511051

@@ -1106,7 +1106,7 @@ async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> htt
11061106
):
11071107
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
11081108
# Attempt to make a request after termination
1109-
with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch
1109+
with pytest.raises(MCPError, match="[Ss]ession.*terminated"): # pragma: no branch
11101110
await session.list_tools()
11111111

11121112

0 commit comments

Comments
 (0)