Skip to content

Commit d6c9b63

Browse files
committed
test: add lifecycle edge cases, concurrency, and behaviour-gap interaction tests
Adds ClientSession-level tests for pre-initialization request rejection and protocol version negotiation, a proof that concurrent tool calls are dispatched simultaneously and answered independently, and tests pinning three behaviour gaps: tool-set mutations send no list_changed notification, logging/setLevel is not supported by MCPServer and no level filtering exists, and tool-enabled sampling is rejected because the high-level client cannot declare the sampling.tools capability.
1 parent d4a3558 commit d6c9b63

6 files changed

Lines changed: 330 additions & 5 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ class Requirement:
9595
"(sampling, elicitation, roots)."
9696
),
9797
),
98+
"lifecycle:initialize:protocol-version": Requirement(
99+
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
100+
behavior=(
101+
"The server echoes a requested protocol version it supports, and answers an unsupported "
102+
"requested version with its own latest supported version rather than an error."
103+
),
104+
),
105+
"lifecycle:requests-before-initialized": Requirement(
106+
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
107+
behavior="A request sent before the initialization handshake completes is rejected with an error.",
108+
),
98109
# ═══════════════════════════════════════════════════════════════════════════
99110
# Cancellation
100111
# ═══════════════════════════════════════════════════════════════════════════
@@ -279,6 +290,13 @@ class Requirement:
279290
source=f"{SPEC_BASE_URL}/server/tools#error-handling",
280291
behavior="tools/call for a name the server does not recognise returns a JSON-RPC error.",
281292
),
293+
"tools:call:concurrent": Requirement(
294+
source=f"{SPEC_BASE_URL}/basic#requests",
295+
behavior=(
296+
"Multiple tool calls in flight on one session are dispatched concurrently, and each caller "
297+
"receives the response to its own request."
298+
),
299+
),
282300
"tools:call:invalid-arguments": Requirement(
283301
source=f"{SPEC_BASE_URL}/server/tools#error-handling",
284302
behavior=(
@@ -326,6 +344,21 @@ class Requirement:
326344
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
327345
behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.",
328346
),
347+
"logging:set-level:filtering": Requirement(
348+
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
349+
behavior=(
350+
"MCPServer registers no logging/setLevel handler (the request is rejected with method-not-found) "
351+
"and log messages are delivered at every severity regardless of any requested level."
352+
),
353+
divergence=Divergence(
354+
note=(
355+
"The spec says servers SHOULD only send log messages at or above the level the client "
356+
"configured via logging/setLevel. Neither MCPServer (which rejects the request outright) "
357+
"nor the low-level Server (which leaves the handler entirely to the author) implements "
358+
"any filtering."
359+
),
360+
),
361+
),
329362
# ═══════════════════════════════════════════════════════════════════════════
330363
# Resources
331364
# ═══════════════════════════════════════════════════════════════════════════
@@ -426,6 +459,25 @@ class Requirement:
426459
source=f"{SPEC_BASE_URL}/client/sampling#message-content",
427460
behavior="Sampling messages can carry image content: base64 data with a mimeType.",
428461
),
462+
"sampling:create-message:tools:not-supported": Requirement(
463+
source=f"{SPEC_BASE_URL}/client/sampling#capabilities",
464+
behavior=(
465+
"A tool-enabled sampling request to a client that did not declare sampling.tools is rejected "
466+
"by the server before anything reaches the wire, with an Invalid params error."
467+
),
468+
),
469+
"sampling:create-message:tools:round-trip": Requirement(
470+
source=f"{SPEC_BASE_URL}/client/sampling#sampling-with-tools",
471+
behavior=(
472+
"A sampling request carrying tools and toolChoice reaches the client, and a tool_use response "
473+
"with a toolUse stop reason returns to the requesting handler."
474+
),
475+
deferred=(
476+
"Not expressible through the public API: Client does not expose ClientSession's "
477+
"sampling_capabilities parameter, so a client can never declare sampling.tools and the "
478+
"server-side validator rejects every tool-enabled request before it is sent."
479+
),
480+
),
429481
"sampling:create-message:client-error": Requirement(
430482
source=f"{SPEC_BASE_URL}/client/sampling#error-handling",
431483
behavior="A sampling callback that returns an error is surfaced to the requesting handler as an MCPError.",
@@ -599,6 +651,19 @@ class Requirement:
599651
source="sdk",
600652
behavior="Context.read_resource reads a resource registered on the same server from inside a tool.",
601653
),
654+
"mcpserver:tools:list-changed-on-mutation": Requirement(
655+
source="sdk",
656+
behavior=(
657+
"Adding or removing a tool on a running server changes what tools/list returns but sends no "
658+
"notification to connected clients."
659+
),
660+
divergence=Divergence(
661+
note=(
662+
"The spec provides notifications/tools/list_changed for exactly this case; MCPServer never "
663+
"sends it, so a connected client cannot learn that the tool set changed without polling."
664+
),
665+
),
666+
),
602667
"mcpserver:tools:handler-exception": Requirement(
603668
source="sdk",
604669
behavior=(

tests/interaction/lowlevel/test_initialize.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
1-
"""Initialization handshake against the low-level Server, driven through the public Client API."""
1+
"""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
4+
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.
6+
"""
7+
8+
import anyio
39
import pytest
410
from inline_snapshot import snapshot
511

6-
from mcp import types
7-
from mcp.client import ClientRequestContext
12+
from mcp import MCPError, types
13+
from mcp.client import ClientRequestContext, ClientSession
14+
from mcp.client._memory import InMemoryTransport
815
from mcp.client.client import Client
916
from mcp.server import Server, ServerRequestContext
1017
from mcp.types import (
18+
INVALID_PARAMS,
1119
CallToolResult,
20+
ClientCapabilities,
1221
CompletionsCapability,
22+
EmptyResult,
23+
ErrorData,
1324
Icon,
1425
Implementation,
26+
InitializeRequest,
27+
InitializeRequestParams,
28+
InitializeResult,
29+
ListToolsRequest,
30+
ListToolsResult,
1531
LoggingCapability,
1632
PromptsCapability,
1733
ResourcesCapability,
@@ -198,3 +214,64 @@ async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
198214
async with Client(server, list_roots_callback=list_roots) as client:
199215
result = await client.call_tool("abilities", {})
200216
assert result == snapshot(CallToolResult(content=[TextContent(text="roots")]))
217+
218+
219+
@requirement("lifecycle:requests-before-initialized")
220+
async def test_request_before_initialization_is_rejected() -> None:
221+
"""A feature request sent before the handshake completes is rejected; ping is exempt.
222+
223+
Client always initializes on entry, so this drives a bare ClientSession that never sends
224+
initialize. The server's stated reason for the rejection never reaches the client: the error
225+
is reported as a generic invalid-params failure.
226+
"""
227+
228+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
229+
"""Registered so the request is routed to a real handler; never reached."""
230+
raise NotImplementedError
231+
232+
server = Server("strict", on_list_tools=list_tools)
233+
234+
async with InMemoryTransport(server) as (read_stream, write_stream):
235+
async with ClientSession(read_stream, write_stream) as session:
236+
with anyio.fail_after(5):
237+
with pytest.raises(MCPError) as exc_info:
238+
await session.send_request(ListToolsRequest(), ListToolsResult)
239+
240+
# Ping is explicitly permitted before initialization completes.
241+
pong = await session.send_ping()
242+
243+
assert exc_info.value.error == snapshot(
244+
ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
245+
)
246+
assert pong == snapshot(EmptyResult())
247+
248+
249+
@requirement("lifecycle:initialize:protocol-version")
250+
async def test_initialize_negotiates_protocol_version() -> None:
251+
"""The server echoes a supported requested version and answers an unsupported one with its latest.
252+
253+
Client always requests the latest version, so each half hand-builds an InitializeRequest on a
254+
bare ClientSession to control the requested version.
255+
"""
256+
server = Server("negotiator")
257+
258+
def initialize_request(protocol_version: str) -> InitializeRequest:
259+
return InitializeRequest(
260+
params=InitializeRequestParams(
261+
protocol_version=protocol_version,
262+
capabilities=ClientCapabilities(),
263+
client_info=Implementation(name="time-traveller", version="0.0.1"),
264+
)
265+
)
266+
267+
async with InMemoryTransport(server) as (read_stream, write_stream):
268+
async with ClientSession(read_stream, write_stream) as session:
269+
with anyio.fail_after(5):
270+
result = await session.send_request(initialize_request("2025-03-26"), InitializeResult)
271+
assert result.protocol_version == snapshot("2025-03-26")
272+
273+
async with InMemoryTransport(server) as (read_stream, write_stream):
274+
async with ClientSession(read_stream, write_stream) as session:
275+
with anyio.fail_after(5):
276+
result = await session.send_request(initialize_request("1999-01-01"), InitializeResult)
277+
assert result.protocol_version == snapshot("2025-11-25")

tests/interaction/lowlevel/test_sampling.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,44 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
289289
result = await client.call_tool("ask_model", {})
290290

291291
assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: Sampling not supported")]))
292+
293+
294+
@requirement("sampling:create-message:tools:not-supported")
295+
async def test_create_message_with_tools_is_rejected_for_unsupporting_client() -> None:
296+
"""A tool-enabled sampling request to a client that has not declared sampling.tools never leaves the server.
297+
298+
The client supports plain sampling but cannot declare the tools sub-capability (Client does not
299+
expose it), so the server-side validator rejects the request before anything reaches the wire.
300+
"""
301+
302+
async def list_tools(
303+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
304+
) -> types.ListToolsResult:
305+
return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
306+
307+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
308+
assert params.name == "ask_model"
309+
try:
310+
await ctx.session.create_message(
311+
messages=[SamplingMessage(role="user", content=TextContent(text="What is the weather?"))],
312+
max_tokens=100,
313+
tools=[types.Tool(name="get_weather", input_schema={"type": "object"})],
314+
)
315+
except MCPError as exc:
316+
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
317+
raise NotImplementedError # the validator rejects every tool-enabled request
318+
319+
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
320+
321+
async def sampling_callback(
322+
context: ClientRequestContext, params: CreateMessageRequestParams
323+
) -> CreateMessageResult:
324+
"""Declares the plain sampling capability; never invoked because the request is rejected first."""
325+
raise NotImplementedError
326+
327+
async with Client(server, sampling_callback=sampling_callback) as client:
328+
result = await client.call_tool("ask_model", {})
329+
330+
assert result == snapshot(
331+
CallToolResult(content=[TextContent(text="-32602: Client does not support sampling tools capability")])
332+
)

tests/interaction/lowlevel/test_tools.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tool interactions against the low-level Server, driven through the public Client API."""
22

3+
import anyio
34
import pytest
45
from inline_snapshot import snapshot
56

@@ -264,3 +265,55 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
264265
result = await client.call_tool("sum", {})
265266

266267
assert result == snapshot(CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5}))
268+
269+
270+
@requirement("tools:call:concurrent")
271+
async def test_concurrent_tool_calls_complete_independently() -> None:
272+
"""Two tool calls in flight at once run concurrently and each caller gets its own answer.
273+
274+
Both handlers are held on a shared event after signalling that they have started, and the test
275+
only releases them once both signals have arrived -- a server that processed requests
276+
sequentially would never start the second handler and the test would time out instead.
277+
"""
278+
started: list[str] = []
279+
started_events = {"first": anyio.Event(), "second": anyio.Event()}
280+
release = anyio.Event()
281+
results: dict[str, CallToolResult] = {}
282+
283+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
284+
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
285+
286+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
287+
assert params.name == "echo"
288+
assert params.arguments is not None
289+
tag = params.arguments["tag"]
290+
assert isinstance(tag, str)
291+
started.append(tag)
292+
started_events[tag].set()
293+
await release.wait()
294+
return CallToolResult(content=[TextContent(text=tag)])
295+
296+
server = Server("echoer", on_list_tools=list_tools, on_call_tool=call_tool)
297+
298+
async with Client(server) as client:
299+
with anyio.fail_after(5):
300+
async with anyio.create_task_group() as task_group:
301+
302+
async def call_and_record(tag: str) -> None:
303+
results[tag] = await client.call_tool("echo", {"tag": tag})
304+
305+
task_group.start_soon(call_and_record, "first")
306+
task_group.start_soon(call_and_record, "second")
307+
308+
# Both handlers are running at the same time before either is allowed to finish.
309+
await started_events["first"].wait()
310+
await started_events["second"].wait()
311+
release.set()
312+
313+
assert sorted(started) == ["first", "second"]
314+
assert results == snapshot(
315+
{
316+
"first": CallToolResult(content=[TextContent(text="first")]),
317+
"second": CallToolResult(content=[TextContent(text="second")]),
318+
}
319+
)

tests/interaction/mcpserver/test_context.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44
from inline_snapshot import snapshot
55
from pydantic import BaseModel
66

7+
from mcp import MCPError
78
from mcp.client import ClientRequestContext
89
from mcp.client.client import Client
910
from mcp.server.elicitation import AcceptedElicitation
1011
from mcp.server.mcpserver import Context, MCPServer
1112
from mcp.types import (
13+
METHOD_NOT_FOUND,
1214
CallToolResult,
1315
ElicitRequestFormParams,
1416
ElicitRequestParams,
1517
ElicitResult,
18+
ErrorData,
1619
LoggingMessageNotificationParams,
1720
TextContent,
1821
)
@@ -163,3 +166,39 @@ async def show_config(ctx: Context) -> str:
163166
structured_content={"result": "text/plain: 'theme = dark'"},
164167
)
165168
)
169+
170+
171+
@requirement("logging:set-level:filtering")
172+
async def test_set_logging_level_is_rejected_and_messages_are_never_filtered() -> None:
173+
"""MCPServer does not support logging/setLevel, so log messages are never filtered by severity.
174+
175+
The request is rejected with METHOD_NOT_FOUND because MCPServer registers no handler for it,
176+
and every message a tool emits is delivered regardless of level. The spec says the server
177+
should only send messages at or above the configured level; with no way to configure one,
178+
everything is sent.
179+
"""
180+
received: list[LoggingMessageNotificationParams] = []
181+
mcp = MCPServer("unfilterable")
182+
183+
@mcp.tool()
184+
async def chatter(ctx: Context) -> str:
185+
await ctx.debug("noise")
186+
await ctx.error("signal")
187+
return "done"
188+
189+
async def collect(params: LoggingMessageNotificationParams) -> None:
190+
received.append(params)
191+
192+
async with Client(mcp, logging_callback=collect) as client:
193+
with pytest.raises(MCPError) as exc_info:
194+
await client.set_logging_level("error")
195+
196+
await client.call_tool("chatter", {})
197+
198+
assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
199+
assert received == snapshot(
200+
[
201+
LoggingMessageNotificationParams(level="debug", data="noise"),
202+
LoggingMessageNotificationParams(level="error", data="signal"),
203+
]
204+
)

0 commit comments

Comments
 (0)