Skip to content

Commit e0e8e57

Browse files
committed
test: fix interaction suite for 3.10/3.11/3.14 and lowest-direct CI legs
- raise pytest floor to 8.4.0 (RaisesGroup, used in the auth tests) - collect twice in connect_over_sse so the unclosed sse_stream_reader is finalized on 3.10 (PEP 442 cycle needs a second pass) - collapse stacked async-with statements into comma form where it reads cleanly, and apply # pragma: no branch on the remaining sync-with + async-with shapes coverage.py mis-traces on 3.11/3.14
1 parent 0157444 commit e0e8e57

18 files changed

Lines changed: 320 additions & 288 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ dev = [
7878
# We add mcp[cli,ws] so `uv sync` considers the extras.
7979
"mcp[cli,ws]",
8080
"pyright>=1.1.400",
81-
"pytest>=8.3.4",
81+
"pytest>=8.4.0",
8282
"ruff>=0.8.5",
8383
"trio>=0.26.2",
8484
"pytest-flakefinder>=1.1.0",

tests/interaction/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,5 +208,8 @@ assert after the call, with no synchronisation. The exceptions:
208208

209209
CI requires 100% line and branch coverage, including `tests/`, and `strict-no-cover` fails the
210210
build if a line marked `# pragma: no cover` is ever executed. When a new test starts covering a
211-
pragma'd line in `src/`, delete the pragma in the same change. Do not add new `# pragma`,
212-
`# type: ignore`, or `# noqa` comments; restructure instead.
211+
pragma'd line in `src/`, delete the pragma in the same change. Do not add new `# type: ignore` or
212+
`# noqa` comments; restructure instead. The one sanctioned pragma is `# pragma: no branch` on a
213+
`with`/`async with` line whose only fault is coverage.py mis-tracing the exit arc of a nested
214+
async context — restructure first, and reserve the pragma for shapes that cannot collapse (a sync
215+
`with` adjacent to an `async with`).

tests/interaction/_connect.py

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -130,20 +130,21 @@ async def connect_over_streamable_http(
130130
retry_interval=retry_interval,
131131
transport_security=NO_DNS_REBINDING_PROTECTION,
132132
)
133-
async with server.session_manager.run():
134-
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client:
135-
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
136-
async with Client(
137-
transport,
138-
read_timeout_seconds=read_timeout_seconds,
139-
sampling_callback=sampling_callback,
140-
list_roots_callback=list_roots_callback,
141-
logging_callback=logging_callback,
142-
message_handler=message_handler,
143-
client_info=client_info,
144-
elicitation_callback=elicitation_callback,
145-
) as client:
146-
yield client
133+
async with (
134+
server.session_manager.run(),
135+
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
136+
Client(
137+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
138+
read_timeout_seconds=read_timeout_seconds,
139+
sampling_callback=sampling_callback,
140+
list_roots_callback=list_roots_callback,
141+
logging_callback=logging_callback,
142+
message_handler=message_handler,
143+
client_info=client_info,
144+
elicitation_callback=elicitation_callback,
145+
) as client,
146+
):
147+
yield client
147148

148149

149150
@asynccontextmanager
@@ -183,11 +184,13 @@ async def mounted_app(
183184
auth_server_provider=auth_server_provider,
184185
)
185186
event_hooks = {"request": [on_request]} if on_request is not None else None
186-
async with server.session_manager.run():
187-
async with httpx.AsyncClient(
187+
async with (
188+
server.session_manager.run(),
189+
httpx.AsyncClient(
188190
transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers
189-
) as http_client:
190-
yield http_client, server.session_manager
191+
) as http_client,
192+
):
193+
yield http_client, server.session_manager
191194

192195

193196
@asynccontextmanager
@@ -357,11 +360,13 @@ def httpx_client_factory(
357360
) as client:
358361
yield client
359362
finally:
360-
# SseServerTransport.connect_sse hands its internal SSE-chunk receive stream to
361-
# sse_starlette's EventSourceResponse, which never closes it when its task group is
362-
# cancelled on disconnect (see notes/findings.md). Collect the orphan here so its
363-
# ResourceWarning fires deterministically inside this fixture instead of at an
364-
# arbitrary later GC.
363+
# SseServerTransport.connect_sse never closes its sse_stream_reader (handed to
364+
# sse_starlette.EventSourceResponse, which does not aclose() its content on cancel).
365+
# After teardown that reader is held only by a reference cycle through the connect_sse
366+
# frame and its task objects; collecting twice runs the cycle's finalizers and then
367+
# frees the reader while ResourceWarning is suppressed, instead of at an arbitrary
368+
# later GC under pytest's error filter. One pass suffices on 3.11+; 3.10 needs both.
365369
with warnings.catch_warnings():
366370
warnings.simplefilter("ignore", ResourceWarning)
367371
gc.collect()
372+
gc.collect()

tests/interaction/auth/_harness.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import json
1313
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
14-
from contextlib import asynccontextmanager
14+
from contextlib import AsyncExitStack, asynccontextmanager
1515
from dataclasses import dataclass, field
1616
from typing import Any
1717
from urllib.parse import parse_qs, parse_qsl, urlsplit
@@ -451,11 +451,15 @@ async def hook(request: httpx.Request) -> None:
451451

452452
event_hooks = {"request": [hook]}
453453

454-
async with server.session_manager.run():
455-
async with httpx.AsyncClient(
456-
transport=StreamingASGITransport(app), base_url=BASE_URL, auth=oauth, event_hooks=event_hooks
457-
) as http_client:
458-
headless.bind(http_client)
459-
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
460-
async with Client(transport) as client:
461-
yield client, headless
454+
async with AsyncExitStack() as stack:
455+
await stack.enter_async_context(server.session_manager.run())
456+
http_client = await stack.enter_async_context(
457+
httpx.AsyncClient(
458+
transport=StreamingASGITransport(app), base_url=BASE_URL, auth=oauth, event_hooks=event_hooks
459+
)
460+
)
461+
headless.bind(http_client)
462+
client = await stack.enter_async_context(
463+
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client))
464+
)
465+
yield client, headless

tests/interaction/lowlevel/test_cancellation.py

Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from mcp import MCPError, types
1414
from mcp.client import ClientSession
1515
from mcp.server import Server, ServerRequestContext
16-
from mcp.shared.memory import create_client_server_memory_streams
16+
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
1717
from mcp.shared.message import SessionMessage
1818
from mcp.types import (
1919
CallToolResult,
@@ -170,63 +170,65 @@ async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_hand
170170
scripted-peer mechanism is the in-memory stream pair, not because the behaviour is
171171
transport-specific.
172172
"""
173-
async with create_client_server_memory_streams() as (client_streams, server_streams):
174-
client_read, client_write = client_streams
175-
server_read, server_write = server_streams
176-
177-
async def scripted_server() -> None:
178-
def respond(request_id: types.RequestId, result: types.Result) -> SessionMessage:
179-
return SessionMessage(
180-
JSONRPCResponse(
181-
jsonrpc="2.0",
182-
id=request_id,
183-
# Serialized exactly as a real server serializes results onto the wire.
184-
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
185-
)
186-
)
187173

188-
init = await server_read.receive()
189-
assert isinstance(init, SessionMessage)
190-
assert isinstance(init.message, JSONRPCRequest)
191-
assert init.message.method == "initialize"
192-
await server_write.send(
193-
respond(
194-
init.message.id,
195-
InitializeResult(
196-
protocol_version="2025-11-25",
197-
capabilities=ServerCapabilities(),
198-
server_info=Implementation(name="scripted", version="0.0.1"),
199-
),
174+
async def scripted_server(streams: MessageStream) -> None:
175+
server_read, server_write = streams
176+
177+
def respond(request_id: types.RequestId, result: types.Result) -> SessionMessage:
178+
return SessionMessage(
179+
JSONRPCResponse(
180+
jsonrpc="2.0",
181+
id=request_id,
182+
# Serialized exactly as a real server serializes results onto the wire.
183+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
200184
)
201185
)
202186

203-
initialized = await server_read.receive()
204-
assert isinstance(initialized, SessionMessage)
205-
assert isinstance(initialized.message, JSONRPCNotification)
206-
assert initialized.message.method == "notifications/initialized"
207-
208-
ping = await server_read.receive()
209-
assert isinstance(ping, SessionMessage)
210-
assert isinstance(ping.message, JSONRPCRequest)
211-
assert ping.message.method == "ping"
212-
# First answer with a fabricated id that matches nothing in flight, then the real id.
213-
await server_write.send(respond(9999, EmptyResult()))
214-
await server_write.send(respond(ping.message.id, EmptyResult()))
215-
216-
incoming: list[IncomingMessage] = []
217-
218-
async def message_handler(message: IncomingMessage) -> None:
219-
incoming.append(message)
220-
221-
async with anyio.create_task_group() as task_group:
222-
task_group.start_soon(scripted_server)
223-
async with ClientSession(client_read, client_write, message_handler=message_handler) as session:
224-
with anyio.fail_after(5):
225-
await session.initialize()
226-
pong = await session.send_request(PingRequest(), EmptyResult)
227-
228-
assert pong == snapshot(EmptyResult())
229-
assert len(incoming) == 1
230-
assert isinstance(incoming[0], RuntimeError)
231-
# The full message embeds the response object's repr; only the prefix is stable.
232-
assert str(incoming[0]).startswith("Received response with an unknown request ID:")
187+
init = await server_read.receive()
188+
assert isinstance(init, SessionMessage)
189+
assert isinstance(init.message, JSONRPCRequest)
190+
assert init.message.method == "initialize"
191+
await server_write.send(
192+
respond(
193+
init.message.id,
194+
InitializeResult(
195+
protocol_version="2025-11-25",
196+
capabilities=ServerCapabilities(),
197+
server_info=Implementation(name="scripted", version="0.0.1"),
198+
),
199+
)
200+
)
201+
202+
initialized = await server_read.receive()
203+
assert isinstance(initialized, SessionMessage)
204+
assert isinstance(initialized.message, JSONRPCNotification)
205+
assert initialized.message.method == "notifications/initialized"
206+
207+
ping = await server_read.receive()
208+
assert isinstance(ping, SessionMessage)
209+
assert isinstance(ping.message, JSONRPCRequest)
210+
assert ping.message.method == "ping"
211+
# First answer with a fabricated id that matches nothing in flight, then the real id.
212+
await server_write.send(respond(9999, EmptyResult()))
213+
await server_write.send(respond(ping.message.id, EmptyResult()))
214+
215+
incoming: list[IncomingMessage] = []
216+
217+
async def message_handler(message: IncomingMessage) -> None:
218+
incoming.append(message)
219+
220+
async with (
221+
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
222+
anyio.create_task_group() as task_group,
223+
ClientSession(client_read, client_write, message_handler=message_handler) as session,
224+
):
225+
task_group.start_soon(scripted_server, server_streams)
226+
with anyio.fail_after(5):
227+
await session.initialize()
228+
pong = await session.send_request(PingRequest(), EmptyResult)
229+
230+
assert pong == snapshot(EmptyResult())
231+
assert len(incoming) == 1
232+
assert isinstance(incoming[0], RuntimeError)
233+
# The full message embeds the response object's repr; only the prefix is stable.
234+
assert str(incoming[0]).startswith("Received response with an unknown request ID:")

tests/interaction/lowlevel/test_elicitation.py

Lines changed: 61 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp import MCPError, UrlElicitationRequiredError, types
1212
from mcp.client import ClientRequestContext, ClientSession
1313
from mcp.server import Server, ServerRequestContext
14-
from mcp.shared.memory import create_client_server_memory_streams
14+
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
1515
from mcp.shared.message import SessionMessage
1616
from mcp.types import (
1717
CallToolResult,
@@ -594,68 +594,68 @@ async def answer_form(context: ClientRequestContext, params: types.ElicitRequest
594594
received.append(params)
595595
return ElicitResult(action="accept", content={})
596596

597-
async with create_client_server_memory_streams() as (client_streams, server_streams):
598-
client_read, client_write = client_streams
599-
server_read, server_write = server_streams
600-
601-
async def scripted_server() -> None:
602-
initialize = await server_read.receive()
603-
assert isinstance(initialize, SessionMessage)
604-
request = initialize.message
605-
assert isinstance(request, JSONRPCRequest)
606-
assert request.method == "initialize"
607-
result = InitializeResult(
608-
protocol_version="2025-11-25",
609-
capabilities=ServerCapabilities(),
610-
server_info=Implementation(name="legacy", version="0.0.1"),
611-
)
612-
await server_write.send(
613-
SessionMessage(
614-
JSONRPCResponse(
615-
jsonrpc="2.0",
616-
id=request.id,
617-
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
618-
)
597+
async def scripted_server(streams: MessageStream) -> None:
598+
server_read, server_write = streams
599+
initialize = await server_read.receive()
600+
assert isinstance(initialize, SessionMessage)
601+
request = initialize.message
602+
assert isinstance(request, JSONRPCRequest)
603+
assert request.method == "initialize"
604+
result = InitializeResult(
605+
protocol_version="2025-11-25",
606+
capabilities=ServerCapabilities(),
607+
server_info=Implementation(name="legacy", version="0.0.1"),
608+
)
609+
await server_write.send(
610+
SessionMessage(
611+
JSONRPCResponse(
612+
jsonrpc="2.0",
613+
id=request.id,
614+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
619615
)
620616
)
621-
initialized = await server_read.receive()
622-
assert isinstance(initialized, SessionMessage)
623-
assert isinstance(initialized.message, JSONRPCNotification)
624-
assert initialized.message.method == "notifications/initialized"
625-
# No mode key: a server speaking a pre-mode revision of the spec sends only message + schema.
626-
await server_write.send(
627-
SessionMessage(
628-
JSONRPCRequest(
629-
jsonrpc="2.0",
630-
id=2,
631-
method="elicitation/create",
632-
params={"message": "Legacy ask.", "requestedSchema": {"type": "object", "properties": {}}},
633-
)
617+
)
618+
initialized = await server_read.receive()
619+
assert isinstance(initialized, SessionMessage)
620+
assert isinstance(initialized.message, JSONRPCNotification)
621+
assert initialized.message.method == "notifications/initialized"
622+
# No mode key: a server speaking a pre-mode revision of the spec sends only message + schema.
623+
await server_write.send(
624+
SessionMessage(
625+
JSONRPCRequest(
626+
jsonrpc="2.0",
627+
id=2,
628+
method="elicitation/create",
629+
params={"message": "Legacy ask.", "requestedSchema": {"type": "object", "properties": {}}},
634630
)
635631
)
636-
response = await server_read.receive()
637-
assert isinstance(response, SessionMessage)
638-
server_received.append(response.message)
639-
answered.set()
640-
641-
async with anyio.create_task_group() as tg:
642-
tg.start_soon(scripted_server)
643-
async with ClientSession(client_read, client_write, elicitation_callback=answer_form) as session:
644-
with anyio.fail_after(5):
645-
await session.initialize()
646-
await answered.wait()
647-
648-
assert received == snapshot(
649-
[
650-
ElicitRequestFormParams(
651-
_meta=None,
652-
message="Legacy ask.",
653-
requested_schema={"type": "object", "properties": {}},
654-
)
655-
]
632+
)
633+
response = await server_read.receive()
634+
assert isinstance(response, SessionMessage)
635+
server_received.append(response.message)
636+
answered.set()
637+
638+
async with (
639+
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
640+
anyio.create_task_group() as tg,
641+
ClientSession(client_read, client_write, elicitation_callback=answer_form) as session,
642+
):
643+
tg.start_soon(scripted_server, server_streams)
644+
with anyio.fail_after(5):
645+
await session.initialize()
646+
await answered.wait()
647+
648+
assert received == snapshot(
649+
[
650+
ElicitRequestFormParams(
651+
_meta=None,
652+
message="Legacy ask.",
653+
requested_schema={"type": "object", "properties": {}},
656654
)
657-
assert isinstance(received[0], ElicitRequestFormParams)
658-
assert received[0].mode == "form"
659-
assert len(server_received) == 1
660-
assert isinstance(server_received[0], JSONRPCResponse)
661-
assert server_received[0].id == 2
655+
]
656+
)
657+
assert isinstance(received[0], ElicitRequestFormParams)
658+
assert received[0].mode == "form"
659+
assert len(server_received) == 1
660+
assert isinstance(server_received[0], JSONRPCResponse)
661+
assert server_received[0].id == 2

0 commit comments

Comments
 (0)