Skip to content

Commit c13d6ae

Browse files
committed
test: cover protocol/lifecycle gap requirements and refine the divergence model
1 parent 538136a commit c13d6ae

6 files changed

Lines changed: 517 additions & 34 deletions

File tree

tests/interaction/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ entry without a test cannot be silently aspirational.
113113
spec-correct output, and deletes the `Divergence`.
114114
3. An empty divergence list means the SDK is spec-conformant on every behaviour the suite covers.
115115

116+
A requirement may carry both `divergence` and `deferred`: the divergence records that the SDK falls
117+
short of the spec, and the deferral records why no test pins it (typically because the divergent
118+
behaviour cannot be driven through the public API). Divergence alone implies a test pins the
119+
divergent behaviour; divergence plus deferred means the gap is known but unpinned.
120+
116121
This is also the triage key for any rewrite: a test that fails on the new code path either has a
117122
divergence note (the rewrite accidentally fixed a known gap — decide whether to keep the fix) or
118123
it does not (the rewrite broke something that was correct — fix the rewrite).

tests/interaction/_requirements.py

Lines changed: 91 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
own contract) says should happen. Tests always pin the SDK's current behaviour. Where current
1616
behaviour falls short of `behavior`, the gap is recorded as data: `divergence` on entries whose
1717
tests pin the divergent behaviour, or `deferred` on entries that are tracked but not yet covered
18-
by a test in this suite. `issue` carries the tracking link for a recorded gap once one is filed.
18+
by a test in this suite. An entry may carry both: `divergence` records the spec-compliance gap
19+
(issue-able) and `deferred` records why no test exists; `divergence` alone implies a test pins
20+
the divergent behaviour. `issue` carries the tracking link for a recorded gap once one is filed.
1921
2022
`deferred` reasons take one of three shapes: where the behaviour is exercised elsewhere in this
2123
repo the reason names the covering test path; where the SDK does not implement the behaviour at
@@ -85,6 +87,12 @@ def __post_init__(self) -> None:
8587
behavior=(
8688
"The client rejects sending notifications or registering handlers for capabilities it did not declare."
8789
),
90+
divergence=Divergence(
91+
note=(
92+
"The client does not check its own declared capabilities before sending notifications or "
93+
"serving callbacks; nothing prevents a caller from violating the spec's SHOULD."
94+
),
95+
),
8896
deferred=(
8997
"Not implemented in the SDK: the client does not check its own declared capabilities before "
9098
"sending notifications or serving callbacks."
@@ -95,6 +103,12 @@ def __post_init__(self) -> None:
95103
behavior=(
96104
"The client rejects calls to methods (e.g. resources/list) for capabilities the server did not advertise."
97105
),
106+
divergence=Divergence(
107+
note=(
108+
"The client sends any request regardless of the server's advertised capabilities and "
109+
"surfaces whatever the server answers; the spec's SHOULD is not enforced."
110+
),
111+
),
98112
deferred=(
99113
"Not implemented in the SDK: the client sends any request regardless of the server's "
100114
"advertised capabilities and surfaces whatever the server answers."
@@ -168,9 +182,19 @@ def __post_init__(self) -> None:
168182
"Before initialization completes, the client sends no requests other than pings, and the "
169183
"server sends no requests other than pings and logging."
170184
),
185+
divergence=Divergence(
186+
note=(
187+
"The server's send methods (create_message / elicit_form / list_roots) do not check "
188+
"initialization state before sending; on the client side, Client always completes the "
189+
"handshake before any caller code runs."
190+
),
191+
),
171192
deferred=(
172-
"Not yet covered here: the sender-side restraint (especially the server half — no sampling, "
173-
"elicitation, or roots requests before the initialized notification) has no test yet."
193+
"Not implemented in the SDK: neither side enforces sender-side restraint. The server's send "
194+
"methods (create_message / elicit_form / list_roots) do not check initialization state before "
195+
"sending, and there is no natural hook to issue a server-to-client request between the "
196+
"initialize response and the initialized notification through the public API; on the client "
197+
"side, Client always completes the handshake before any caller code runs."
174198
),
175199
),
176200
"lifecycle:version:downgrade": Requirement(
@@ -179,12 +203,6 @@ def __post_init__(self) -> None:
179203
"When the server returns an older supported protocol version, the client downgrades to it "
180204
"and the connection succeeds at that version."
181205
),
182-
transports=("streamable-http",),
183-
deferred=(
184-
"Not yet covered here: observing the negotiated version requires the MCP-Protocol-Version "
185-
"request header, which only exists on the HTTP transport; planned with the transport "
186-
"conformance work."
187-
),
188206
),
189207
"lifecycle:version:match": Requirement(
190208
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
@@ -268,9 +286,13 @@ def __post_init__(self) -> None:
268286
"A response that arrives after the sender issued notifications/cancelled is ignored; the "
269287
"request stays failed and no error is raised."
270288
),
271-
deferred=(
272-
"Not yet covered here: needs the scripted-peer wire pattern to deliver a response after a "
273-
"cancellation; today the receive loop logs an unknown-request-id error for such responses."
289+
divergence=Divergence(
290+
note=(
291+
"A response whose id matches no in-flight request is delivered to the message handler "
292+
"as a RuntimeError rather than being silently ignored. The post-cancellation case is the "
293+
"same code path; tested in its unknown-id form because that is deterministic without the "
294+
"client-side cancellation API the SDK does not yet provide."
295+
),
274296
),
275297
),
276298
"protocol:cancel:server-survives": Requirement(
@@ -283,6 +305,13 @@ def __post_init__(self) -> None:
283305
"A server that abandons an in-flight server-initiated request (sampling, elicitation, roots) "
284306
"cancels it, and the client stops processing the cancelled request."
285307
),
308+
divergence=Divergence(
309+
note=(
310+
"Abandoning a server-side send_request emits no cancellation notification, and the client "
311+
"could not act on one anyway: client callbacks run inline in the receive loop, so a "
312+
"cancellation is not even read until the callback has finished."
313+
),
314+
),
286315
deferred=(
287316
"Not implemented in the SDK: abandoning a server-side send_request emits no cancellation "
288317
"notification (the same sender-side gap recorded on protocol:timeout:sends-cancellation), and "
@@ -311,10 +340,6 @@ def __post_init__(self) -> None:
311340
"protocol:error:connection-closed": Requirement(
312341
source="sdk",
313342
behavior="Closing the transport fails all in-flight requests with a connection-closed error.",
314-
deferred=(
315-
"Not yet covered here: planned gap test (close the transport while a request is in flight and "
316-
"pin the error the caller receives)."
317-
),
318343
),
319344
"protocol:error:internal-error": Requirement(
320345
source=f"{SPEC_BASE_URL}/basic#responses",
@@ -332,10 +357,6 @@ def __post_init__(self) -> None:
332357
"protocol:error:invalid-params": Requirement(
333358
source=f"{SPEC_BASE_URL}/basic#responses",
334359
behavior="A request with malformed params is answered with JSON-RPC error -32602 Invalid params.",
335-
deferred=(
336-
"Not yet covered here: the typed client API cannot send malformed params; needs a request "
337-
"driven one level below it (planned gap test)."
338-
),
339360
),
340361
"protocol:error:method-not-found": Requirement(
341362
source=f"{SPEC_BASE_URL}/basic#responses",
@@ -371,27 +392,34 @@ def __post_init__(self) -> None:
371392
"protocol:progress:token-unique": Requirement(
372393
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
373394
behavior=("Concurrent in-flight requests that each supply a progress callback carry distinct progress tokens."),
374-
deferred=(
375-
"Not yet covered here: planned gap test (two concurrent requests with progress callbacks, "
376-
"asserting their tokens differ and each callback only sees its own notifications)."
377-
),
378395
),
379396
"protocol:progress:monotonic": Requirement(
380397
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
381398
behavior=(
382399
"The progress value increases with each notification for a given token, even when the total is unknown."
383400
),
384-
deferred=(
385-
"Not implemented in the SDK: progress values are not validated anywhere; a handler can emit "
386-
"non-increasing values and they are forwarded as-is."
401+
divergence=Divergence(
402+
note=(
403+
"The spec MUST is not enforced: progress values are not validated on either side, so a "
404+
"handler that emits non-increasing values has them forwarded to the callback unchanged."
405+
),
387406
),
388407
),
389408
"protocol:progress:stops-after-completion": Requirement(
390409
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
391410
behavior="Progress notifications for a token stop once the associated request completes.",
392-
deferred=(
393-
"Not yet covered here: needs a test that a handler reporting progress after its request "
394-
"completed produces no further notifications for the caller."
411+
divergence=Divergence(
412+
note=(
413+
"send_progress_notification does not check whether the token's request has already "
414+
"completed; the late notification is sent and reaches the client."
415+
),
416+
),
417+
),
418+
"protocol:progress:late-dropped-by-client": Requirement(
419+
source="sdk",
420+
behavior=(
421+
"A progress notification that arrives after its request has completed is not delivered to the "
422+
"original progress callback."
395423
),
396424
),
397425
"protocol:progress:no-token": Requirement(
@@ -415,6 +443,12 @@ def __post_init__(self) -> None:
415443
"protocol:timeout:max-total": Requirement(
416444
source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
417445
behavior="A maximum total timeout is enforced even when progress notifications keep arriving.",
446+
divergence=Divergence(
447+
note=(
448+
"There is no maximum-total-timeout option; only the per-request read timeout exists, so the "
449+
"spec's SHOULD that an overall maximum is always enforced cannot be satisfied."
450+
),
451+
),
418452
deferred=(
419453
"Not implemented in the SDK: there is no maximum-total-timeout option; only the per-request "
420454
"read timeout exists."
@@ -1097,6 +1131,12 @@ def __post_init__(self) -> None:
10971131
"The server does not use includeContext values thisServer or allServers unless the client "
10981132
"declared the sampling.context capability."
10991133
),
1134+
divergence=Divergence(
1135+
note=(
1136+
"include_context is forwarded regardless of the client's declared sampling.context "
1137+
"capability; the server-side validator only checks tools/tool_choice."
1138+
),
1139+
),
11001140
deferred=(
11011141
"Not implemented in the SDK: include_context is forwarded regardless of the client's declared "
11021142
"sampling.context capability (unlike tools, which are gated by the server-side validator)."
@@ -1223,6 +1263,12 @@ def __post_init__(self) -> None:
12231263
"The server refuses to send an elicitation request with a mode the connected client did not "
12241264
"declare in its capabilities."
12251265
),
1266+
divergence=Divergence(
1267+
note=(
1268+
"The server does not check the client's declared elicitation modes before sending "
1269+
"elicitation/create; the spec's SHOULD is not enforced."
1270+
),
1271+
),
12261272
deferred=(
12271273
"Not implemented in the SDK: the server does not check the client's declared elicitation "
12281274
"modes before sending elicitation/create."
@@ -1295,6 +1341,12 @@ def __post_init__(self) -> None:
12951341
"Form-mode requested schemas are flat objects with primitive-typed properties only; nested "
12961342
"structures and arrays of objects are not used."
12971343
),
1344+
divergence=Divergence(
1345+
note=(
1346+
"Nothing restricts or validates the requested-schema shape on the sending side; a server "
1347+
"can send nested or non-primitive schemas and the SDK forwards them unchanged."
1348+
),
1349+
),
12981350
deferred=(
12991351
"Not implemented in the SDK: nothing restricts or validates the requested-schema shape on the "
13001352
"sending side; hand-built lowlevel elicitation requests pass through unchecked."
@@ -1306,6 +1358,9 @@ def __post_init__(self) -> None:
13061358
"Accepted form-mode content is validated against the requested schema: the client validates "
13071359
"the response before sending and the server validates the content it receives."
13081360
),
1361+
divergence=Divergence(
1362+
note="Accepted elicitation content passes through unvalidated on both sides.",
1363+
),
13091364
deferred=("Not implemented in the SDK: accepted elicitation content passes through unvalidated on both sides."),
13101365
),
13111366
"elicitation:url:action:accept-no-content": Requirement(
@@ -2147,6 +2202,12 @@ def __post_init__(self) -> None:
21472202
"with a fresh InitializeRequest and no session ID attached."
21482203
),
21492204
transports=("streamable-http",),
2205+
divergence=Divergence(
2206+
note=(
2207+
"The client surfaces the 404 as an error to the caller instead of re-initializing a new "
2208+
"session; the spec's MUST is not satisfied."
2209+
),
2210+
),
21502211
deferred=(
21512212
"Not implemented in the SDK: the client surfaces the 404 as an error to the caller instead of "
21522213
"re-initializing a new session."

tests/interaction/lowlevel/test_cancellation.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,25 @@
1111
from inline_snapshot import snapshot
1212

1313
from mcp import MCPError, types
14+
from mcp.client import ClientSession
1415
from mcp.server import Server, ServerRequestContext
15-
from mcp.types import CallToolResult, ErrorData, TextContent
16+
from mcp.shared.memory import create_client_server_memory_streams
17+
from mcp.shared.message import SessionMessage
18+
from mcp.types import (
19+
CallToolResult,
20+
EmptyResult,
21+
ErrorData,
22+
Implementation,
23+
InitializeResult,
24+
JSONRPCNotification,
25+
JSONRPCRequest,
26+
JSONRPCResponse,
27+
PingRequest,
28+
ServerCapabilities,
29+
TextContent,
30+
)
1631
from tests.interaction._connect import Connect
32+
from tests.interaction._helpers import IncomingMessage
1733
from tests.interaction._requirements import requirement
1834

1935
pytestmark = pytest.mark.anyio
@@ -137,3 +153,80 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
137153
result = await client.call_tool("echo", {})
138154

139155
assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
156+
157+
158+
@requirement("protocol:cancel:late-response-ignored")
159+
async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_handler() -> None:
160+
"""A response whose id matches no in-flight request is surfaced to the message handler as a RuntimeError.
161+
162+
The spec says a sender SHOULD ignore a response that arrives after it issued a cancellation;
163+
that is the same client-side code path as any response with an unknown id, and that form is
164+
deterministic to test without depending on the cancellation API the SDK does not yet provide.
165+
See the divergence note on the requirement.
166+
167+
A real Server cannot be made to answer with a fabricated id, so the test plays the server's
168+
side of the wire by hand. Reserve this pattern for behaviour no real server can produce. The
169+
other tests in this file run over the transport matrix; this one is in-memory only because the
170+
scripted-peer mechanism is the in-memory stream pair, not because the behaviour is
171+
transport-specific.
172+
"""
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+
)
187+
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+
),
200+
)
201+
)
202+
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:")

0 commit comments

Comments
 (0)