Skip to content

Commit 7709b98

Browse files
committed
test: add output schema, sampling constraint, roots error, and version-rejection interaction tests
1 parent cce06b2 commit 7709b98

8 files changed

Lines changed: 287 additions & 6 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ class Requirement:
116116
"requested version with its own latest supported version rather than an error."
117117
),
118118
),
119+
"lifecycle:initialize:protocol-version:client-rejects": Requirement(
120+
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
121+
behavior=(
122+
"A client that receives an initialize response carrying a protocol version it does not "
123+
"support fails initialization with an error rather than proceeding with the session."
124+
),
125+
),
119126
"lifecycle:requests-before-initialized": Requirement(
120127
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
121128
behavior="A request sent before the initialization handshake completes is rejected with an error.",
@@ -154,6 +161,19 @@ class Requirement:
154161
"A cancellation notification referencing an unknown or already-completed request is ignored without error."
155162
),
156163
),
164+
"cancellation:server-to-client": Requirement(
165+
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
166+
behavior=(
167+
"A server that abandons an in-flight server-initiated request (sampling, elicitation, roots) "
168+
"cancels it, and the client stops processing the cancelled request."
169+
),
170+
deferred=(
171+
"Not expressible through the public API: abandoning a server-side send_request emits no "
172+
"cancellation notification (the same sender-side gap recorded on timeouts:per-request), and "
173+
"the client could not act on one anyway because client callbacks run inline in the receive "
174+
"loop, so a cancellation would not even be read until the callback had already finished."
175+
),
176+
),
157177
# ═══════════════════════════════════════════════════════════════════════════
158178
# Progress
159179
# ═══════════════════════════════════════════════════════════════════════════
@@ -325,6 +345,13 @@ class Requirement:
325345
"with the validation failure described in content), not a protocol error."
326346
),
327347
),
348+
"tools:call:output-schema-validation": Requirement(
349+
source=f"{SPEC_BASE_URL}/server/tools#tool-result",
350+
behavior=(
351+
"A tool result whose structuredContent does not conform to the tool's declared outputSchema "
352+
"is rejected by the client: the call raises instead of returning the invalid result."
353+
),
354+
),
328355
# ═══════════════════════════════════════════════════════════════════════════
329356
# Completion
330357
# ═══════════════════════════════════════════════════════════════════════════
@@ -473,6 +500,17 @@ class Requirement:
473500
source=f"{SPEC_BASE_URL}/server/prompts#error-handling",
474501
behavior="prompts/get for an unknown prompt name returns a JSON-RPC error.",
475502
),
503+
"prompts:get:missing-arguments": Requirement(
504+
source=f"{SPEC_BASE_URL}/server/prompts#error-handling",
505+
behavior="prompts/get with a required argument missing returns a JSON-RPC error.",
506+
divergence=Divergence(
507+
note=(
508+
"The spec says missing required arguments are answered with -32602 Invalid params; "
509+
"MCPServer's prompt renderer raises a plain ValueError before the prompt function runs, "
510+
"which the low-level server converts to error code 0 with the exception text as the message."
511+
),
512+
),
513+
),
476514
# ═══════════════════════════════════════════════════════════════════════════
477515
# Sampling (server → client)
478516
# ═══════════════════════════════════════════════════════════════════════════
@@ -487,7 +525,7 @@ class Requirement:
487525
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
488526
behavior=(
489527
"The sampling parameters supplied by the server (messages, maxTokens, systemPrompt, "
490-
"modelPreferences, temperature, stopSequences) reach the client callback intact."
528+
"modelPreferences, temperature, stopSequences, includeContext) reach the client callback intact."
491529
),
492530
),
493531
"sampling:create-message:image-content": Requirement(
@@ -501,6 +539,13 @@ class Requirement:
501539
"by the server before anything reaches the wire, with an Invalid params error."
502540
),
503541
),
542+
"sampling:create-message:tools:message-constraints": Requirement(
543+
source=f"{SPEC_BASE_URL}/client/sampling#message-content-constraints",
544+
behavior=(
545+
"A sampling request whose messages violate the tool_use/tool_result pairing rules is rejected "
546+
"by the server-side validator before anything reaches the wire."
547+
),
548+
),
504549
"sampling:create-message:tools:round-trip": Requirement(
505550
source=f"{SPEC_BASE_URL}/client/sampling#sampling-with-tools",
506551
behavior=(
@@ -587,6 +632,17 @@ class Requirement:
587632
),
588633
),
589634
),
635+
"elicitation:url:not-supported": Requirement(
636+
source=f"{SPEC_BASE_URL}/client/elicitation#capabilities",
637+
behavior=(
638+
"A URL-mode elicitation to a client that declared only form-mode support is rejected with an "
639+
"Invalid params error."
640+
),
641+
deferred=(
642+
"Not expressible through the public API: a Client with an elicitation callback always declares "
643+
"both the form and url sub-capabilities, so a form-only client cannot be constructed."
644+
),
645+
),
590646
# ═══════════════════════════════════════════════════════════════════════════
591647
# Roots (server → client)
592648
# ═══════════════════════════════════════════════════════════════════════════
@@ -614,6 +670,10 @@ class Requirement:
614670
),
615671
),
616672
),
673+
"roots:list:client-error": Requirement(
674+
source=f"{SPEC_BASE_URL}/client/roots#error-handling",
675+
behavior="A roots callback that answers with an error surfaces to the requesting handler as an MCPError.",
676+
),
617677
"roots:list-changed": Requirement(
618678
source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",
619679
behavior="A roots/list_changed notification sent by the client is delivered to the server's handler.",
@@ -671,6 +731,22 @@ class Requirement:
671731
"tests/shared/test_streamable_http.py."
672732
),
673733
),
734+
"transport:streamable-http:resumability": Requirement(
735+
source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
736+
behavior="A client that reconnects with Last-Event-ID receives the events it missed.",
737+
deferred=(
738+
"Replay requires dropping and re-establishing the SSE connection, which the in-process ASGI "
739+
"client cannot express. Covered over a real socket by tests/shared/test_streamable_http.py."
740+
),
741+
),
742+
"transport:streamable-http:origin-validation": Requirement(
743+
source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
744+
behavior="Requests with a disallowed Origin or Host header are rejected before reaching the session.",
745+
deferred=(
746+
"The in-process fixture disables DNS-rebinding protection because no network attack surface "
747+
"exists in-process. Covered by tests/server/test_streamable_http_security.py."
748+
),
749+
),
674750
"transport:stdio": Requirement(
675751
source=f"{SPEC_BASE_URL}/basic/transports#stdio",
676752
behavior="The interaction round trip works over a stdio subprocess.",

tests/interaction/lowlevel/test_initialize.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Initialization handshake against the low-level Server, driven through the public Client API.
22
3-
The last two tests drive a bare ClientSession over an InMemoryTransport instead: Client always
3+
The later tests drive a bare ClientSession over an InMemoryTransport instead: Client always
44
performs the full handshake with the latest protocol version, so skipping initialization or
5-
requesting a different version can only be expressed one level down.
5+
requesting a different version can only be expressed one level down. The final test goes one step
6+
further and plays the server's side of the wire by hand, because no real Server can be made to
7+
answer initialize with an unsupported protocol version.
68
"""
79

810
import anyio
@@ -14,6 +16,8 @@
1416
from mcp.client._memory import InMemoryTransport
1517
from mcp.client.client import Client
1618
from mcp.server import Server, ServerRequestContext
19+
from mcp.shared.memory import create_client_server_memory_streams
20+
from mcp.shared.message import SessionMessage
1721
from mcp.types import (
1822
INVALID_PARAMS,
1923
CallToolResult,
@@ -26,6 +30,8 @@
2630
InitializeRequest,
2731
InitializeRequestParams,
2832
InitializeResult,
33+
JSONRPCRequest,
34+
JSONRPCResponse,
2935
ListToolsRequest,
3036
ListToolsResult,
3137
LoggingCapability,
@@ -195,10 +201,11 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
195201
for name, value in (
196202
("sampling", capabilities.sampling),
197203
("elicitation", capabilities.elicitation),
198-
("roots", capabilities.roots),
199204
)
200205
if value is not None
201206
]
207+
if capabilities.roots is not None:
208+
declared.append(f"roots(list_changed={capabilities.roots.list_changed})")
202209
return CallToolResult(content=[TextContent(text=",".join(declared) or "none")])
203210

204211
async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
@@ -213,7 +220,7 @@ async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
213220

214221
async with Client(server, list_roots_callback=list_roots) as client:
215222
result = await client.call_tool("abilities", {})
216-
assert result == snapshot(CallToolResult(content=[TextContent(text="roots")]))
223+
assert result == snapshot(CallToolResult(content=[TextContent(text="roots(list_changed=True)")]))
217224

218225

219226
@requirement("lifecycle:requests-before-initialized")
@@ -275,3 +282,48 @@ def initialize_request(protocol_version: str) -> InitializeRequest:
275282
with anyio.fail_after(5):
276283
result = await session.send_request(initialize_request("1999-01-01"), InitializeResult)
277284
assert result.protocol_version == snapshot("2025-11-25")
285+
286+
287+
@requirement("lifecycle:initialize:protocol-version:client-rejects")
288+
async def test_unsupported_server_protocol_version_fails_initialization() -> None:
289+
"""An initialize response carrying a protocol version the client does not support fails initialization.
290+
291+
A real Server only ever answers with a version it supports, so this test alone plays the
292+
server's side of the wire by hand: it reads the initialize request off the raw stream and
293+
answers it with a hand-built result. Reserve this pattern for behaviour no real server can
294+
be made to produce.
295+
"""
296+
async with create_client_server_memory_streams() as (client_streams, server_streams):
297+
client_read, client_write = client_streams
298+
server_read, server_write = server_streams
299+
300+
async def scripted_server() -> None:
301+
message = await server_read.receive()
302+
assert isinstance(message, SessionMessage)
303+
request = message.message
304+
assert isinstance(request, JSONRPCRequest)
305+
assert request.method == "initialize"
306+
result = InitializeResult(
307+
protocol_version="1991-08-06",
308+
capabilities=ServerCapabilities(),
309+
server_info=Implementation(name="relic", version="0.0.1"),
310+
)
311+
await server_write.send(
312+
SessionMessage(
313+
JSONRPCResponse(
314+
jsonrpc="2.0",
315+
id=request.id,
316+
# Serialized exactly as a real server serializes results onto the wire.
317+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
318+
)
319+
)
320+
)
321+
322+
async with anyio.create_task_group() as tg:
323+
tg.start_soon(scripted_server)
324+
async with ClientSession(client_read, client_write) as session:
325+
with anyio.fail_after(5):
326+
with pytest.raises(RuntimeError) as exc_info:
327+
await session.initialize()
328+
329+
assert str(exc_info.value) == snapshot("Unsupported protocol version from the server: 1991-08-06")

tests/interaction/lowlevel/test_prompts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
INVALID_PARAMS,
1111
ErrorData,
1212
GetPromptResult,
13+
Icon,
1314
ListPromptsResult,
1415
Prompt,
1516
PromptArgument,
@@ -35,6 +36,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest
3536
PromptArgument(name="code", description="The code to review.", required=True),
3637
PromptArgument(name="style_guide", description="Optional style guide to apply."),
3738
],
39+
icons=[Icon(src="https://example.com/review.png", mime_type="image/png", sizes=["48x48"])],
3840
),
3941
Prompt(name="daily_standup"),
4042
]
@@ -55,6 +57,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest
5557
PromptArgument(name="code", description="The code to review.", required=True),
5658
PromptArgument(name="style_guide", description="Optional style guide to apply."),
5759
],
60+
icons=[Icon(src="https://example.com/review.png", mime_type="image/png", sizes=["48x48"])],
5861
),
5962
Prompt(name="daily_standup"),
6063
]

tests/interaction/lowlevel/test_resources.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
CallToolResult,
1515
EmptyResult,
1616
ErrorData,
17+
Icon,
1718
ListResourcesResult,
1819
ListResourceTemplatesResult,
1920
ReadResourceResult,
@@ -48,6 +49,7 @@ async def list_resources(
4849
mime_type="text/markdown",
4950
size=1024,
5051
annotations=Annotations(audience=["user", "assistant"], priority=0.8),
52+
icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])],
5153
),
5254
]
5355
)
@@ -69,6 +71,7 @@ async def list_resources(
6971
mime_type="text/markdown",
7072
size=1024,
7173
annotations=Annotations(audience=["user", "assistant"], priority=0.8),
74+
icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])],
7275
),
7376
]
7477
)
@@ -159,6 +162,7 @@ async def list_resource_templates(
159162
title="Service logs",
160163
description="One day of logs for one service.",
161164
mime_type="text/plain",
165+
icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])],
162166
),
163167
]
164168
)
@@ -178,6 +182,7 @@ async def list_resource_templates(
178182
title="Service logs",
179183
description="One day of logs for one service.",
180184
mime_type="text/plain",
185+
icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])],
181186
),
182187
]
183188
)

tests/interaction/lowlevel/test_roots.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from mcp.client import ClientRequestContext
1010
from mcp.client.client import Client
1111
from mcp.server import Server, ServerRequestContext
12-
from mcp.types import CallToolResult, ListRootsResult, Root, TextContent
12+
from mcp.types import INTERNAL_ERROR, CallToolResult, ErrorData, ListRootsResult, Root, TextContent
1313
from tests.interaction._requirements import requirement
1414

1515
pytestmark = pytest.mark.anyio
@@ -107,6 +107,37 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
107107
assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: List roots not supported")]))
108108

109109

110+
@requirement("roots:list:client-error")
111+
async def test_list_roots_callback_error_surfaces_to_the_handler() -> None:
112+
"""A roots callback that answers with an error fails the roots/list request with that exact error.
113+
114+
The callback's code and message reach the requesting handler verbatim as an MCPError.
115+
"""
116+
117+
async def list_tools(
118+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
119+
) -> types.ListToolsResult:
120+
return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
121+
122+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
123+
assert params.name == "show_roots"
124+
try:
125+
await ctx.session.list_roots()
126+
except MCPError as exc:
127+
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
128+
raise NotImplementedError # the callback always answers with an error
129+
130+
server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
131+
132+
async def list_roots(context: ClientRequestContext) -> ErrorData:
133+
return ErrorData(code=INTERNAL_ERROR, message="roots provider crashed")
134+
135+
async with Client(server, list_roots_callback=list_roots) as client:
136+
result = await client.call_tool("show_roots", {})
137+
138+
assert result == snapshot(CallToolResult(content=[TextContent(text="-32603: roots provider crashed")]))
139+
140+
110141
@requirement("roots:list-changed")
111142
async def test_roots_list_changed_reaches_server_handler() -> None:
112143
"""A roots/list_changed notification from the client is delivered to the server's handler.

0 commit comments

Comments
 (0)