Skip to content

Commit e0f8ab9

Browse files
committed
T5: coverage backfill for S2-added branches
1 parent 416c9d9 commit e0f8ab9

5 files changed

Lines changed: 115 additions & 0 deletions

File tree

‎tests/client/test_notification_response.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,62 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
201201

202202
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
203203
await session.list_tools()
204+
205+
206+
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
207+
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests."""
208+
209+
async def handle_mcp_request(request: Request) -> Response:
210+
data = json.loads(await request.body())
211+
if data.get("method") == "initialize":
212+
return _init_json_response(data)
213+
if "id" not in data:
214+
return Response(status_code=202)
215+
return Response(content=body, status_code=status, media_type="application/json")
216+
217+
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
218+
219+
220+
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
221+
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
222+
server set ``id: null`` — the client rewraps it under the pending request's id, so
223+
the awaiting call resolves with the server's error code instead of the generic fallback."""
224+
body = json.dumps(
225+
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
226+
).encode()
227+
app = _create_non_2xx_json_body_app(400, body)
228+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
229+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
230+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
231+
await session.initialize()
232+
with pytest.raises(MCPError) as exc:
233+
await session.list_tools()
234+
assert exc.value.error.code == types.METHOD_NOT_FOUND
235+
236+
237+
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
238+
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
239+
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
240+
treated as the request's reply."""
241+
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
242+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
243+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
244+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
245+
await session.initialize()
246+
with pytest.raises(MCPError) as exc:
247+
await session.list_tools()
248+
assert exc.value.error.code == types.INTERNAL_ERROR
249+
250+
251+
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
252+
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
253+
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
254+
pending request — the parse failure never propagates."""
255+
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
256+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
257+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
258+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
259+
await session.initialize()
260+
with pytest.raises(MCPError) as exc:
261+
await session.list_tools()
262+
assert exc.value.error.code == types.INVALID_REQUEST

‎tests/server/test_connection.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,11 @@ def test_connection_check_capability_false_when_no_client_params_recorded():
356356
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
357357
True,
358358
),
359+
(
360+
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
361+
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
362+
True,
363+
),
359364
(ClientCapabilities(experimental=None), ClientCapabilities(experimental={"a": {}}), False),
360365
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
361366
(ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),

‎tests/server/test_runner.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,22 @@ async def short_circuit(ctx: Ctx, method: str, params: Any, call_next: Any) -> A
795795
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="denied")
796796

797797

798+
@pytest.mark.anyio
799+
async def test_runner_middleware_returning_non_model_surfaces_as_error(server: SrvT):
800+
"""SDK-defined: a middleware that short-circuits with a non-BaseModel/non-dict
801+
value is rejected by ``_dump_result`` and surfaces as a JSON-RPC error rather
802+
than leaking the raw return onto the wire."""
803+
804+
async def short_circuit(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
805+
return 42
806+
807+
server.middleware.append(short_circuit)
808+
async with connected_runner(server, initialized=False) as (client, _):
809+
with pytest.raises(MCPError) as exc:
810+
await client.send_raw_request("tools/list", None)
811+
assert "int" in exc.value.error.message
812+
813+
798814
@pytest.mark.anyio
799815
async def test_runner_handler_returning_unsupported_type_surfaces_as_error(server: SrvT):
800816
async def bad_return(ctx: Ctx, params: PaginatedRequestParams | None) -> int:

‎tests/server/test_stateless_mode.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ async def test_elicit_form_with_related_id_rides_the_request_channel():
128128
assert request_ch.requests[0][0] == "elicitation/create"
129129

130130

131+
@pytest.mark.anyio
132+
async def test_send_log_message_with_related_id_rides_the_request_channel():
133+
"""SDK-defined: the deprecated ``send_log_message`` notification with a related id
134+
rides the per-request channel, so it is delivered even with no standalone back-channel."""
135+
session, request_ch = _no_channel_session()
136+
await session.send_log_message( # pyright: ignore[reportDeprecated]
137+
level="info", data="hello", logger="test", related_request_id=3
138+
)
139+
assert request_ch.notifications == [("notifications/message", {"level": "info", "data": "hello", "logger": "test"})]
140+
141+
131142
@pytest.mark.anyio
132143
async def test_unrelated_notification_is_dropped_silently():
133144
"""SDK-defined: notifications on the no-channel standalone are best-effort — dropped, never raised."""

‎tests/server/test_streamable_http_modern.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,30 @@ def _list_tools_body() -> dict[str, Any]:
109109
return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
110110

111111

112+
async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None:
113+
"""SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied —
114+
the request still routes (200 + result) and the handler observes ``client_params is None``
115+
rather than the request being rejected at the validation ladder. A non-spec method is
116+
used so the kernel's per-method params validation does not re-reject the envelope."""
117+
seen: list[object] = []
118+
119+
async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> dict[str, Any]:
120+
seen.append(ctx.session._connection.client_params)
121+
return {"ok": True}
122+
123+
server: Server[Any] = Server("test")
124+
server.add_request_handler("custom/greet", PaginatedRequestParams, greet)
125+
126+
body = _list_tools_body()
127+
body["method"] = "custom/greet"
128+
body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
129+
async with _asgi_client(server) as http:
130+
response = await http.post("/mcp", json=body, headers={"content-type": "application/json"})
131+
assert response.status_code == 200
132+
assert response.json()["result"] == {"ok": True}
133+
assert seen == [None]
134+
135+
112136
async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_raises(
113137
caplog: pytest.LogCaptureFixture,
114138
) -> None:

0 commit comments

Comments
 (0)