Skip to content

Commit 5216997

Browse files
committed
test: add server-initiated request and notification interaction tests
Covers the server-to-client half of the interaction model: sampling, form-mode elicitation, roots, progress in both directions, list_changed notifications, and request cancellation, all against the low-level Server through the public Client API. Records a further divergence: the server answers cancelled requests with an error response where the spec says no response should be sent. Removes five more 'pragma: no cover' comments on paths these tests now cover (server list_changed senders, the client roots send path, and the default elicitation callback).
1 parent 5710662 commit 5216997

11 files changed

Lines changed: 1115 additions & 6 deletions

File tree

src/mcp/client/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,4 @@ async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta
305305
async def send_roots_list_changed(self) -> None:
306306
"""Send a notification that the roots list has changed."""
307307
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
308-
await self.session.send_roots_list_changed() # pragma: no cover
308+
await self.session.send_roots_list_changed()

src/mcp/client/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ async def _default_elicitation_callback(
7474
context: RequestContext[ClientSession],
7575
params: types.ElicitRequestParams,
7676
) -> types.ElicitResult | types.ErrorData:
77-
return types.ErrorData( # pragma: no cover
77+
return types.ErrorData(
7878
code=types.INVALID_REQUEST,
7979
message="Elicitation not supported",
8080
)
@@ -408,7 +408,7 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
408408

409409
return result
410410

411-
async def send_roots_list_changed(self) -> None: # pragma: no cover
411+
async def send_roots_list_changed(self) -> None:
412412
"""Send a roots/list_changed notification."""
413413
await self.send_notification(types.RootsListChangedNotification())
414414

src/mcp/server/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,11 +479,11 @@ async def send_resource_list_changed(self) -> None:
479479
"""Send a resource list changed notification."""
480480
await self.send_notification(types.ResourceListChangedNotification())
481481

482-
async def send_tool_list_changed(self) -> None: # pragma: no cover
482+
async def send_tool_list_changed(self) -> None:
483483
"""Send a tool list changed notification."""
484484
await self.send_notification(types.ToolListChangedNotification())
485485

486-
async def send_prompt_list_changed(self) -> None: # pragma: no cover
486+
async def send_prompt_list_changed(self) -> None:
487487
"""Send a prompt list changed notification."""
488488
await self.send_notification(types.PromptListChangedNotification())
489489

tests/interaction/_requirements.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,61 @@ class Requirement:
9696
),
9797
),
9898
# ═══════════════════════════════════════════════════════════════════════════
99+
# Cancellation
100+
# ═══════════════════════════════════════════════════════════════════════════
101+
"cancellation:in-flight": Requirement(
102+
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
103+
behavior=(
104+
"A cancellation notification for an in-flight request stops the server-side handler, and the "
105+
"caller's pending request fails with an error response."
106+
),
107+
divergence=Divergence(
108+
note=(
109+
"The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled "
110+
"request; the server sends an error response (code 0, 'Request cancelled'), which is what "
111+
"unblocks the SDK client's pending call."
112+
),
113+
),
114+
),
115+
"cancellation:server-survives": Requirement(
116+
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
117+
behavior="The session continues to serve new requests after an earlier request was cancelled.",
118+
),
119+
"cancellation:unknown-request": Requirement(
120+
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
121+
behavior=(
122+
"A cancellation notification referencing an unknown or already-completed request is ignored without error."
123+
),
124+
),
125+
# ═══════════════════════════════════════════════════════════════════════════
126+
# Progress
127+
# ═══════════════════════════════════════════════════════════════════════════
128+
"progress:server-to-client": Requirement(
129+
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
130+
behavior=(
131+
"Progress notifications emitted by a handler during a request are delivered to the caller's "
132+
"progress callback, in order, with their progress, total, and message."
133+
),
134+
),
135+
"progress:token-propagation": Requirement(
136+
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
137+
behavior=(
138+
"Supplying a progress callback attaches a progress token to the outgoing request, which the "
139+
"server-side handler can observe in its request metadata."
140+
),
141+
),
142+
"progress:no-token": Requirement(
143+
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
144+
behavior=(
145+
"Without a progress callback no token is attached, and a handler that reports progress anyway "
146+
"sends nothing."
147+
),
148+
),
149+
"progress:client-to-server": Requirement(
150+
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
151+
behavior="A progress notification sent by the client is delivered to the server's progress handler.",
152+
),
153+
# ═══════════════════════════════════════════════════════════════════════════
99154
# Ping
100155
# ═══════════════════════════════════════════════════════════════════════════
101156
"ping:client-to-server": Requirement(
@@ -229,6 +284,21 @@ class Requirement:
229284
behavior="resources/read for an unknown URI returns a JSON-RPC error; the spec reserves -32002 for it.",
230285
),
231286
# ═══════════════════════════════════════════════════════════════════════════
287+
# Notifications: list_changed (server → client)
288+
# ═══════════════════════════════════════════════════════════════════════════
289+
"notifications:tools:list-changed": Requirement(
290+
source=f"{SPEC_BASE_URL}/server/tools#list-changed-notification",
291+
behavior="A tools/list_changed notification sent by the server reaches the client's message handler.",
292+
),
293+
"notifications:resources:list-changed": Requirement(
294+
source=f"{SPEC_BASE_URL}/server/resources#list-changed-notification",
295+
behavior="A resources/list_changed notification sent by the server reaches the client's message handler.",
296+
),
297+
"notifications:prompts:list-changed": Requirement(
298+
source=f"{SPEC_BASE_URL}/server/prompts#list-changed-notification",
299+
behavior="A prompts/list_changed notification sent by the server reaches the client's message handler.",
300+
),
301+
# ═══════════════════════════════════════════════════════════════════════════
232302
# Prompts
233303
# ═══════════════════════════════════════════════════════════════════════════
234304
"prompts:list:basic": Requirement(
@@ -248,6 +318,88 @@ class Requirement:
248318
behavior="prompts/get for an unknown prompt name returns a JSON-RPC error.",
249319
),
250320
# ═══════════════════════════════════════════════════════════════════════════
321+
# Sampling (server → client)
322+
# ═══════════════════════════════════════════════════════════════════════════
323+
"sampling:create-message:round-trip": Requirement(
324+
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
325+
behavior=(
326+
"A sampling/createMessage request from a server handler is answered by the client's sampling "
327+
"callback, and the callback's result (role, content, model, stopReason) is returned to the handler."
328+
),
329+
),
330+
"sampling:create-message:params": Requirement(
331+
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
332+
behavior=(
333+
"The sampling parameters supplied by the server (messages, maxTokens, systemPrompt, "
334+
"modelPreferences, temperature, stopSequences) reach the client callback intact."
335+
),
336+
),
337+
"sampling:create-message:image-content": Requirement(
338+
source=f"{SPEC_BASE_URL}/client/sampling#message-content",
339+
behavior="Sampling messages can carry image content: base64 data with a mimeType.",
340+
),
341+
"sampling:create-message:client-error": Requirement(
342+
source=f"{SPEC_BASE_URL}/client/sampling#error-handling",
343+
behavior="A sampling callback that returns an error is surfaced to the requesting handler as an MCPError.",
344+
),
345+
"sampling:create-message:not-supported": Requirement(
346+
source=f"{SPEC_BASE_URL}/client/sampling#capabilities",
347+
behavior=(
348+
"A sampling request to a client that did not declare the sampling capability fails with an "
349+
"error rather than hanging or being silently dropped."
350+
),
351+
),
352+
# ═══════════════════════════════════════════════════════════════════════════
353+
# Elicitation (server → client)
354+
# ═══════════════════════════════════════════════════════════════════════════
355+
"elicitation:form:accept": Requirement(
356+
source=f"{SPEC_BASE_URL}/client/elicitation#form-mode-elicitation",
357+
behavior=(
358+
"A form-mode elicitation answered with action 'accept' returns the user's content to the "
359+
"requesting handler, validated against the requested schema."
360+
),
361+
),
362+
"elicitation:form:decline": Requirement(
363+
source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
364+
behavior="A form-mode elicitation answered with action 'decline' returns no content to the handler.",
365+
),
366+
"elicitation:form:cancel": Requirement(
367+
source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
368+
behavior="A form-mode elicitation answered with action 'cancel' returns no content to the handler.",
369+
),
370+
"elicitation:form:not-supported": Requirement(
371+
source=f"{SPEC_BASE_URL}/client/elicitation#capabilities",
372+
behavior=(
373+
"An elicitation request to a client that did not declare the elicitation capability fails with "
374+
"an error rather than hanging or being silently dropped."
375+
),
376+
),
377+
# ═══════════════════════════════════════════════════════════════════════════
378+
# Roots (server → client)
379+
# ═══════════════════════════════════════════════════════════════════════════
380+
"roots:list:round-trip": Requirement(
381+
source=f"{SPEC_BASE_URL}/client/roots#listing-roots",
382+
behavior=(
383+
"A roots/list request from a server handler is answered by the client's roots callback, and "
384+
"the returned roots (uri, name) reach the handler."
385+
),
386+
),
387+
"roots:list:empty": Requirement(
388+
source=f"{SPEC_BASE_URL}/client/roots#listing-roots",
389+
behavior="An empty roots list is a valid response and reaches the handler as such.",
390+
),
391+
"roots:list:not-supported": Requirement(
392+
source=f"{SPEC_BASE_URL}/client/roots#capabilities",
393+
behavior=(
394+
"A roots/list request to a client that did not declare the roots capability fails with an "
395+
"error rather than hanging or being silently dropped."
396+
),
397+
),
398+
"roots:list-changed": Requirement(
399+
source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",
400+
behavior="A roots/list_changed notification sent by the client is delivered to the server's handler.",
401+
),
402+
# ═══════════════════════════════════════════════════════════════════════════
251403
# MCPServer behavioural guarantees (not spec-mandated)
252404
# ═══════════════════════════════════════════════════════════════════════════
253405
"mcpserver:tools:output-schema:model": Requirement(
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Cancellation interactions against the low-level Server, driven through the public Client API.
2+
3+
There is no client-side cancellation API: cancelling means sending a CancelledNotification
4+
carrying the request id, which only the server-side handler can observe (`ctx.request_id`), so
5+
these tests capture the id from inside the blocked handler before cancelling. The handler blocks
6+
on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
7+
"""
8+
9+
import anyio
10+
import pytest
11+
from inline_snapshot import snapshot
12+
13+
from mcp import MCPError, types
14+
from mcp.client.client import Client
15+
from mcp.server import Server, ServerRequestContext
16+
from mcp.types import CallToolResult, ErrorData, TextContent
17+
from tests.interaction._requirements import requirement
18+
19+
pytestmark = pytest.mark.anyio
20+
21+
22+
@requirement("cancellation:in-flight")
23+
async def test_cancellation_stops_in_flight_handler() -> None:
24+
"""Cancelling an in-flight request interrupts its handler and fails the pending call.
25+
26+
The server answers the cancelled request with an error response (the spec says it should
27+
not respond at all; see the divergence note on the requirement), so the caller's pending
28+
request raises rather than hanging.
29+
"""
30+
started = anyio.Event()
31+
handler_cancelled = anyio.Event()
32+
request_ids: list[types.RequestId] = []
33+
errors: list[ErrorData] = []
34+
35+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
36+
assert params.name == "block"
37+
assert ctx.request_id is not None
38+
request_ids.append(ctx.request_id)
39+
started.set()
40+
try:
41+
await anyio.Event().wait() # blocks until cancelled; nothing ever sets this event
42+
except anyio.get_cancelled_exc_class():
43+
handler_cancelled.set()
44+
raise
45+
raise NotImplementedError # unreachable: the wait above never completes normally
46+
47+
server = Server("blocker", on_call_tool=call_tool)
48+
49+
async with Client(server) as client:
50+
with anyio.fail_after(5):
51+
async with anyio.create_task_group() as task_group:
52+
53+
async def call_and_capture_error() -> None:
54+
with pytest.raises(MCPError) as exc_info:
55+
await client.call_tool("block", {})
56+
errors.append(exc_info.value.error)
57+
58+
task_group.start_soon(call_and_capture_error)
59+
await started.wait()
60+
await client.session.send_notification(
61+
types.CancelledNotification(
62+
params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted")
63+
)
64+
)
65+
66+
await handler_cancelled.wait()
67+
68+
assert errors == snapshot([ErrorData(code=0, message="Request cancelled")])
69+
70+
71+
@requirement("cancellation:server-survives")
72+
async def test_session_serves_requests_after_cancellation() -> None:
73+
"""A request cancelled mid-flight does not poison the session: the next request succeeds."""
74+
started = anyio.Event()
75+
request_ids: list[types.RequestId] = []
76+
77+
async def list_tools(
78+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
79+
) -> types.ListToolsResult:
80+
return types.ListToolsResult(
81+
tools=[
82+
types.Tool(name="block", input_schema={"type": "object"}),
83+
types.Tool(name="echo", input_schema={"type": "object"}),
84+
]
85+
)
86+
87+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
88+
if params.name == "echo":
89+
return CallToolResult(content=[TextContent(text="still alive")])
90+
assert ctx.request_id is not None
91+
request_ids.append(ctx.request_id)
92+
started.set()
93+
await anyio.Event().wait() # blocks until cancelled
94+
raise NotImplementedError # unreachable
95+
96+
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
97+
98+
async with Client(server) as client:
99+
with anyio.fail_after(5):
100+
async with anyio.create_task_group() as task_group:
101+
102+
async def call_and_swallow_cancellation_error() -> None:
103+
with pytest.raises(MCPError):
104+
await client.call_tool("block", {})
105+
106+
task_group.start_soon(call_and_swallow_cancellation_error)
107+
await started.wait()
108+
await client.session.send_notification(
109+
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0]))
110+
)
111+
112+
result = await client.call_tool("echo", {})
113+
114+
assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
115+
116+
117+
@requirement("cancellation:unknown-request")
118+
async def test_cancellation_for_unknown_request_is_ignored() -> None:
119+
"""A cancellation referencing a request id that is not in flight is ignored without error."""
120+
121+
async def list_tools(
122+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
123+
) -> types.ListToolsResult:
124+
return types.ListToolsResult(tools=[types.Tool(name="echo", input_schema={"type": "object"})])
125+
126+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
127+
assert params.name == "echo"
128+
return CallToolResult(content=[TextContent(text="unbothered")])
129+
130+
server = Server("calm", on_list_tools=list_tools, on_call_tool=call_tool)
131+
132+
async with Client(server) as client:
133+
await client.session.send_notification(
134+
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=9999))
135+
)
136+
result = await client.call_tool("echo", {})
137+
138+
assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))

0 commit comments

Comments
 (0)