diff --git a/pyproject.toml b/pyproject.toml
index d88869da1c..b98e64a487 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -193,6 +193,9 @@ strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" }
[tool.pytest.ini_options]
log_cli = true
xfail_strict = true
+markers = [
+ "requirement(id): links a test to the entry in tests/interaction/_requirements.py it exercises",
+]
addopts = """
--color=yes
--capture=fd
diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py
index 34d6a360fa..b33fea4052 100644
--- a/src/mcp/client/client.py
+++ b/src/mcp/client/client.py
@@ -305,4 +305,4 @@ async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta
async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed."""
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
- await self.session.send_roots_list_changed() # pragma: no cover
+ await self.session.send_roots_list_changed()
diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py
index 0cea454a77..cf92696682 100644
--- a/src/mcp/client/session.py
+++ b/src/mcp/client/session.py
@@ -74,7 +74,7 @@ async def _default_elicitation_callback(
context: RequestContext[ClientSession],
params: types.ElicitRequestParams,
) -> types.ElicitResult | types.ErrorData:
- return types.ErrorData( # pragma: no cover
+ return types.ErrorData(
code=types.INVALID_REQUEST,
message="Elicitation not supported",
)
@@ -408,7 +408,7 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
return result
- async def send_roots_list_changed(self) -> None: # pragma: no cover
+ async def send_roots_list_changed(self) -> None:
"""Send a roots/list_changed notification."""
await self.send_notification(types.RootsListChangedNotification())
@@ -449,7 +449,7 @@ async def _received_request(self, responder: RequestResponder[types.ServerReques
client_response = ClientResponse.validate_python(response)
await responder.respond(client_response)
- case types.PingRequest(): # pragma: no cover
+ case types.PingRequest():
with responder:
return await responder.respond(types.EmptyResult())
diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py
index 59de0ace45..d1a15120af 100644
--- a/src/mcp/server/lowlevel/server.py
+++ b/src/mcp/server/lowlevel/server.py
@@ -349,12 +349,12 @@ def session_manager(self) -> StreamableHTTPSessionManager:
Raises:
RuntimeError: If called before streamable_http_app() has been called.
"""
- if self._session_manager is None: # pragma: no cover
- raise RuntimeError(
+ if self._session_manager is None:
+ raise RuntimeError( # pragma: no cover
"Session manager can only be accessed after calling streamable_http_app(). "
"The session manager is created lazily to avoid unnecessary initialization."
)
- return self._session_manager # pragma: no cover
+ return self._session_manager
async def run(
self,
@@ -513,7 +513,7 @@ async def _handle_request(
if raise_exceptions: # pragma: no cover
raise err
response = types.ErrorData(code=0, message=str(err))
- else: # pragma: no cover
+ else:
response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found")
if isinstance(response, types.ErrorData) and span is not None:
diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py
index e87388eee9..d4344daa92 100644
--- a/src/mcp/server/mcpserver/context.py
+++ b/src/mcp/server/mcpserver/context.py
@@ -94,7 +94,7 @@ async def report_progress(self, progress: float, total: float | None = None, mes
"""
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None
- if progress_token is None: # pragma: no cover
+ if progress_token is None:
return
await self.request_context.session.send_progress_notification(
diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py
index b3471163b7..ec2365810e 100644
--- a/src/mcp/server/mcpserver/server.py
+++ b/src/mcp/server/mcpserver/server.py
@@ -244,7 +244,7 @@ def session_manager(self) -> StreamableHTTPSessionManager:
Raises:
RuntimeError: If called before streamable_http_app() has been called.
"""
- return self._lowlevel_server.session_manager # pragma: no cover
+ return self._lowlevel_server.session_manager
@overload
def run(self, transport: Literal["stdio"] = ...) -> None: ...
diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py
index 20b640527a..fc2f97a9cb 100644
--- a/src/mcp/server/session.py
+++ b/src/mcp/server/session.py
@@ -223,7 +223,7 @@ async def send_log_message(
related_request_id,
)
- async def send_resource_updated(self, uri: str | AnyUrl) -> None: # pragma: no cover
+ async def send_resource_updated(self, uri: str | AnyUrl) -> None:
"""Send a resource updated notification."""
await self.send_notification(
types.ResourceUpdatedNotification(
@@ -447,7 +447,7 @@ async def elicit_url(
metadata=ServerMessageMetadata(related_request_id=related_request_id),
)
- async def send_ping(self) -> types.EmptyResult: # pragma: no cover
+ async def send_ping(self) -> types.EmptyResult:
"""Send a ping request."""
return await self.send_request(
types.PingRequest(),
@@ -479,11 +479,11 @@ async def send_resource_list_changed(self) -> None:
"""Send a resource list changed notification."""
await self.send_notification(types.ResourceListChangedNotification())
- async def send_tool_list_changed(self) -> None: # pragma: no cover
+ async def send_tool_list_changed(self) -> None:
"""Send a tool list changed notification."""
await self.send_notification(types.ToolListChangedNotification())
- async def send_prompt_list_changed(self) -> None: # pragma: no cover
+ async def send_prompt_list_changed(self) -> None:
"""Send a prompt list changed notification."""
await self.send_notification(types.PromptListChangedNotification())
diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py
index 48192ff612..3e5261896b 100644
--- a/src/mcp/server/sse.py
+++ b/src/mcp/server/sse.py
@@ -116,15 +116,15 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
@asynccontextmanager
- async def connect_sse(self, scope: Scope, receive: Receive, send: Send): # pragma: no cover
- if scope["type"] != "http":
+ async def connect_sse(self, scope: Scope, receive: Receive, send: Send):
+ if scope["type"] != "http": # pragma: no cover
logger.error("connect_sse received non-HTTP request")
raise ValueError("connect_sse can only handle HTTP requests")
# Validate request headers for DNS rebinding protection
request = Request(scope, receive)
error_response = await self._security.validate_request(request, is_post=False)
- if error_response:
+ if error_response: # pragma: no cover
await error_response(scope, receive, send)
raise ValueError("Request validation failed")
@@ -190,13 +190,13 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
logger.debug("Yielding read and write streams")
yield (read_stream, write_stream)
- async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
+ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None:
logger.debug("Handling POST message")
request = Request(scope, receive)
# Validate request headers for DNS rebinding protection
error_response = await self._security.validate_request(request, is_post=True)
- if error_response:
+ if error_response: # pragma: no cover
return await error_response(scope, receive, send)
session_id_param = request.query_params.get("session_id")
@@ -225,7 +225,7 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send)
try:
message = types.jsonrpc_message_adapter.validate_json(body, by_name=False)
logger.debug(f"Validated client message: {message}")
- except ValidationError as err:
+ except ValidationError as err: # pragma: no cover
logger.exception("Failed to parse message")
response = Response("Could not parse message", status_code=400)
await response(scope, receive, send)
diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py
index f14201857c..a4cb5af03a 100644
--- a/src/mcp/server/streamable_http.py
+++ b/src/mcp/server/streamable_http.py
@@ -374,7 +374,7 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
await error_response(scope, receive, send)
return
- if self._terminated: # pragma: no cover
+ if self._terminated: # pragma: lax no cover
# If the session has been terminated, return 404 Not Found
response = self._create_error_response(
"Not Found: Session has been terminated",
@@ -635,7 +635,7 @@ async def sse_writer(): # pragma: lax no cover
finally:
await sse_stream_reader.aclose()
- except Exception as err: # pragma: no cover
+ except Exception as err: # pragma: lax no cover
logger.exception("Error handling POST request")
response = self._create_error_response(
f"Error handling POST request: {err}",
@@ -818,7 +818,7 @@ async def _validate_request_headers(self, request: Request, send: Send) -> bool:
async def _validate_session(self, request: Request, send: Send) -> bool:
"""Validate the session ID in the request."""
- if not self.mcp_session_id: # pragma: no cover
+ if not self.mcp_session_id:
# If we're not using session IDs, return True
return True
@@ -993,7 +993,7 @@ async def message_router():
if isinstance(message, JSONRPCResponse | JSONRPCError) and message.id is not None:
target_request_id = str(message.id)
# Extract related_request_id from meta if it exists
- elif ( # pragma: no cover
+ elif (
session_message.metadata is not None
and isinstance(
session_message.metadata,
diff --git a/tests/interaction/README.md b/tests/interaction/README.md
new file mode 100644
index 0000000000..df8f331596
--- /dev/null
+++ b/tests/interaction/README.md
@@ -0,0 +1,188 @@
+# Interaction-model test suite
+
+This suite enumerates the MCP interaction model as end-to-end tests: one test per piece of
+functionality, asserting the full client↔server round trip through the public API. It exists to
+pin the SDK's observable behaviour — every request type, every notification direction, every
+error plane — so that internal rewrites of the send/receive path can be proven equivalent by
+running the suite before and after.
+
+```bash
+uv run --frozen pytest tests/interaction/
+```
+
+The whole suite is in-memory and event-driven; it runs in about a second.
+
+## Ground rules
+
+- **Public API only.** Tests drive a `Client` connected to a `Server` or `MCPServer`. Nothing
+ reaches into session internals, so the suite keeps working when those internals change.
+ `ClientSession` is used directly only for behaviours `Client` cannot express (skipping
+ initialization, requesting a non-default protocol version).
+- **Pin current behaviour.** Every test passes against the current `main`, including behaviours
+ that diverge from the specification. A failing or xfailed test proves nothing about whether a
+ rewrite preserved behaviour; a passing test that pins the wrong output exactly does. Known
+ divergences are recorded as data on the requirement (see below), not worked around in the test.
+- **Spec-mandated assertions, not implementation quirks.** Error *codes* are asserted against
+ the constants in `mcp.types`; error *message strings* are pinned only where they are the
+ SDK's own deliberate output.
+- **No sleeps, no real I/O.** Concurrency is coordinated with `anyio.Event`; every wait that
+ could hang is bounded by `anyio.fail_after(5)`. The streamable HTTP tests drive the Starlette
+ app in-process through the suite's streaming ASGI bridge (`transports/_bridge.py`), which
+ delivers each response chunk as the server produces it — full duplex, but still no sockets,
+ threads, or subprocesses anywhere.
+
+## Layout
+
+```text
+tests/interaction/
+ _requirements.py the requirements manifest (see below)
+ _helpers.py shared type aliases + the wire-recording transport
+ _connect.py the transport-parametrized connection factories
+ conftest.py the connect fixture (the transport matrix)
+ test_coverage.py enforces the manifest ↔ test contract
+ lowlevel/ one file per feature area, against the low-level Server
+ mcpserver/ the same feature areas in MCPServer's natural idiom
+ transports/ behaviour specific to one transport (modes, streams, framing)
+```
+
+The two server APIs produce genuinely different wire output for the same conceptual feature
+(`MCPServer` generates schemas, converts exceptions to `isError` results, attaches structured
+content), so they get parallel directories with mirrored file names rather than one parametrized
+test body — each directory pins its flavour's true output exactly.
+
+### The transport matrix
+
+Transport-agnostic tests take the `connect` fixture instead of constructing `Client(server)`
+directly, and therefore run once per transport: over the in-memory transport and over the
+server's real streamable HTTP app driven in process through the streaming bridge. A test connects
+the same way in either case — `async with connect(server, ...) as client:` — and asserts the same
+output, because the transport is not supposed to change observable behaviour. Tests that are tied
+to one transport do not use the fixture: the wire-recording tests (their seam is the in-memory
+stream pair), the bare-`ClientSession` lifecycle tests, the real-clock timeout tests (the timeout
+machinery is transport-independent and must not race transport latency), and everything under
+`transports/`, which pins behaviour only observable on that transport.
+
+## The requirements manifest
+
+`_requirements.py` maps every behaviour the suite covers to the reason it must hold:
+
+```python
+"tools:call:content:text": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#text-content",
+ behavior="tools/call delivers arguments to the tool handler and returns its text content.",
+),
+```
+
+- **`source`** is a deep link into the MCP specification for externally mandated behaviour,
+ the literal string `"sdk"` for behaviour the SDK chose where the spec is silent, or
+ `"issue:#n"` for a regression lock.
+- **`behavior`** describes the *required* behaviour — what the specification (or the SDK's own
+ contract) says should happen. Tests always pin the SDK's current behaviour; where that falls
+ short of `behavior`, the gap is recorded as data rather than hidden in the test.
+- **`divergence`** records that gap for entries whose tests pin the divergent current behaviour.
+- **`deferred`** marks a behaviour that is tracked but not yet covered by a test in this suite.
+ The reason names the covering tests elsewhere in the repo, starts with "Not implemented in the
+ SDK" for genuine feature gaps, or starts with "Not yet covered here" for tests that are planned.
+- **`transports`** names the transports a behaviour applies to; omitted means transport-independent.
+- **`issue`** carries the tracking link for a recorded gap once one is filed.
+
+Tests link themselves to the manifest with a decorator:
+
+```python
+@requirement("tools:call:content:text")
+async def test_call_tool_returns_text_content() -> None: ...
+```
+
+`test_coverage.py` enforces the contract in both directions: every non-deferred requirement must
+be exercised by at least one test, every deferred requirement by none, and an unknown ID fails at
+import time. A behaviour without a manifest entry cannot be silently half-tested, and a manifest
+entry without a test cannot be silently aspirational.
+
+### The divergence lifecycle
+
+1. A test reveals that the SDK does not do what the spec says. The test pins what the SDK
+ *actually does* and a `Divergence(note=..., issue=...)` goes on the requirement.
+2. When the behaviour is eventually fixed, the pinned test fails. Whoever makes the change finds
+ the divergence note explaining that the old behaviour was a known gap, re-pins the test to the
+ spec-correct output, and deletes the `Divergence`.
+3. An empty divergence list means the SDK is spec-conformant on every behaviour the suite covers.
+
+This is also the triage key for any rewrite: a test that fails on the new code path either has a
+divergence note (the rewrite accidentally fixed a known gap — decide whether to keep the fix) or
+it does not (the rewrite broke something that was correct — fix the rewrite).
+
+### When a new spec revision is released
+
+1. Update `SPEC_REVISION` and walk the new revision's changelog.
+2. For each changed interaction, find its requirements (the IDs use the wire method strings the
+ changelog speaks in), re-audit the tests against the new text, and update `source` links and
+ assertions where behaviour legitimately changed.
+3. New interactions get new requirements and new tests; removed interactions get their
+ requirements deleted along with their tests.
+4. A behaviour that is correct under both revisions needs no change beyond the `source` link.
+
+## Writing a test
+
+The shortest complete example of the conventions:
+
+```python
+@requirement("tools:call:content:text")
+async def test_call_tool_returns_text_content() -> None:
+ """Arguments reach the tool handler; its content comes back as the call result."""
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "add"
+ assert params.arguments is not None
+ return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
+
+ server = Server("adder", on_call_tool=call_tool)
+
+ async with Client(server) as client:
+ result = await client.call_tool("add", {"a": 2, "b": 3})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
+```
+
+- **The server is defined inside the test** (or in a small fixture at the top of the file when
+ several tests genuinely share it). The whole observable behaviour fits on one screen.
+- **Test names are behaviour sentences** — they state the observable outcome, not the feature
+ being poked. Docstrings add the one or two sentences of context a reviewer needs, including
+ whether the assertion is spec-mandated, SDK-defined, or a known divergence.
+- **Handlers assert their dispatch identity first** (`assert params.name == "add"`), proving the
+ request that arrived is the request the test sent.
+- **The result proves the round trip.** Server-side observations travel back to the test through
+ the protocol itself (a tool returns what it saw) or through a closure-captured list; the test
+ asserts after the call returns.
+- **Order within a test**: server handlers → server construction → client callbacks → connect →
+ act → assert. The test reads in the order the conversation happens.
+- A registered handler or tool that a test never invokes gets a `raise NotImplementedError` body
+ so it cannot silently become load-bearing.
+
+### Choosing an assertion
+
+| The property under test is… | Assert with |
+|---|---|
+| the result of a transformation (arguments → output, exception → error result) | `result == snapshot(...)` of the full object, so any field the implementation adds or drops fails the test |
+| pass-through of an opaque value (`_meta`, cursors) | identity against the same variable that was sent — a snapshot of a pass-through value only matches the input because a human checked two literals correspond |
+| an error | `pytest.raises(MCPError)` and a snapshot of `exc.value.error` when the message is the SDK's own; a plain `==` on `.code` against the `mcp.types` constant when it is not |
+| third-party output embedded in a result (validation messages) | the stable prefix only — never pin text that changes with a dependency upgrade |
+
+### Notifications and concurrency
+
+The client's receive loop dispatches each incoming message to completion before reading the next,
+and the in-memory transport delivers everything on one ordered stream. Together these guarantee
+that every notification a server handler emits before its response reaches the client callback
+before the originating request returns — so tests collect notifications into a plain list and
+assert after the call, with no synchronisation. The exceptions:
+
+- a notification not triggered by a request the test is awaiting needs an `anyio.Event` set in
+ the receiving handler and awaited under `anyio.fail_after(5)`;
+- the ordering guarantee does not survive transports that split messages across streams (the
+ streamable HTTP standalone GET stream) — see `transports/test_streamable_http.py`.
+
+### Coverage
+
+CI requires 100% line and branch coverage, including `tests/`, and `strict-no-cover` fails the
+build if a line marked `# pragma: no cover` is ever executed. When a new test starts covering a
+pragma'd line in `src/`, delete the pragma in the same change. Do not add new `# pragma`,
+`# type: ignore`, or `# noqa` comments; restructure instead.
diff --git a/tests/interaction/__init__.py b/tests/interaction/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py
new file mode 100644
index 0000000000..b553477f63
--- /dev/null
+++ b/tests/interaction/_connect.py
@@ -0,0 +1,205 @@
+"""Transport-parametrized connection factories for the interaction suite.
+
+The `connect` fixture (see conftest.py) hands tests one of these factories so the same test body
+runs over each transport without naming any of them: the factory is a drop-in replacement for
+constructing `Client(server, ...)` and yields the connected client. The HTTP factories drive the
+server's real Starlette app through the in-process streaming bridge, so the full transport layer
+(session ids, SSE encoding, session management) runs with no sockets, threads, or subprocesses.
+"""
+
+import gc
+import warnings
+from collections.abc import AsyncIterator
+from contextlib import AbstractAsyncContextManager, asynccontextmanager
+from typing import Protocol
+
+import httpx
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import Response
+from starlette.routing import Mount, Route
+
+from mcp.client.client import Client
+from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
+from mcp.client.sse import sse_client
+from mcp.client.streamable_http import streamable_http_client
+from mcp.server import Server
+from mcp.server.mcpserver import MCPServer
+from mcp.server.sse import SseServerTransport
+from mcp.server.transport_security import TransportSecuritySettings
+from mcp.types import Implementation
+from tests.interaction.transports._bridge import StreamingASGITransport
+
+# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
+_BASE_URL = "http://127.0.0.1:8000"
+
+
+class Connect(Protocol):
+ """Connect a Client to a server over the transport selected by the `connect` fixture.
+
+ Accepts the same keyword arguments as `Client` and yields the connected client.
+ """
+
+ def __call__(
+ self,
+ server: Server | MCPServer,
+ *,
+ read_timeout_seconds: float | None = None,
+ sampling_callback: SamplingFnT | None = None,
+ list_roots_callback: ListRootsFnT | None = None,
+ logging_callback: LoggingFnT | None = None,
+ message_handler: MessageHandlerFnT | None = None,
+ client_info: Implementation | None = None,
+ elicitation_callback: ElicitationFnT | None = None,
+ ) -> AbstractAsyncContextManager[Client]: ...
+
+
+@asynccontextmanager
+async def connect_in_memory(
+ server: Server | MCPServer,
+ *,
+ read_timeout_seconds: float | None = None,
+ sampling_callback: SamplingFnT | None = None,
+ list_roots_callback: ListRootsFnT | None = None,
+ logging_callback: LoggingFnT | None = None,
+ message_handler: MessageHandlerFnT | None = None,
+ client_info: Implementation | None = None,
+ elicitation_callback: ElicitationFnT | None = None,
+) -> AsyncIterator[Client]:
+ """Yield a Client connected to the server over the in-memory transport."""
+ async with Client(
+ server,
+ read_timeout_seconds=read_timeout_seconds,
+ sampling_callback=sampling_callback,
+ list_roots_callback=list_roots_callback,
+ logging_callback=logging_callback,
+ message_handler=message_handler,
+ client_info=client_info,
+ elicitation_callback=elicitation_callback,
+ ) as client:
+ yield client
+
+
+@asynccontextmanager
+async def connect_over_streamable_http(
+ server: Server | MCPServer,
+ *,
+ stateless_http: bool = False,
+ json_response: bool = False,
+ read_timeout_seconds: float | None = None,
+ sampling_callback: SamplingFnT | None = None,
+ list_roots_callback: ListRootsFnT | None = None,
+ logging_callback: LoggingFnT | None = None,
+ message_handler: MessageHandlerFnT | None = None,
+ client_info: Implementation | None = None,
+ elicitation_callback: ElicitationFnT | None = None,
+) -> AsyncIterator[Client]:
+ """Yield a Client connected to the server's streamable HTTP app, entirely in process.
+
+ With the defaults this is the matrix leg (stateful sessions, SSE responses); the
+ transport-specific tests pass `stateless_http` or `json_response` to select the other
+ server modes.
+ """
+ # DNS-rebinding protection validates Host/Origin headers against a real network attack that
+ # cannot exist for an in-process ASGI app; leaving it on would also pull the origin-validation
+ # branch (deliberately uncovered in src) into coverage.
+ app = server.streamable_http_app(
+ stateless_http=stateless_http,
+ json_response=json_response,
+ transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
+ )
+ async with server.session_manager.run():
+ async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=_BASE_URL) as http_client:
+ transport = streamable_http_client(f"{_BASE_URL}/mcp", http_client=http_client)
+ async with Client(
+ transport,
+ read_timeout_seconds=read_timeout_seconds,
+ sampling_callback=sampling_callback,
+ list_roots_callback=list_roots_callback,
+ logging_callback=logging_callback,
+ message_handler=message_handler,
+ client_info=client_info,
+ elicitation_callback=elicitation_callback,
+ ) as client:
+ yield client
+
+
+def build_sse_app(server: Server | MCPServer) -> tuple[Starlette, SseServerTransport]:
+ """Mount a server on a Starlette app exposing the legacy SSE transport at /sse and /messages/.
+
+ `MCPServer.sse_app()` exists but does not expose the underlying `SseServerTransport`, which
+ the SSE-specific tests need; building the app explicitly here gives both server flavours the
+ same routing while keeping that handle.
+ """
+ sse = SseServerTransport(
+ "/messages/", security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False)
+ )
+ lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
+
+ async def handle_sse(request: Request) -> Response:
+ async with sse.connect_sse(request.scope, request.receive, request._send) as (read, write):
+ await lowlevel.run(read, write, lowlevel.create_initialization_options())
+ return Response()
+
+ app = Starlette(
+ routes=[
+ Route("/sse", endpoint=handle_sse, methods=["GET"]),
+ Mount("/messages/", app=sse.handle_post_message),
+ ],
+ )
+ return app, sse
+
+
+@asynccontextmanager
+async def connect_over_sse(
+ server: Server | MCPServer,
+ *,
+ read_timeout_seconds: float | None = None,
+ sampling_callback: SamplingFnT | None = None,
+ list_roots_callback: ListRootsFnT | None = None,
+ logging_callback: LoggingFnT | None = None,
+ message_handler: MessageHandlerFnT | None = None,
+ client_info: Implementation | None = None,
+ elicitation_callback: ElicitationFnT | None = None,
+) -> AsyncIterator[Client]:
+ """Yield a Client connected to the server's legacy SSE transport, entirely in process."""
+ app, _ = build_sse_app(server)
+
+ def httpx_client_factory(
+ headers: dict[str, str] | None = None,
+ timeout: httpx.Timeout | None = None,
+ auth: httpx.Auth | None = None,
+ ) -> httpx.AsyncClient:
+ # The SSE server transport's connect_sse runs the entire MCP session inside the GET
+ # request and only releases its streams after that request observes a disconnect, so the
+ # bridge must let the application drain rather than cancelling at close.
+ return httpx.AsyncClient(
+ transport=StreamingASGITransport(app, cancel_on_close=False),
+ base_url=_BASE_URL,
+ headers=headers,
+ timeout=timeout,
+ auth=auth,
+ )
+
+ transport = sse_client(f"{_BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
+ try:
+ async with Client(
+ transport,
+ read_timeout_seconds=read_timeout_seconds,
+ sampling_callback=sampling_callback,
+ list_roots_callback=list_roots_callback,
+ logging_callback=logging_callback,
+ message_handler=message_handler,
+ client_info=client_info,
+ elicitation_callback=elicitation_callback,
+ ) as client:
+ yield client
+ finally:
+ # SseServerTransport.connect_sse hands its internal SSE-chunk receive stream to
+ # sse_starlette's EventSourceResponse, which never closes it when its task group is
+ # cancelled on disconnect (see notes/findings.md). Collect the orphan here so its
+ # ResourceWarning fires deterministically inside this fixture instead of at an
+ # arbitrary later GC.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", ResourceWarning)
+ gc.collect()
diff --git a/tests/interaction/_helpers.py b/tests/interaction/_helpers.py
new file mode 100644
index 0000000000..25833b0ca5
--- /dev/null
+++ b/tests/interaction/_helpers.py
@@ -0,0 +1,107 @@
+"""Shared helpers for the interaction suite.
+
+Keep this module small: it exists only for (a) types that every test would otherwise have to
+assemble from the SDK's internals to annotate a client callback, and (b) the recording transport
+used by the wire-level tests. Server fixtures and assertion helpers belong in the test that uses
+them.
+"""
+
+from types import TracebackType
+
+import anyio
+from typing_extensions import Self
+
+from mcp.client._transport import ReadStream, Transport, TransportStreams, WriteStream
+from mcp.shared.message import SessionMessage
+from mcp.shared.session import RequestResponder
+from mcp.types import ClientResult, ServerNotification, ServerRequest
+
+# TODO: this union is the parameter type of every client message handler (MessageHandlerFnT),
+# but the SDK does not export a name for it -- writing a correctly-typed handler requires
+# importing RequestResponder from mcp.shared.session and assembling the union by hand. It
+# should be a named, exported alias next to MessageHandlerFnT (like ClientRequestContext is
+# for the request callbacks), at which point this alias can be deleted.
+IncomingMessage = RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
+"""Everything a client message handler can receive."""
+
+
+class _RecordingReadStream:
+ """Delegates to a read stream, appending every received message to a log."""
+
+ def __init__(self, inner: ReadStream[SessionMessage | Exception], log: list[SessionMessage | Exception]) -> None:
+ self._inner = inner
+ self._log = log
+
+ async def receive(self) -> SessionMessage | Exception:
+ item = await self._inner.receive()
+ self._log.append(item)
+ return item
+
+ async def aclose(self) -> None:
+ await self._inner.aclose()
+
+ def __aiter__(self) -> Self:
+ return self
+
+ async def __anext__(self) -> SessionMessage | Exception:
+ try:
+ return await self.receive()
+ except anyio.EndOfStream:
+ raise StopAsyncIteration from None
+
+ async def __aenter__(self) -> Self:
+ return self
+
+ async def __aexit__(
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
+ ) -> bool | None:
+ await self.aclose()
+ return None
+
+
+class _RecordingWriteStream:
+ """Delegates to a write stream, appending every sent message to a log."""
+
+ def __init__(self, inner: WriteStream[SessionMessage], log: list[SessionMessage]) -> None:
+ self._inner = inner
+ self._log = log
+
+ async def send(self, item: SessionMessage, /) -> None:
+ self._log.append(item)
+ await self._inner.send(item)
+
+ async def aclose(self) -> None:
+ await self._inner.aclose()
+
+ async def __aenter__(self) -> Self:
+ return self
+
+ async def __aexit__(
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
+ ) -> bool | None:
+ await self.aclose()
+ return None
+
+
+class RecordingTransport:
+ """Wraps a Transport and records every message crossing the client's transport boundary.
+
+ `sent` holds everything the client wrote towards the server; `received` holds everything the
+ server delivered to the client. The recording sits at the transport seam -- the exact payloads
+ a real transport would serialise -- and never touches the session, so wire-level assertions
+ written against it survive changes to the receive path.
+ """
+
+ def __init__(self, inner: Transport) -> None:
+ self.inner = inner
+ self.sent: list[SessionMessage] = []
+ self.received: list[SessionMessage | Exception] = []
+
+ async def __aenter__(self) -> TransportStreams:
+ read_stream, write_stream = await self.inner.__aenter__()
+ return _RecordingReadStream(read_stream, self.received), _RecordingWriteStream(write_stream, self.sent)
+
+ async def __aexit__(
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
+ ) -> bool | None:
+ return await self.inner.__aexit__(exc_type, exc_val, exc_tb)
diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py
new file mode 100644
index 0000000000..842fe7a199
--- /dev/null
+++ b/tests/interaction/_requirements.py
@@ -0,0 +1,2639 @@
+"""Requirements manifest for the interaction-model test suite.
+
+Every user-facing behaviour the SDK must satisfy, keyed by a stable `:[:]`
+ID. Each entry owns the tests that exercise it: tests declare `@requirement("")` (a test that
+proves several behaviours stacks several decorators) and `test_coverage.py` enforces the contract
+in both directions: every non-deferred requirement has at least one test, and every test carries
+at least one requirement.
+
+Sources:
+ spec URL -- externally mandated by the MCP specification (deep link to the section)
+ `sdk` -- a behavioural guarantee the SDK chose; not spec-mandated
+ `issue:#n` -- regression lock-in for a previously fixed bug
+
+The `behavior` sentence describes the REQUIRED behaviour -- what the specification (or the SDK's
+own contract) says should happen. Tests always pin the SDK's current behaviour. Where current
+behaviour falls short of `behavior`, the gap is recorded as data: `divergence` on entries whose
+tests pin the divergent behaviour, or `deferred` on entries that are tracked but not yet covered
+by a test in this suite. `issue` carries the tracking link for a recorded gap once one is filed.
+
+`deferred` reasons take one of three shapes: where the behaviour is exercised elsewhere in this
+repo the reason names the covering test path; where the SDK does not implement the behaviour at
+all the reason starts with "Not implemented in the SDK"; and where an interaction-level test is
+planned but not yet written the reason starts with "Not yet covered here".
+
+`transports` records which transports a behaviour applies to (or is observable on); None means
+the behaviour is transport-independent.
+
+The ID vocabulary and entry granularity are aligned with the TypeScript SDK's end-to-end
+requirements suite, so coverage and recorded divergences can be compared across the two SDKs
+entry by entry; IDs that exist in only one SDK reflect genuinely different API surface.
+"""
+
+import re
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Literal, TypeVar
+
+import pytest
+
+SPEC_REVISION = "2025-11-25"
+SPEC_BASE_URL = f"https://modelcontextprotocol.io/specification/{SPEC_REVISION}"
+
+Transport = Literal["in-memory", "stdio", "streamable-http", "sse"]
+
+_TestFn = TypeVar("_TestFn", bound=Callable[..., object])
+
+_SOURCE_PATTERN = re.compile(r"https://modelcontextprotocol\.io/specification/.+|sdk|issue:#\d+")
+
+_TASKS_DEFERRAL = (
+ "Tasks are experimental and the spec is being substantially revised; python task behaviour is "
+ "covered by tests/experimental/tasks/ until the next spec revision settles."
+)
+
+
+@dataclass(frozen=True, kw_only=True)
+class Divergence:
+ """A documented gap between the SDK behaviour this suite pins and what `source` mandates."""
+
+ note: str
+ issue: str | None = None
+
+
+@dataclass(frozen=True, kw_only=True)
+class Requirement:
+ """A single testable behaviour and the provenance of why it must hold."""
+
+ source: str
+ behavior: str
+ transports: tuple[Transport, ...] | None = None
+ divergence: Divergence | None = None
+ deferred: str | None = None
+ issue: str | None = None
+
+ def __post_init__(self) -> None:
+ if not _SOURCE_PATTERN.fullmatch(self.source):
+ raise ValueError(f"source must be a specification URL, 'sdk', or 'issue:#n', got {self.source!r}")
+
+
+REQUIREMENTS: dict[str, Requirement] = {
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Lifecycle & version negotiation
+ # ═══════════════════════════════════════════════════════════════════════════
+ "lifecycle:capability:client-not-declared": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#operation",
+ behavior=(
+ "The client rejects sending notifications or registering handlers for capabilities it did not declare."
+ ),
+ deferred=(
+ "Not implemented in the SDK: the client does not check its own declared capabilities before "
+ "sending notifications or serving callbacks."
+ ),
+ ),
+ "lifecycle:capability:server-not-advertised": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#operation",
+ behavior=(
+ "The client rejects calls to methods (e.g. resources/list) for capabilities the server did not advertise."
+ ),
+ deferred=(
+ "Not implemented in the SDK: the client sends any request regardless of the server's "
+ "advertised capabilities and surfaces whatever the server answers."
+ ),
+ ),
+ "lifecycle:initialize:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
+ behavior=(
+ "Connecting sends initialize with the protocol version, client capabilities, and client "
+ "info; the server responds with its own and the connection is established."
+ ),
+ ),
+ "lifecycle:initialize:server-info": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
+ behavior="The initialize result identifies the server: name and version, plus title when declared.",
+ ),
+ "lifecycle:initialize:instructions": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
+ behavior="A server may include an instructions string in the initialize result; the client exposes it.",
+ ),
+ "lifecycle:initialize:capabilities:from-handlers": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
+ behavior=(
+ "The server advertises a capability for each feature area it has a registered handler for, "
+ "and omits the capability for areas it does not."
+ ),
+ ),
+ "lifecycle:initialize:capabilities:minimal": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
+ behavior="A server with no feature handlers advertises no feature capabilities.",
+ ),
+ "lifecycle:initialize:client-info": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
+ behavior="The client's name, version, and title are visible to server handlers after initialization.",
+ ),
+ "lifecycle:initialize:client-capabilities": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
+ behavior=(
+ "The client capabilities visible to the server reflect which client callbacks are configured "
+ "(sampling, elicitation, roots)."
+ ),
+ ),
+ "lifecycle:initialized-notification": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
+ behavior=(
+ "After successful initialization, the client sends exactly one initialized notification, "
+ "before any non-ping request."
+ ),
+ ),
+ "lifecycle:ping": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements",
+ behavior="ping in either direction returns an empty result.",
+ ),
+ "ping:client-to-server": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements",
+ behavior="A client-initiated ping receives an empty result from the server.",
+ ),
+ "ping:server-to-client": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements",
+ behavior="A server-initiated ping receives an empty result from the client.",
+ ),
+ "lifecycle:requests-before-initialized": Requirement(
+ source="sdk",
+ behavior=(
+ "A request other than ping sent before the initialization handshake completes is rejected with an error."
+ ),
+ ),
+ "lifecycle:pre-initialization-ordering": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
+ behavior=(
+ "Before initialization completes, the client sends no requests other than pings, and the "
+ "server sends no requests other than pings and logging."
+ ),
+ deferred=(
+ "Not yet covered here: the sender-side restraint (especially the server half — no sampling, "
+ "elicitation, or roots requests before the initialized notification) has no test yet."
+ ),
+ ),
+ "lifecycle:version:downgrade": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
+ behavior=(
+ "When the server returns an older supported protocol version, the client downgrades to it "
+ "and the connection succeeds at that version."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here: observing the negotiated version requires the MCP-Protocol-Version "
+ "request header, which only exists on the HTTP transport; planned with the transport "
+ "conformance work."
+ ),
+ ),
+ "lifecycle:version:match": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
+ behavior=(
+ "When the server supports the requested protocol version it echoes that version in the "
+ "initialize result, and the connection proceeds at that version."
+ ),
+ ),
+ "lifecycle:version:server-fallback-latest": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
+ behavior=(
+ "An initialize request carrying a protocol version the server does not support is answered "
+ "with another version the server supports — the latest one — rather than an error."
+ ),
+ ),
+ "lifecycle:version:reject-unsupported": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
+ behavior=(
+ "A client that receives an initialize response carrying a protocol version it does not "
+ "support fails initialization with an error rather than proceeding with the session."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Protocol primitives: cancellation, timeout, progress, errors, _meta
+ # ═══════════════════════════════════════════════════════════════════════════
+ "protocol:request-id:unique": Requirement(
+ source=f"{SPEC_BASE_URL}/basic#requests",
+ behavior=(
+ "Every request sent on a session carries a unique, non-null string or integer id; ids are "
+ "never reused within the session."
+ ),
+ ),
+ "protocol:notifications:no-response": Requirement(
+ source=f"{SPEC_BASE_URL}/basic#notifications",
+ behavior=(
+ "Notifications are never answered: every message the server delivers is either the response "
+ "to a request the client sent or a notification carrying no id."
+ ),
+ ),
+ "protocol:cancel:abort-signal": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow",
+ behavior=(
+ "Cancelling an in-flight request through the client API sends notifications/cancelled with "
+ "the request id and fails the local call."
+ ),
+ deferred=(
+ "Not implemented in the SDK: there is no public client-side API to cancel an in-flight "
+ "request; cancellation requires hand-constructing the notification (which is how "
+ "protocol:cancel:in-flight exercises the receiving side)."
+ ),
+ ),
+ "protocol:cancel:handler-abort-propagates": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
+ behavior="On the receiving side, a cancellation notification stops the running request handler.",
+ ),
+ "protocol:cancel:in-flight": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
+ behavior=(
+ "A cancellation notification for an in-flight request stops the server-side handler, and the "
+ "receiver does not send a response for the cancelled request."
+ ),
+ divergence=Divergence(
+ note=(
+ "The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled "
+ "request; the server sends an error response (code 0, 'Request cancelled'), which is what "
+ "unblocks the SDK client's pending call."
+ ),
+ ),
+ ),
+ "protocol:cancel:initialize-not-cancellable": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
+ behavior="The client never sends notifications/cancelled for the initialize request.",
+ deferred=(
+ "Not implemented in the SDK: the client has no public cancellation API at all, so no pathway "
+ "exists that could cancel initialize; there is no distinct behaviour to pin beyond that absence."
+ ),
+ ),
+ "protocol:cancel:late-response-ignored": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
+ behavior=(
+ "A response that arrives after the sender issued notifications/cancelled is ignored; the "
+ "request stays failed and no error is raised."
+ ),
+ deferred=(
+ "Not yet covered here: needs the scripted-peer wire pattern to deliver a response after a "
+ "cancellation; today the receive loop logs an unknown-request-id error for such responses."
+ ),
+ ),
+ "protocol:cancel:server-survives": Requirement(
+ source="sdk",
+ behavior="The session continues to serve new requests after an earlier request was cancelled.",
+ ),
+ "protocol:cancel:server-to-client": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
+ behavior=(
+ "A server that abandons an in-flight server-initiated request (sampling, elicitation, roots) "
+ "cancels it, and the client stops processing the cancelled request."
+ ),
+ deferred=(
+ "Not implemented in the SDK: abandoning a server-side send_request emits no cancellation "
+ "notification (the same sender-side gap recorded on protocol:timeout:sends-cancellation), and "
+ "the client could not act on one anyway because client callbacks run inline in the receive "
+ "loop, so a cancellation would not even be read until the callback had already finished."
+ ),
+ ),
+ "protocol:cancel:unknown-id-ignored": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling",
+ behavior=(
+ "The receiver silently ignores a cancellation notification referencing an unknown or "
+ "already-completed request id; no error response is sent and no exception is raised."
+ ),
+ ),
+ "protocol:cancel:sender-targeting": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
+ behavior=(
+ "Cancellation notifications reference only requests that were previously issued in the same "
+ "direction and are believed to still be in flight."
+ ),
+ deferred=(
+ "Not yet covered here: there is no public client-side cancel API to drive (see "
+ "protocol:cancel:abort-signal), so the sender-side targeting rule has nothing to pin yet."
+ ),
+ ),
+ "protocol:error:connection-closed": Requirement(
+ source="sdk",
+ behavior="Closing the transport fails all in-flight requests with a connection-closed error.",
+ deferred=(
+ "Not yet covered here: planned gap test (close the transport while a request is in flight and "
+ "pin the error the caller receives)."
+ ),
+ ),
+ "protocol:error:internal-error": Requirement(
+ source=f"{SPEC_BASE_URL}/basic#responses",
+ behavior=(
+ "An unhandled exception in a request handler is returned to the caller as JSON-RPC error "
+ "-32603 Internal error."
+ ),
+ divergence=Divergence(
+ note=(
+ "The low-level Server returns code 0 (not a defined JSON-RPC code) instead of -32603 and "
+ "leaks str(exc) as the error message."
+ ),
+ ),
+ ),
+ "protocol:error:invalid-params": Requirement(
+ source=f"{SPEC_BASE_URL}/basic#responses",
+ behavior="A request with malformed params is answered with JSON-RPC error -32602 Invalid params.",
+ deferred=(
+ "Not yet covered here: the typed client API cannot send malformed params; needs a request "
+ "driven one level below it (planned gap test)."
+ ),
+ ),
+ "protocol:error:method-not-found": Requirement(
+ source=f"{SPEC_BASE_URL}/basic#responses",
+ behavior="A request whose method has no registered handler is answered with a METHOD_NOT_FOUND error.",
+ ),
+ "protocol:meta:related-task": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#related-task-metadata",
+ behavior="Messages may carry related-task _meta associating them with a task.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "meta:request-to-handler": Requirement(
+ source=f"{SPEC_BASE_URL}/basic#_meta",
+ behavior="The _meta object the client attaches to a request is visible to the server handler.",
+ ),
+ "meta:result-to-client": Requirement(
+ source=f"{SPEC_BASE_URL}/basic#_meta",
+ behavior="The _meta object a handler attaches to its result is delivered to the client.",
+ ),
+ "protocol:progress:callback": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
+ behavior=(
+ "Progress notifications emitted by a handler during a request are delivered to the caller's "
+ "progress callback, in order, with their progress, total, and message."
+ ),
+ ),
+ "protocol:progress:token-injected": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
+ behavior=(
+ "Supplying a progress callback attaches a progress token to the outgoing request, which the "
+ "server-side handler can observe in its request metadata."
+ ),
+ ),
+ "protocol:progress:token-unique": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
+ behavior=("Concurrent in-flight requests that each supply a progress callback carry distinct progress tokens."),
+ deferred=(
+ "Not yet covered here: planned gap test (two concurrent requests with progress callbacks, "
+ "asserting their tokens differ and each callback only sees its own notifications)."
+ ),
+ ),
+ "protocol:progress:monotonic": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
+ behavior=(
+ "The progress value increases with each notification for a given token, even when the total is unknown."
+ ),
+ deferred=(
+ "Not implemented in the SDK: progress values are not validated anywhere; a handler can emit "
+ "non-increasing values and they are forwarded as-is."
+ ),
+ ),
+ "protocol:progress:stops-after-completion": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
+ behavior="Progress notifications for a token stop once the associated request completes.",
+ deferred=(
+ "Not yet covered here: needs a test that a handler reporting progress after its request "
+ "completed produces no further notifications for the caller."
+ ),
+ ),
+ "protocol:progress:no-token": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
+ behavior=(
+ "Without a progress callback no token is attached, and a handler that reports progress anyway "
+ "sends nothing."
+ ),
+ ),
+ "protocol:progress:client-to-server": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
+ behavior="A progress notification sent by the client is delivered to the server's progress handler.",
+ ),
+ "protocol:timeout:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
+ behavior=(
+ "A request that exceeds its read timeout fails with a request-timeout error instead of "
+ "waiting forever for the response."
+ ),
+ ),
+ "protocol:timeout:max-total": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
+ behavior="A maximum total timeout is enforced even when progress notifications keep arriving.",
+ deferred=(
+ "Not implemented in the SDK: there is no maximum-total-timeout option; only the per-request "
+ "read timeout exists."
+ ),
+ ),
+ "protocol:timeout:reset-on-progress": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
+ behavior="When configured to do so, each progress notification resets the request's read timeout.",
+ deferred=(
+ "Not implemented in the SDK: progress notifications do not reset the request read timeout and "
+ "no option exists to enable that."
+ ),
+ ),
+ "protocol:timeout:sends-cancellation": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
+ behavior=(
+ "When a request times out, the sender issues notifications/cancelled for that request before "
+ "failing the local call."
+ ),
+ divergence=Divergence(
+ note=(
+ "The client only raises locally and sends nothing on timeout, so the server keeps running the handler."
+ ),
+ ),
+ ),
+ "protocol:timeout:session-survives": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
+ behavior="The session continues to serve new requests after an earlier request timed out.",
+ ),
+ "protocol:timeout:session-default": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
+ behavior="A session-level read timeout applies to every request that does not override it.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Tools
+ # ═══════════════════════════════════════════════════════════════════════════
+ "tools:call:content:audio": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#audio-content",
+ behavior="A tool result can carry audio content: base64 data with a mimeType.",
+ ),
+ "tools:call:content:embedded-resource": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#embedded-resources",
+ behavior="A tool result can carry an embedded resource with full text or blob contents.",
+ ),
+ "tools:call:content:image": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#image-content",
+ behavior="A tool result can carry image content: base64 data with a mimeType.",
+ ),
+ "tools:call:content:mixed": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#tool-result",
+ behavior="A tool result can carry multiple content blocks of different types; order is preserved.",
+ ),
+ "tools:call:content:resource-link": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#resource-links",
+ behavior="A tool result can carry a resource_link content block referencing a resource by URI.",
+ ),
+ "tools:call:content:text": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#text-content",
+ behavior="tools/call delivers arguments to the tool handler and returns its text content to the caller.",
+ ),
+ "tools:call:concurrent": Requirement(
+ source="sdk",
+ behavior=(
+ "Multiple tool calls in flight on one session are dispatched concurrently, and each caller "
+ "receives the response to its own request."
+ ),
+ ),
+ "tools:call:elicitation-roundtrip": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#user-interaction-model",
+ behavior=(
+ "A tool handler that issues an elicitation receives the client's result and can embed it in "
+ "the tool call result."
+ ),
+ ),
+ "tools:call:is-error": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#error-handling",
+ behavior=(
+ "A tool execution failure is returned as a result with isError true and the failure described "
+ "in content, not as a JSON-RPC error."
+ ),
+ ),
+ "tools:call:logging-mid-execution": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
+ behavior=(
+ "Log notifications emitted by a tool handler during execution reach the client's logging "
+ "callback before the tool result returns."
+ ),
+ ),
+ "tools:call:progress": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
+ behavior=(
+ "Progress notifications emitted by a tool handler reach the caller's progress callback before "
+ "the tool result returns."
+ ),
+ ),
+ "tools:call:sampling-roundtrip": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
+ behavior=(
+ "A tool handler that issues a sampling request receives the client's completion and can embed "
+ "it in the tool call result."
+ ),
+ ),
+ "tools:call:structured-content": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#structured-content",
+ behavior="A tool result can carry structuredContent alongside content; the client receives both.",
+ ),
+ "tools:call:structured-content:text-mirror": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#structured-content",
+ behavior="A tool returning structured content also returns the serialized JSON as a text content block.",
+ ),
+ "tools:call:unknown-name": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#error-handling",
+ behavior="tools/call for a name the server does not recognise returns a JSON-RPC error.",
+ ),
+ "tools:capability:declared": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#capabilities",
+ behavior="A server with a list_tools handler advertises the tools capability in its initialize result.",
+ ),
+ "tools:input-schema:json-schema-2020-12": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#tool",
+ behavior=(
+ "A tool registered with a JSON Schema 2020-12 inputSchema (nested objects, $defs references) "
+ "is discoverable and callable."
+ ),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/test_types.py at the type level; an "
+ "interaction-level passthrough test is planned with the gap batch."
+ ),
+ ),
+ "tools:input-schema:preserve-additional-properties": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#tool",
+ behavior="tools/list preserves inputSchema additionalProperties as registered.",
+ deferred=(
+ "Not yet covered here; existing coverage in tests/test_types.py at the type level; an "
+ "interaction-level passthrough test is planned with the gap batch."
+ ),
+ ),
+ "tools:input-schema:preserve-defs": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#tool",
+ behavior="tools/list preserves inputSchema $defs as registered.",
+ deferred=(
+ "Not yet covered here; existing coverage in tests/test_types.py at the type level; an "
+ "interaction-level passthrough test is planned with the gap batch."
+ ),
+ ),
+ "tools:input-schema:preserve-schema-dialect": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#tool",
+ behavior="tools/list preserves the inputSchema $schema dialect URI as registered.",
+ deferred=(
+ "Not yet covered here; existing coverage in tests/test_types.py at the type level; an "
+ "interaction-level passthrough test is planned with the gap batch."
+ ),
+ ),
+ "tools:list-changed": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#list-changed-notification",
+ behavior=(
+ "When the tool set changes, a server that declared the tools listChanged capability sends "
+ "notifications/tools/list_changed and it reaches the client's handler."
+ ),
+ ),
+ "tools:list:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#listing-tools",
+ behavior="tools/list returns the registered tools with name, description, and inputSchema.",
+ ),
+ "tools:list:metadata": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#tool",
+ behavior=(
+ "Optional Tool fields supplied by the server (title, annotations, outputSchema, icons, _meta) "
+ "are delivered to the client unchanged."
+ ),
+ ),
+ "tools:list:pagination": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/pagination#response-format",
+ behavior=(
+ "tools/list supports cursor pagination: the nextCursor returned by a list handler round-trips "
+ "back to the handler as an opaque cursor until the listing is exhausted."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Tools: SDK guarantees
+ # ═══════════════════════════════════════════════════════════════════════════
+ "client:output-schema:skip-on-error": Requirement(
+ source="sdk",
+ behavior="The client skips structured-content validation when the tool result has isError true.",
+ deferred=(
+ "Not yet covered here: planned gap test (an isError result with mismatching structuredContent "
+ "is returned to the caller rather than rejected)."
+ ),
+ ),
+ "client:output-schema:validate": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#output-schema",
+ behavior=(
+ "A tool result whose structuredContent does not conform to the tool's declared outputSchema "
+ "is rejected by the client: the call raises instead of returning the invalid result."
+ ),
+ ),
+ "mcpserver:output-schema:missing-structured": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#output-schema",
+ behavior="A tool with an output schema whose function returns no structured content produces a server error.",
+ deferred="Not yet covered here: planned gap test (output schema declared but no structured content returned).",
+ ),
+ "mcpserver:output-schema:server-validate": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#output-schema",
+ behavior=(
+ "MCPServer validates structured content against the tool's output schema before returning; a "
+ "mismatch produces a server error."
+ ),
+ deferred="Not yet covered here: planned gap test (server-side output schema validation failure).",
+ ),
+ "mcpserver:output-schema:skip-on-error": Requirement(
+ source="sdk",
+ behavior="Server-side output schema validation is skipped when the tool returns an isError result.",
+ deferred="Not yet covered here: planned gap test (isError results bypass server-side schema validation).",
+ ),
+ "mcpserver:tool:duplicate-name": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#tool-names",
+ behavior="Registering a tool with a name already in use is rejected at registration time.",
+ deferred="Not yet covered here: planned gap test (duplicate tool registration).",
+ ),
+ "mcpserver:tool:extra": Requirement(
+ source="sdk",
+ behavior=(
+ "Tool functions can access request metadata (request id, client params, session, lifespan "
+ "state) through the Context parameter."
+ ),
+ deferred="Not yet covered here: planned gap test (Context request-metadata access from inside a tool).",
+ ),
+ "mcpserver:tool:handler-throws": Requirement(
+ source="sdk",
+ behavior=(
+ "An exception raised by a tool function (ToolError or otherwise) is caught and returned as a "
+ "tool result with isError true and the failure text in content; it does not become a JSON-RPC error."
+ ),
+ ),
+ "mcpserver:tool:input-validation": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#error-handling",
+ behavior=(
+ "Arguments that fail the tool's input validation produce a tool execution error (isError true "
+ "with the validation failure described in content) without invoking the function."
+ ),
+ ),
+ "mcpserver:tool:naming-validation": Requirement(
+ source="sdk",
+ behavior="Tool names that violate the spec's naming rules are rejected at registration time.",
+ deferred="Not yet covered here: tool-name validation at registration has not been pinned yet.",
+ ),
+ "mcpserver:tool:output-schema:model": Requirement(
+ source="sdk",
+ behavior=(
+ "A tool returning a typed model advertises a matching generated outputSchema and returns the "
+ "model's fields as structuredContent alongside a serialised text block."
+ ),
+ ),
+ "mcpserver:tool:output-schema:wrapped": Requirement(
+ source="sdk",
+ behavior=(
+ "A tool returning a non-object type (primitive or list) wraps the value as {'result': ...} in "
+ "structuredContent, with a matching generated outputSchema."
+ ),
+ ),
+ "mcpserver:tool:schema-variants": Requirement(
+ source="sdk",
+ behavior=(
+ "Tool input schemas generated from complex parameter types (unions, nested models, "
+ "constrained types) validate and coerce arguments before the function runs."
+ ),
+ deferred=(
+ "Not yet covered here: planned gap test (complex parameter types validated and coerced before "
+ "the function runs)."
+ ),
+ ),
+ "mcpserver:tool:unknown-name": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#error-handling",
+ behavior="tools/call for a name that was never registered returns a JSON-RPC error.",
+ divergence=Divergence(
+ note=(
+ "The spec classifies unknown tools as a protocol error (its example uses -32602 Invalid "
+ "params); MCPServer reports a tool execution error (isError true) instead. The low-level "
+ "path follows the spec example (see tools:call:unknown-name)."
+ ),
+ ),
+ ),
+ "mcpserver:tool:url-elicitation-error": Requirement(
+ source="sdk",
+ behavior=(
+ "A tool function that raises the URL-elicitation-required error surfaces to the caller as "
+ "error -32042 with the elicitation parameters intact."
+ ),
+ deferred=(
+ "Not yet covered here: the low-level equivalent is pinned by elicitation:url:required-error; "
+ "the MCPServer-decorated path is a planned gap test."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # MCPServer: Context helpers (SDK)
+ # ═══════════════════════════════════════════════════════════════════════════
+ "mcpserver:context:logging": Requirement(
+ source="sdk",
+ behavior=(
+ "The Context logging helpers (debug/info/warning/error) send log message notifications at the "
+ "corresponding severity."
+ ),
+ ),
+ "mcpserver:context:progress": Requirement(
+ source="sdk",
+ behavior=(
+ "Context.report_progress sends a progress notification against the requesting client's progress token."
+ ),
+ ),
+ "mcpserver:context:elicit": Requirement(
+ source="sdk",
+ behavior=(
+ "Context.elicit sends a form elicitation built from a typed schema and returns a typed "
+ "accepted/declined/cancelled result."
+ ),
+ ),
+ "mcpserver:context:read-resource": Requirement(
+ source="sdk",
+ behavior="Context.read_resource reads a resource registered on the same server from inside a tool.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Resources
+ # ═══════════════════════════════════════════════════════════════════════════
+ "resources:annotations": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#annotations",
+ behavior=(
+ "Resource annotations (audience, priority, lastModified) supplied by the server round-trip to "
+ "the client in list and read results."
+ ),
+ deferred="Not yet covered here: planned gap test (annotations passthrough on list and read results).",
+ ),
+ "resources:capability:declared": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#capabilities",
+ behavior=(
+ "A server with resource handlers advertises the resources capability, including the subscribe "
+ "sub-flag when a subscribe handler is registered."
+ ),
+ ),
+ "resources:list-changed": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#list-changed-notification",
+ behavior=(
+ "When the resource set changes, a server that declared the resources listChanged capability "
+ "sends notifications/resources/list_changed and it reaches the client's handler."
+ ),
+ ),
+ "resources:list:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#listing-resources",
+ behavior=(
+ "resources/list returns the registered resources with uri, name, and the optional descriptive "
+ "fields supplied by the server."
+ ),
+ ),
+ "resources:list:pagination": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/pagination#operations-supporting-pagination",
+ behavior="resources/list supports cursor pagination.",
+ ),
+ "resources:read:blob": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#reading-resources",
+ behavior="resources/read returns binary contents base64-encoded in blob.",
+ ),
+ "resources:read:template-vars": Requirement(
+ source="sdk",
+ behavior="Variables extracted from a templated resource URI reach the resource function as typed arguments.",
+ ),
+ "resources:read:text": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#reading-resources",
+ behavior="resources/read returns text contents carrying uri, mimeType, and the text.",
+ ),
+ "resources:read:unknown-uri": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#error-handling",
+ behavior="resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).",
+ ),
+ "resources:subscribe": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
+ behavior="resources/subscribe delivers the URI to the server's subscribe handler and returns an empty result.",
+ ),
+ "resources:subscribe:capability-required": Requirement(
+ source="sdk",
+ behavior=(
+ "resources/subscribe to a server that did not advertise the subscribe capability is rejected with an error."
+ ),
+ deferred=(
+ "Not yet covered here: planned gap test (subscribe rejected with METHOD_NOT_FOUND when no "
+ "subscribe handler is registered)."
+ ),
+ ),
+ "resources:subscribe:updated": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
+ behavior="After resources/subscribe, changes to that resource send notifications/resources/updated.",
+ deferred=(
+ "Not implemented in the SDK: the server keeps no subscription state linking subscribe to "
+ "updated notifications; emitting updates is entirely handler code. The two halves are pinned "
+ "separately by resources:subscribe and resources:updated-notification."
+ ),
+ ),
+ "resources:templates:list": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#resource-templates",
+ behavior=(
+ "resources/templates/list returns the registered templates with their uriTemplate and descriptive fields."
+ ),
+ ),
+ "resources:templates:pagination": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/pagination#operations-supporting-pagination",
+ behavior="resources/templates/list supports cursor pagination.",
+ ),
+ "resources:unsubscribe": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
+ behavior=(
+ "resources/unsubscribe delivers the URI to the server's unsubscribe handler and returns an empty result."
+ ),
+ ),
+ "resources:unsubscribe:stops-updates": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
+ behavior="After resources/unsubscribe the server stops sending updated notifications for that URI.",
+ deferred=(
+ "The SDK keeps no subscription state -- emitting updated notifications is entirely handler "
+ "code -- so there is no SDK behaviour to pin beyond the unsubscribe request reaching the "
+ "handler (covered by resources:unsubscribe)."
+ ),
+ ),
+ "resources:updated-notification": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
+ behavior=(
+ "A resources/updated notification sent by the server reaches the client carrying the URI of "
+ "the changed resource."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Resources: SDK guarantees
+ # ═══════════════════════════════════════════════════════════════════════════
+ "mcpserver:resource:duplicate-name": Requirement(
+ source="sdk",
+ behavior="Registering a resource or template with a duplicate identifier is rejected at registration time.",
+ deferred="Not yet covered here: planned gap test (duplicate resource registration).",
+ ),
+ "mcpserver:resource:read-throws-surfaced": Requirement(
+ source="sdk",
+ behavior="A resource function that raises is surfaced to the caller as a JSON-RPC error response.",
+ deferred="Not yet covered here: planned gap test (resource function raising during read).",
+ ),
+ "mcpserver:resource:static": Requirement(
+ source="sdk",
+ behavior=(
+ "A function registered with @mcp.resource() for a fixed URI is listed by resources/list and "
+ "served by resources/read at that URI."
+ ),
+ ),
+ "mcpserver:resource:template": Requirement(
+ source="sdk",
+ behavior=(
+ "A function registered with a URI template is listed by resources/templates/list and matched "
+ "by resources/read, receiving the parameters extracted from the requested URI."
+ ),
+ ),
+ "mcpserver:resource:unknown-uri": Requirement(
+ source=f"{SPEC_BASE_URL}/server/resources#error-handling",
+ behavior="resources/read for a URI matching no registered resource returns JSON-RPC error -32002.",
+ divergence=Divergence(
+ note=(
+ "The spec reserves -32002 for resource-not-found; MCPServer raises ResourceError, which "
+ "the low-level server converts to error code 0."
+ ),
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Prompts
+ # ═══════════════════════════════════════════════════════════════════════════
+ "prompts:capability:declared": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#capabilities",
+ behavior="A server with a list_prompts handler advertises the prompts capability in its initialize result.",
+ ),
+ "prompts:get:content:audio": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#audio-content",
+ behavior="Prompt messages may contain audio content with base64 data and a mimeType.",
+ deferred="Not yet covered here: planned gap test (audio content in prompt messages).",
+ ),
+ "prompts:get:content:embedded-resource": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#embedded-resources",
+ behavior="Prompt messages may contain embedded resource content.",
+ deferred="Not yet covered here: planned gap test (embedded resources in prompt messages).",
+ ),
+ "prompts:get:content:image": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#image-content",
+ behavior="Prompt messages may contain image content.",
+ deferred="Not yet covered here: planned gap test (image content in prompt messages).",
+ ),
+ "prompts:get:missing-required-args": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#error-handling",
+ behavior="prompts/get omitting a required argument returns JSON-RPC error -32602 (Invalid params).",
+ divergence=Divergence(
+ note=(
+ "MCPServer's prompt renderer raises a plain ValueError before the prompt function runs, "
+ "which the low-level server converts to error code 0 with the exception text as the message."
+ ),
+ ),
+ ),
+ "prompts:get:multi-message": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt",
+ behavior="A prompt can return multiple messages mixing user and assistant roles; order is preserved.",
+ ),
+ "prompts:get:no-args": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt",
+ behavior="prompts/get with no arguments returns the prompt's messages.",
+ deferred="Not yet covered here: planned gap test (argument-free prompt fetched without arguments).",
+ ),
+ "prompts:get:unknown-name": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#error-handling",
+ behavior="prompts/get for an unknown prompt name returns JSON-RPC error -32602 (Invalid params).",
+ ),
+ "prompts:get:with-args": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt",
+ behavior="prompts/get delivers the supplied arguments to the prompt handler and returns its messages.",
+ ),
+ "prompts:list-changed": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#list-changed-notification",
+ behavior=(
+ "When the prompt set changes, a server that declared the prompts listChanged capability sends "
+ "notifications/prompts/list_changed and it reaches the client's handler."
+ ),
+ ),
+ "prompts:list:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#listing-prompts",
+ behavior="prompts/list returns the registered prompts with name, description, and argument declarations.",
+ ),
+ "prompts:list:pagination": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/pagination#operations-supporting-pagination",
+ behavior="prompts/list supports cursor pagination.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Prompts: SDK guarantees
+ # ═══════════════════════════════════════════════════════════════════════════
+ "mcpserver:prompt:args-validation": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#implementation-considerations",
+ behavior="prompts/get arguments that fail the prompt's argument schema are rejected before the function runs.",
+ deferred="Not yet covered here: planned gap test (argument validation on decorated prompts).",
+ ),
+ "mcpserver:prompt:decorated": Requirement(
+ source="sdk",
+ behavior=(
+ "A function registered with @mcp.prompt() is listed with arguments derived from its signature "
+ "and rendered into prompt messages by prompts/get."
+ ),
+ ),
+ "mcpserver:prompt:duplicate-name": Requirement(
+ source="sdk",
+ behavior="Registering a duplicate prompt name is rejected at registration time.",
+ deferred="Not yet covered here: planned gap test (duplicate prompt registration).",
+ ),
+ "mcpserver:prompt:optional-args": Requirement(
+ source="sdk",
+ behavior="A prompt with optional arguments can be fetched without supplying them.",
+ deferred="Not yet covered here: planned gap test (optional prompt arguments omitted).",
+ ),
+ "mcpserver:prompt:unknown-name": Requirement(
+ source=f"{SPEC_BASE_URL}/server/prompts#error-handling",
+ behavior="prompts/get for a name that was never registered returns JSON-RPC error -32602 (Invalid params).",
+ divergence=Divergence(
+ note=(
+ "The spec's example uses -32602 Invalid params for unknown prompts; MCPServer raises "
+ "ValueError, which the low-level server converts to error code 0."
+ ),
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Completion
+ # ═══════════════════════════════════════════════════════════════════════════
+ "completion:capability:declared": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/completion#capabilities",
+ behavior="A server with a completion handler advertises the completions capability in its initialize result.",
+ ),
+ "completion:complete:not-supported": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/completion#capabilities",
+ behavior=(
+ "A server with no completion handler does not advertise the completions capability and rejects "
+ "completion/complete with METHOD_NOT_FOUND."
+ ),
+ ),
+ "completion:context-arguments": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/completion#requesting-completions",
+ behavior="Previously-resolved argument values supplied in context.arguments reach the completion handler.",
+ ),
+ "completion:error:invalid-ref": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/completion#error-handling",
+ behavior=(
+ "completion/complete with a ref naming an unknown prompt or non-matching resource URI returns "
+ "JSON-RPC error -32602 (Invalid params)."
+ ),
+ deferred="Not yet covered here: planned gap test (completion against an unknown ref).",
+ ),
+ "completion:prompt-arg": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/completion#reference-types",
+ behavior="completion/complete with a ref/prompt returns suggested values for the named prompt argument.",
+ ),
+ "completion:resource-template-arg": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/completion#reference-types",
+ behavior="completion/complete with a ref/resource returns suggested values for a URI template variable.",
+ ),
+ "completion:result-shape": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/completion#completion-results",
+ behavior="The completion result carries values (at most 100), an optional total, and an optional hasMore flag.",
+ ),
+ "mcpserver:completion:capability-auto": Requirement(
+ source="sdk",
+ behavior=(
+ "MCPServer advertises the completions capability when at least one completion source is "
+ "registered, and omits it otherwise."
+ ),
+ deferred="Not yet covered here: planned gap test (automatic completions capability derivation).",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Logging
+ # ═══════════════════════════════════════════════════════════════════════════
+ "logging:capability:declared": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/logging#capabilities",
+ behavior=(
+ "A server that emits log message notifications declares the logging capability in its initialize result."
+ ),
+ divergence=Divergence(
+ note=(
+ "MCPServer registers no setLevel handler, so capability derivation leaves logging unset "
+ "even though the Context helpers send log message notifications."
+ ),
+ ),
+ ),
+ "logging:message:all-levels": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
+ behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.",
+ ),
+ "logging:message:fields": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
+ behavior=(
+ "A log message sent by a server handler is delivered to the client's logging callback with its "
+ "severity level, logger name, and data, in the order the server sent them."
+ ),
+ ),
+ "logging:message:filtered": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
+ behavior="After logging/setLevel, log messages below the configured level are not sent.",
+ divergence=Divergence(
+ note=(
+ "Neither MCPServer (which rejects logging/setLevel with method-not-found) nor the "
+ "low-level Server (which leaves the handler entirely to the author) implements any "
+ "filtering; messages are delivered at every severity regardless of the requested level."
+ ),
+ ),
+ ),
+ "logging:set-level": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
+ behavior="logging/setLevel delivers the requested level to the server's handler and returns an empty result.",
+ ),
+ "logging:set-level:invalid-level": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/logging#error-handling",
+ behavior="logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).",
+ deferred="Not yet covered here: planned gap test (invalid level value on setLevel).",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Sampling (server → client)
+ # ═══════════════════════════════════════════════════════════════════════════
+ "sampling:capability:declare": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#capabilities",
+ behavior=(
+ "A client that handles sampling requests advertises the sampling capability in its initialize request."
+ ),
+ deferred="Not yet covered here: planned gap test (positive sampling capability declaration).",
+ ),
+ "sampling:create:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
+ behavior=(
+ "A sampling/createMessage request from a server handler is answered by the client's sampling "
+ "callback, and the callback's result (role, content, model, stopReason) is returned to the handler."
+ ),
+ ),
+ "sampling:create:include-context": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#capabilities",
+ behavior="The includeContext value supplied by the server reaches the client callback intact.",
+ ),
+ "sampling:context:server-gated-by-capability": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#capabilities",
+ behavior=(
+ "The server does not use includeContext values thisServer or allServers unless the client "
+ "declared the sampling.context capability."
+ ),
+ deferred=(
+ "Not implemented in the SDK: include_context is forwarded regardless of the client's declared "
+ "sampling.context capability (unlike tools, which are gated by the server-side validator)."
+ ),
+ ),
+ "sampling:create:model-preferences": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#model-preferences",
+ behavior=(
+ "The model preferences supplied by the server (hints and the cost, speed, and intelligence "
+ "priorities) reach the client callback intact."
+ ),
+ ),
+ "sampling:create:system-prompt": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
+ behavior="The system prompt supplied by the server reaches the client callback intact.",
+ ),
+ "sampling:create:tools": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#tools-in-sampling",
+ behavior=(
+ "A sampling request carrying tools and toolChoice reaches the client, and a tool_use response "
+ "with a toolUse stop reason returns to the requesting handler."
+ ),
+ deferred=(
+ "Not implemented in the SDK: Client does not expose ClientSession's sampling_capabilities "
+ "parameter, so a client can never declare sampling.tools and the server-side validator "
+ "rejects every tool-enabled request before it is sent."
+ ),
+ ),
+ "sampling:create-message:audio-content": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#audio-content",
+ behavior="Sampling messages can carry audio content: base64 data with a mimeType.",
+ deferred="Not yet covered here: planned gap test (audio content in sampling messages, both directions).",
+ ),
+ "sampling:create-message:image-content": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#image-content",
+ behavior="Sampling messages can carry image content: base64 data with a mimeType.",
+ ),
+ "sampling:create-message:not-supported": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#capabilities",
+ behavior=(
+ "A sampling request to a client that did not declare the sampling capability fails with an "
+ "error rather than hanging or being silently dropped; the spec names no error code for this case."
+ ),
+ ),
+ "sampling:error:user-rejected": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#error-handling",
+ behavior=(
+ "A sampling request the user rejects is answered with a JSON-RPC error (the spec's code for "
+ "this case is -1, 'User rejected sampling request'), surfaced to the requesting handler as an MCPError."
+ ),
+ ),
+ "sampling:message:content-cardinality": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling",
+ behavior="A sampling message's content may be a single block or an array of blocks.",
+ deferred="Not yet covered here: planned gap test (list-valued sampling message content).",
+ ),
+ "sampling:result:no-tools-single-content": Requirement(
+ source="sdk",
+ behavior=(
+ "When the request carries no tools, a sampling callback result whose content is an array is "
+ "rejected by the client."
+ ),
+ deferred="Not yet covered here: planned gap test (array content rejected for tool-free sampling).",
+ ),
+ "sampling:result:with-tools-array-content": Requirement(
+ source="sdk",
+ behavior=(
+ "When the request includes tools, the client accepts a callback result whose content is an "
+ "array including tool_use blocks."
+ ),
+ deferred=(
+ "Not implemented in the SDK: requires declaring sampling.tools, which the high-level client "
+ "cannot do (see sampling:create:tools)."
+ ),
+ ),
+ "sampling:tool-result:no-mixed-content": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#tool-result-messages",
+ behavior=(
+ "A user sampling message that carries tool_result content contains only tool_result blocks; "
+ "mixing tool_result with text, image, or audio content is rejected as invalid."
+ ),
+ ),
+ "sampling:tool-use:result-balance": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#tool-use-and-result-balance",
+ behavior=(
+ "Every assistant tool_use block in a sampling request must be matched by a tool_result with "
+ "the same id in the following user message; an unmatched tool_use is rejected with Invalid params."
+ ),
+ deferred="Not yet covered here: planned gap test (unmatched tool_use rejected by the validator).",
+ ),
+ "sampling:tools:server-gated-by-capability": Requirement(
+ source=f"{SPEC_BASE_URL}/client/sampling#tools-in-sampling",
+ behavior=(
+ "A tool-enabled sampling request to a client that did not declare sampling.tools is rejected "
+ "by the server before anything reaches the wire (the SDK surfaces this as an Invalid params error)."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Elicitation (server → client)
+ # ═══════════════════════════════════════════════════════════════════════════
+ "elicitation:capability:empty-is-form": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#capabilities",
+ behavior="A client advertising an empty elicitation capability accepts form-mode elicitation requests.",
+ deferred=(
+ "Not implemented in the SDK: a Client with an elicitation callback always declares explicit "
+ "form and url sub-capabilities, so an empty elicitation capability cannot be produced through "
+ "the public API."
+ ),
+ ),
+ "elicitation:capability:mode-mismatch": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#error-handling",
+ behavior=(
+ "The client answers elicitation requests for a mode it did not advertise with JSON-RPC error "
+ "-32602 (Invalid params)."
+ ),
+ deferred=(
+ "Not implemented in the SDK: a client cannot be configured form-only or url-only, so the "
+ "per-mode mismatch error cannot arise (see elicitation:url:not-supported)."
+ ),
+ ),
+ "elicitation:capability:server-respects-mode": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#capabilities",
+ behavior=(
+ "The server refuses to send an elicitation request with a mode the connected client did not "
+ "declare in its capabilities."
+ ),
+ deferred=(
+ "Not implemented in the SDK: the server does not check the client's declared elicitation "
+ "modes before sending elicitation/create."
+ ),
+ ),
+ "elicitation:form:action:accept": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
+ behavior=(
+ "A form-mode elicitation answered with action 'accept' returns the user's content to the "
+ "requesting handler."
+ ),
+ ),
+ "elicitation:form:action:cancel": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
+ behavior="A form-mode elicitation answered with action 'cancel' returns no content to the handler.",
+ ),
+ "elicitation:form:action:decline": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
+ behavior="A form-mode elicitation answered with action 'decline' returns no content to the handler.",
+ ),
+ "elicitation:form:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#form-mode-elicitation-requests",
+ behavior=(
+ "A form-mode elicitation delivers the message and requested schema to the client callback "
+ "exactly as the server sent them."
+ ),
+ ),
+ "elicitation:form:defaults": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema",
+ behavior=(
+ "Optional default values declared in a form-mode requested schema are pre-populated into the "
+ "form presented to the user."
+ ),
+ deferred=(
+ "Not implemented in the SDK: there is no form-rendering layer that could pre-populate "
+ "defaults; client callbacks receive the requested schema as-is."
+ ),
+ ),
+ "elicitation:form:mode-omitted-default": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#elicitation-requests",
+ behavior="An elicitation request with no mode field is treated as form mode by the client.",
+ deferred="Not yet covered here: planned gap test (mode-less elicitation request handled as form mode).",
+ ),
+ "elicitation:form:not-supported": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#error-handling",
+ behavior=(
+ "An elicitation request to a client that did not declare the elicitation capability is "
+ "answered with -32602 Invalid params."
+ ),
+ divergence=Divergence(
+ note="The client's default callback answers with -32600 Invalid request instead of -32602.",
+ ),
+ ),
+ "elicitation:form:schema:enum-variants": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema",
+ behavior=(
+ "Requested-schema enum fields (including titled and multi-select variants) reach the client "
+ "callback as sent."
+ ),
+ deferred="Not yet covered here: planned gap test (enum variants in the requested schema).",
+ ),
+ "elicitation:form:schema:primitives": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema",
+ behavior="Requested-schema fields may be string (with format), number or integer, or boolean.",
+ deferred="Not yet covered here: planned gap test (full primitive-type coverage in the requested schema).",
+ ),
+ "elicitation:form:schema:restricted-subset": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema",
+ behavior=(
+ "Form-mode requested schemas are flat objects with primitive-typed properties only; nested "
+ "structures and arrays of objects are not used."
+ ),
+ deferred=(
+ "Not implemented in the SDK: nothing restricts or validates the requested-schema shape on the "
+ "sending side; hand-built lowlevel elicitation requests pass through unchecked."
+ ),
+ ),
+ "elicitation:form:response-validation": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#form-mode-security",
+ behavior=(
+ "Accepted form-mode content is validated against the requested schema: the client validates "
+ "the response before sending and the server validates the content it receives."
+ ),
+ deferred=("Not implemented in the SDK: accepted elicitation content passes through unvalidated on both sides."),
+ ),
+ "elicitation:url:action:accept-no-content": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
+ behavior=(
+ "A URL-mode elicitation delivers the message, URL, and elicitationId to the client; an accept "
+ "response carries no content (accept means the user agreed to visit the URL, not that the "
+ "interaction completed)."
+ ),
+ ),
+ "elicitation:url:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#url-mode-elicitation-requests",
+ behavior=(
+ "A url-mode elicitation delivers the elicitation id and URL to the client callback exactly as "
+ "the server sent them."
+ ),
+ ),
+ "elicitation:url:cancel": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
+ behavior="A URL-mode elicitation answered with cancel returns the action with no content.",
+ ),
+ "elicitation:url:complete-notification": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation",
+ behavior=(
+ "An elicitation/complete notification sent by the server after an out-of-band elicitation "
+ "finishes reaches the client carrying the elicitationId."
+ ),
+ ),
+ "elicitation:url:complete-unknown-ignored": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation",
+ behavior=(
+ "The client ignores an elicitation/complete notification referencing an unknown or "
+ "already-completed elicitationId without error."
+ ),
+ deferred="Not yet covered here: planned gap test (unknown elicitationId in a complete notification).",
+ ),
+ "elicitation:url:decline": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#response-actions",
+ behavior="A URL-mode elicitation answered with decline returns the action with no content.",
+ ),
+ "elicitation:url:not-supported": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#error-handling",
+ behavior=(
+ "A URL-mode elicitation to a client that declared only form-mode support is rejected with an "
+ "Invalid params error."
+ ),
+ deferred=(
+ "Not implemented in the SDK: a Client with an elicitation callback always declares both the "
+ "form and url sub-capabilities, so a form-only client cannot be constructed."
+ ),
+ ),
+ "elicitation:url:required-error": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#url-elicitation-required-error",
+ behavior=(
+ "A handler that cannot proceed without a URL elicitation rejects the request with error "
+ "-32042, carrying the pending elicitations in the error data."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Roots (server → client)
+ # ═══════════════════════════════════════════════════════════════════════════
+ "roots:list-changed": Requirement(
+ source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",
+ behavior="A roots/list_changed notification sent by the client is delivered to the server's handler.",
+ ),
+ "roots:list-changed:client-emits": Requirement(
+ source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",
+ behavior=(
+ "A client that declared roots.listChanged sends notifications/roots/list_changed when its set "
+ "of roots changes."
+ ),
+ deferred=(
+ "Not implemented in the SDK: the client keeps no managed roots store, so nothing fires "
+ "automatically when the configured roots change; emission is an explicit "
+ "send_roots_list_changed() call (pinned by roots:list-changed)."
+ ),
+ ),
+ "roots:list:basic": Requirement(
+ source=f"{SPEC_BASE_URL}/client/roots#listing-roots",
+ behavior=(
+ "A roots/list request from a server handler is answered by the client's roots callback, and "
+ "the returned roots (uri, name) reach the handler."
+ ),
+ ),
+ "roots:list:client-error": Requirement(
+ source=f"{SPEC_BASE_URL}/client/roots#error-handling",
+ behavior="A roots callback that answers with an error surfaces to the requesting handler as an MCPError.",
+ ),
+ "roots:list:empty": Requirement(
+ source=f"{SPEC_BASE_URL}/client/roots#listing-roots",
+ behavior="An empty roots list is a valid response and reaches the handler as such.",
+ ),
+ "roots:list:not-supported": Requirement(
+ source=f"{SPEC_BASE_URL}/client/roots#error-handling",
+ behavior=(
+ "A roots/list request to a client that did not declare the roots capability is answered with "
+ "-32601 Method not found."
+ ),
+ divergence=Divergence(
+ note="The client's default callback answers with -32600 Invalid request instead of -32601.",
+ ),
+ ),
+ "roots:uri:file-scheme": Requirement(
+ source=f"{SPEC_BASE_URL}/client/roots#root",
+ behavior="Every root returned by the client identifies itself with a file:// URI.",
+ deferred=(
+ "Not yet covered here: planned gap test (the SDK's Root type enforces the file:// scheme; pin "
+ "it end-to-end through roots/list)."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # list_changed & dynamic registration
+ # ═══════════════════════════════════════════════════════════════════════════
+ "client:list-changed:auto-refresh": Requirement(
+ source="sdk",
+ behavior=(
+ "A client configured to react to list_changed notifications automatically re-fetches the "
+ "corresponding list and delivers the fresh result to its callback."
+ ),
+ deferred=(
+ "Not implemented in the SDK: the client has no list-changed auto-refresh mechanism; "
+ "notifications are only delivered to the message handler."
+ ),
+ ),
+ "client:list-changed:capability-gated": Requirement(
+ source="sdk",
+ behavior=(
+ "The client does not activate list-changed handling for a kind the server did not advertise "
+ "with listChanged true."
+ ),
+ deferred="Not implemented in the SDK: no client-side list-changed handling exists to gate.",
+ ),
+ "client:list-changed:signal-only": Requirement(
+ source="sdk",
+ behavior="A client configured for signal-only list-changed handling is notified without auto-refreshing.",
+ deferred="Not implemented in the SDK: no client-side list-changed handling exists.",
+ ),
+ "mcpserver:list-changed:debounce": Requirement(
+ source="sdk",
+ behavior=(
+ "Bursts of registration changes on MCPServer are debounced into one list_changed notification per kind."
+ ),
+ deferred=(
+ "Not implemented in the SDK: MCPServer does not send list_changed notifications on "
+ "registration changes at all (see mcpserver:register:post-connect), so there is nothing to "
+ "debounce."
+ ),
+ ),
+ "mcpserver:register:post-connect": Requirement(
+ source="sdk",
+ behavior=(
+ "A tool, resource, or prompt registered or removed after the client connected appears in (or "
+ "disappears from) the corresponding list results, and the change is announced with a "
+ "list_changed notification."
+ ),
+ divergence=Divergence(
+ note=(
+ "MCPServer never sends list_changed notifications on registration changes, so a connected "
+ "client cannot learn that the set changed without polling."
+ ),
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Pagination
+ # ═══════════════════════════════════════════════════════════════════════════
+ "pagination:exhaustion": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/pagination#response-format",
+ behavior=(
+ "Following nextCursor until it is absent yields every page exactly once; a result without "
+ "nextCursor ends the sequence."
+ ),
+ ),
+ "pagination:invalid-cursor": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/pagination#error-handling",
+ behavior="A list request with an invalid cursor returns JSON-RPC error -32602 (Invalid params).",
+ deferred="Not yet covered here: planned gap test (invalid pagination cursor rejected).",
+ ),
+ "pagination:client:cursor-handling": Requirement(
+ source=f"{SPEC_BASE_URL}/server/utilities/pagination#implementation-guidelines",
+ behavior=(
+ "The client treats cursors as opaque tokens — it does not parse, modify, or persist them — "
+ "and does not assume a fixed page size."
+ ),
+ deferred=(
+ "Not yet covered here: planned gap test (the client passes a server-issued cursor back "
+ "byte-for-byte and follows pages of varying sizes)."
+ ),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Tasks (experimental)
+ # ═══════════════════════════════════════════════════════════════════════════
+ "tasks:auth:context-isolation": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-isolation-and-access-control",
+ behavior=(
+ "When an authorization context is available, task operations are scoped to the context that "
+ "created the task: other contexts cannot get it, retrieve its result, cancel it, or see it in "
+ "tasks/list."
+ ),
+ transports=("streamable-http",),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:bidirectional": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#definitions",
+ behavior="Task APIs are bidirectional: the server may create, get, list, and cancel tasks on the client.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:cancel:no-handler-abort": Requirement(
+ source="sdk",
+ behavior=(
+ "tasks/cancel marks the task cancelled without aborting the originating request handler "
+ "(the spec says receivers SHOULD attempt to stop execution)."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:cancel:remains-cancelled": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-cancellation",
+ behavior=(
+ "After tasks/cancel, the task remains cancelled even if the underlying handler subsequently "
+ "completes or fails."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:cancel:terminal-rejected": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-cancellation",
+ behavior="tasks/cancel on a task already in a terminal state returns Invalid params (-32602).",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:cancel:working": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-cancellation",
+ behavior="tasks/cancel on a working task transitions it to cancelled and returns the updated task.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:create:ttl-honored": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#ttl-and-resource-management",
+ behavior=(
+ "tasks/get responses include the actual ttl applied by the receiver (or null for unlimited); "
+ "the create-task result carries the same value."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:create:via-tool-call": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#creating-tasks",
+ behavior="A task-augmented tools/call returns a create-task result instead of the tool result.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:get": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#getting-tasks",
+ behavior="tasks/get returns the task's current status, ttl, timestamps, and status message.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:lifecycle:initial-working": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-status-lifecycle",
+ behavior="A newly created task has status 'working'.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:lifecycle:input-required": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#input-required-status",
+ behavior=(
+ "While a task awaits a side-channel client response its status is input_required; once the "
+ "response arrives the task leaves input_required (typically returning to working)."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:list:invalid-cursor": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#protocol-errors",
+ behavior="tasks/list with an invalid cursor returns Invalid params (-32602).",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:list:pagination": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#listing-tasks",
+ behavior="tasks/list returns created tasks and supports cursor pagination.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:no-capability:ignore-task-param": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-support-and-handling",
+ behavior=(
+ "A receiver that did not declare task capability for a request type processes the request "
+ "normally and returns the ordinary result, ignoring the task augmentation."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:progress:after-create": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-progress-notifications",
+ behavior=(
+ "After the create-task result, progress notifications keyed to the original progress token "
+ "continue to reach the caller until the task is terminal."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:request-cancel:no-task-cancel": Requirement(
+ source="sdk",
+ behavior="A cancellation notification for the originating request does not auto-cancel the created task.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:result:failed": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-execution-errors",
+ behavior="tasks/result for a failed task returns the failure result (isError true).",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:result:related-task-meta": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#related-task-metadata",
+ behavior="The tasks/result response carries related-task _meta naming the requested task.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:result:terminal": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#result-retrieval",
+ behavior="tasks/result for a completed task returns the stored result of the original request type.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:side-channel:drain-fifo": Requirement(
+ source="sdk",
+ behavior="tasks/result drains queued related-task messages in FIFO order before returning the final result.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:side-channel:drop-on-cancel": Requirement(
+ source="sdk",
+ behavior="When a task is cancelled before tasks/result, queued related-task messages are dropped.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:side-channel:elicitation": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#input-required-status",
+ behavior=(
+ "An elicitation issued mid-task is delivered through the tasks/result side-channel, and the "
+ "client's response routes back to the handler."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:side-channel:queue": Requirement(
+ source="sdk",
+ behavior=(
+ "Server-to-client requests with related-task metadata sent while no tasks/result is open are queued."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:side-channel:sampling": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#input-required-status",
+ behavior=(
+ "A sampling request issued mid-task is delivered through the tasks/result side-channel, and "
+ "the client's response routes back to the task."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:side-channel:stream": Requirement(
+ source="sdk",
+ behavior=(
+ "Calling tasks/result while the task is working streams related-task messages as they are "
+ "produced, then returns the result."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:status-notification": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-status-notification",
+ behavior="Task status notifications deliver status updates carrying the full task fields.",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:tool-level:forbidden-with-task-32601": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#tool-level-negotiation",
+ behavior=(
+ "A task-augmented tools/call on a tool that does not support tasks returns Method not found (-32601)."
+ ),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:tool-level:required-no-task-32601": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#tool-level-negotiation",
+ behavior=("A plain tools/call on a tool that requires task augmentation returns Method not found (-32601)."),
+ deferred=_TASKS_DEFERRAL,
+ ),
+ "tasks:unknown-id": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/utilities/tasks#protocol-errors",
+ behavior="tasks/get, tasks/result, and tasks/cancel for an unknown task id return Invalid params (-32602).",
+ deferred=_TASKS_DEFERRAL,
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Transports (in-suite coverage)
+ # ═══════════════════════════════════════════════════════════════════════════
+ "transport:streamable-http:stateful": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
+ behavior=(
+ "The interaction round trip (initialize, tool calls, tool errors) works through the "
+ "streamable HTTP framing in its default stateful SSE-response mode."
+ ),
+ transports=("streamable-http",),
+ ),
+ "transport:streamable-http:json-response": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
+ behavior="The interaction round trip works when the server answers with plain JSON instead of SSE.",
+ transports=("streamable-http",),
+ ),
+ "transport:streamable-http:stateless": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
+ behavior=(
+ "The interaction round trip works in stateless mode, where every request is served by a "
+ "fresh transport with no session id."
+ ),
+ transports=("streamable-http",),
+ ),
+ "transport:streamable-http:notifications": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
+ behavior=(
+ "Notifications emitted during a request are delivered on that request's SSE stream and reach "
+ "the client's callbacks, in order, before the response."
+ ),
+ transports=("streamable-http",),
+ ),
+ "transport:streamable-http:stateless-restrictions": Requirement(
+ source="sdk",
+ behavior=(
+ "A handler that attempts a server-initiated request in stateless mode fails with an error "
+ "result, because there is no session to call back through."
+ ),
+ transports=("streamable-http",),
+ ),
+ "transport:streamable-http:unrelated-messages": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
+ behavior=(
+ "A server-to-client message that is not related to an in-flight request is routed to the "
+ "standalone GET stream and delivered to the client listening on it, not to any request's "
+ "own stream."
+ ),
+ transports=("streamable-http",),
+ ),
+ "transport:streamable-http:server-to-client": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
+ behavior=(
+ "A server-initiated request nested inside an in-flight call round-trips over stateful streamable HTTP."
+ ),
+ transports=("streamable-http",),
+ ),
+ "transport:streamable-http:resumability": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
+ behavior="A client that reconnects with Last-Event-ID receives the events it missed.",
+ transports=("streamable-http",),
+ deferred=(
+ "Replay requires dropping and re-establishing the SSE connection, which the in-process ASGI "
+ "client cannot express. Covered over a real socket by tests/shared/test_streamable_http.py."
+ ),
+ ),
+ "transport:streamable-http:origin-validation": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#security-warning",
+ behavior="Requests with an invalid Origin header are rejected with 403 before reaching the session.",
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here: the in-process fixture leaves the SDK's opt-in protection disabled (see "
+ "hosting:http:dns-rebinding); existing coverage in tests/server/test_streamable_http_security.py."
+ ),
+ ),
+ "transport:sse": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility",
+ behavior=(
+ "A client connected over the legacy HTTP+SSE transport completes the handshake and round-trips "
+ "requests, with server messages delivered on the SSE stream."
+ ),
+ transports=("sse",),
+ ),
+ "transport:sse:endpoint-event": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility",
+ behavior=(
+ "Opening the SSE stream delivers an `endpoint` event naming the message-POST URL and a fresh "
+ "session identifier; the server registers the session before the event is sent and releases it "
+ "when the stream disconnects."
+ ),
+ transports=("sse",),
+ ),
+ "transport:sse:post:session-routing": Requirement(
+ source="sdk",
+ behavior=(
+ "A POST to the SSE message endpoint that names no session id, a malformed session id, or an "
+ "unknown session id is rejected (400/400/404) instead of being forwarded."
+ ),
+ transports=("sse",),
+ ),
+ "transport:stdio": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#stdio",
+ behavior=(
+ "A Client connected to a real SDK Server over stdio initializes, calls a tool with arguments, "
+ "and receives notifications and results over the child process's stdin/stdout."
+ ),
+ transports=("stdio",),
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Hosting: session lifecycle
+ # ═══════════════════════════════════════════════════════════════════════════
+ "hosting:session:cors-expose": Requirement(
+ source="sdk",
+ behavior="CORS configuration exposes the Mcp-Session-Id header so browser clients can read it.",
+ transports=("streamable-http",),
+ deferred="Not implemented in the SDK: CORS configuration is left to the hosting ASGI application.",
+ ),
+ "hosting:session:create": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior=(
+ "An initialize POST without a session id creates a session and returns Mcp-Session-Id in the "
+ "response headers."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/shared/test_streamable_http.py and "
+ "tests/server/test_streamable_http_manager.py."
+ ),
+ ),
+ "hosting:session:delete": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior="DELETE with a valid Mcp-Session-Id terminates the session and removes its transport.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:session:id-charset": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior="Generated Mcp-Session-Id values contain only visible ASCII characters.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:session:isolation": Requirement(
+ source="sdk",
+ behavior="Each session gets its own server instance; closing one session does not affect others.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/server/test_streamable_http_manager.py.",
+ ),
+ "hosting:session:missing-id": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior="A non-initialize POST without Mcp-Session-Id in stateful mode returns 400.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:session:reinitialize": Requirement(
+ source="sdk",
+ behavior="A second initialize on an already-initialized session transport is rejected.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:session:reuse": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior="A POST carrying a valid Mcp-Session-Id routes to that session's transport with state preserved.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:session:unknown-id": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior="A POST, GET, or DELETE with an unknown Mcp-Session-Id returns 404.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:stateless:concurrent-clients": Requirement(
+ source="sdk",
+ behavior="Multiple independent clients can connect to a stateless server concurrently.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:stateless:no-reuse": Requirement(
+ source="sdk",
+ behavior="A stateless per-request transport cannot be reused for a second request.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:stateless:no-session-id": Requirement(
+ source="sdk",
+ behavior="In stateless mode no Mcp-Session-Id is emitted and no session validation is performed.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Hosting: auth
+ # ═══════════════════════════════════════════════════════════════════════════
+ "hosting:auth:as-router": Requirement(
+ source="sdk",
+ behavior=(
+ "The authorization-server routes expose the authorize, token, and registration endpoints "
+ "(and revocation when supported)."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/server/auth/; interaction-level coverage "
+ "planned with the auth tests in this suite."
+ ),
+ ),
+ "hosting:auth:aud-validation": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage",
+ behavior="The resource server validates that the token audience matches its resource identifier.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "hosting:auth:authinfo-propagates": Requirement(
+ source="sdk",
+ behavior="A valid token's auth info is exposed to request handlers.",
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/server/auth/; interaction-level coverage "
+ "planned with the auth tests in this suite."
+ ),
+ ),
+ "hosting:auth:expired-401": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#token-handling",
+ behavior="An expired token returns 401 invalid_token.",
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/server/auth/; interaction-level coverage "
+ "planned with the auth tests in this suite."
+ ),
+ ),
+ "hosting:auth:invalid-401": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#token-handling",
+ behavior="A malformed bearer token or token-verification failure returns 401 with WWW-Authenticate.",
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/server/auth/; interaction-level coverage "
+ "planned with the auth tests in this suite."
+ ),
+ ),
+ "hosting:auth:metadata-endpoints": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location",
+ behavior=(
+ "The MCP server publishes protected-resource metadata at its well-known endpoint, and the "
+ "authorization server (which the SDK can also host) publishes authorization-server metadata "
+ "at its own."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/server/auth/; interaction-level coverage "
+ "planned with the auth tests in this suite."
+ ),
+ ),
+ "hosting:auth:missing-401": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#protected-resource-metadata-discovery-requirements",
+ behavior=(
+ "A request without an Authorization header is rejected with 401; the WWW-Authenticate header "
+ "carries resource_metadata (one of the spec's two permitted discovery mechanisms)."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/server/auth/; interaction-level coverage "
+ "planned with the auth tests in this suite."
+ ),
+ ),
+ "hosting:auth:prm:authorization-servers-field": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location",
+ behavior=(
+ "The protected-resource metadata document includes an authorization_servers array with at least one entry."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "hosting:auth:scope-403": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#runtime-insufficient-scope-errors",
+ behavior=(
+ "A token lacking a required scope returns 403 with WWW-Authenticate carrying "
+ "insufficient_scope, the required scope, and resource_metadata."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Hosting: resumability
+ # ═══════════════════════════════════════════════════════════════════════════
+ "hosting:resume:bad-event-id": Requirement(
+ source="sdk",
+ behavior="A Last-Event-ID that cannot be mapped to a stream is rejected.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:resume:buffered-replay": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery",
+ behavior="Notifications emitted while no client is connected are replayed in order on reconnect.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:resume:close-stream": Requirement(
+ source="sdk",
+ behavior="Handlers can close an SSE stream cleanly when an event store is configured.",
+ transports=("streamable-http",),
+ deferred="Not implemented in the SDK: handlers have no API to close SSE streams.",
+ ),
+ "hosting:resume:event-ids": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery",
+ behavior="With an event store configured, every SSE event carries an id field.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:resume:priming": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior=(
+ "A server-initiated SSE stream begins with a priming event carrying an event ID and an empty "
+ "data field; a server that closes the connection before terminating the stream sends an SSE "
+ "retry field first."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: whether the python server emits priming events has not been pinned.",
+ ),
+ "hosting:resume:replay": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery",
+ behavior="GET with Last-Event-ID replays stored events for that stream after the given id.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:resume:stream-scoped": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery",
+ behavior="Replay via Last-Event-ID returns only messages from the stream that event id belongs to.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Hosting: HTTP semantics
+ # ═══════════════════════════════════════════════════════════════════════════
+ "hosting:http:accept-406": Requirement(
+ source="sdk",
+ behavior="A request whose Accept header does not allow the response representation returns 406.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:batch": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior=(
+ "A POST body is a single JSON-RPC message; batched arrays are rejected for protocol revisions "
+ "that forbid them."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:content-type-415": Requirement(
+ source="sdk",
+ behavior="A POST with a Content-Type other than application/json returns 415.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:disconnect-not-cancel": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior=(
+ "A client connection drop during an in-flight request does not cancel the server-side "
+ "handler; the request continues and its result remains retrievable."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:http:dns-rebinding": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#security-warning",
+ behavior=(
+ "The Origin header is validated on every incoming connection; a request with an invalid "
+ "Origin is rejected with 403 Forbidden."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/server/test_streamable_http_security.py. "
+ "The SDK's protection is opt-in and disabled by default (no TransportSecuritySettings means "
+ "no Origin validation), and it also checks Host — the off-by-default gap is one to record as "
+ "a divergence when the transport conformance tests land."
+ ),
+ ),
+ "hosting:http:json-response-mode": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior="With JSON response mode enabled, POST returns application/json instead of SSE.",
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/shared/test_streamable_http.py and the "
+ "json-response tests in this suite's transports directory."
+ ),
+ ),
+ "hosting:http:method-405": Requirement(
+ source="sdk",
+ behavior="An unsupported HTTP method on the MCP endpoint returns 405.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:no-broadcast": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#multiple-connections",
+ behavior=(
+ "When multiple SSE streams are open for a session, each server-originated message is sent on "
+ "exactly one stream, never duplicated."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:http:notifications-202": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior="A POST containing only notifications or responses returns 202 with no body.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:onerror": Requirement(
+ source="sdk",
+ behavior="Transport-level rejections are reported through an error callback on the server transport.",
+ transports=("streamable-http",),
+ deferred="Not implemented in the SDK: the server transport has no error callback; rejections are logged.",
+ ),
+ "hosting:http:parse-error-400": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior=(
+ "A POST body that is not valid JSON or not a valid JSON-RPC message is rejected with HTTP 400; "
+ "the body may carry a JSON-RPC error response (the SDK sends a Parse error body)."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:protocol-version-400": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#protocol-version-header",
+ behavior="An invalid or unsupported MCP-Protocol-Version header returns 400 Bad Request.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:protocol-version-default": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#protocol-version-header",
+ behavior=(
+ "When no MCP-Protocol-Version header is received and the version cannot be determined another "
+ "way, the server assumes protocol version 2025-03-26."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:http:response-same-connection": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior=(
+ "A response is delivered on the SSE stream opened by the POST that carried its request (or "
+ "that stream's resumed continuation), not on an unrelated stream."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:http:second-sse-rejected": Requirement(
+ source="sdk",
+ behavior="A second concurrent standalone GET SSE stream on the same session is rejected.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:http:sse-close-after-response": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior="The server terminates a POST-initiated SSE stream after writing the JSON-RPC response.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "hosting:http:standalone-sse": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server",
+ behavior="GET opens a standalone SSE stream that receives server-initiated messages.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "hosting:http:standalone-sse-no-response": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server",
+ behavior=(
+ "The standalone GET SSE stream carries server requests and notifications but never a JSON-RPC "
+ "response, except when resuming a prior request stream."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Client transport: streamable HTTP
+ # ═══════════════════════════════════════════════════════════════════════════
+ "client-transport:http:404-surfaces": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior=(
+ "A 404 in response to a request carrying a session ID makes the client start a new session "
+ "with a fresh InitializeRequest and no session ID attached."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not implemented in the SDK: the client surfaces the 404 as an error to the caller instead of "
+ "re-initializing a new session."
+ ),
+ ),
+ "client-transport:http:accept-header-get": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server",
+ behavior="The client GET to the MCP endpoint includes an Accept header listing text/event-stream.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "client-transport:http:accept-header-post": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior=(
+ "Every client POST to the MCP endpoint includes an Accept header listing both application/json "
+ "and text/event-stream."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "client-transport:http:concurrent-streams": Requirement(
+ source="sdk",
+ behavior="Multiple concurrent POST-initiated SSE streams each deliver their response to the right caller.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:custom-client": Requirement(
+ source="sdk",
+ behavior=(
+ "A caller-supplied HTTP client (and its event hooks and headers) is used for all MCP traffic, "
+ "including auth flows."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:custom-headers": Requirement(
+ source="sdk",
+ behavior="Caller-supplied headers are sent on every POST, GET, and DELETE to the MCP endpoint.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:json-response-parsed": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior="A Content-Type application/json response is parsed as a single JSON-RPC message.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "client-transport:http:no-reconnect-after-close": Requirement(
+ source="sdk",
+ behavior="After the transport is closed, no further reconnection attempts are scheduled.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:no-reconnect-after-response": Requirement(
+ source="sdk",
+ behavior="A POST-initiated stream that already delivered its response is not reconnected when it closes.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:protocol-version-header": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#protocol-version-header",
+ behavior=(
+ "After initialization, the client sends the negotiated MCP-Protocol-Version header on every "
+ "subsequent HTTP request."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "client-transport:http:protocol-version-stored": Requirement(
+ source="sdk",
+ behavior="The client transport exposes the negotiated protocol version once initialization completes.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:reconnect-get": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery",
+ behavior=(
+ "A standalone GET SSE stream that errors is reconnected with the Last-Event-ID of the last received event."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "client-transport:http:reconnect-post-priming": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior=(
+ "A POST-initiated SSE stream that errors before delivering its response is reconnected only "
+ "if a priming event (an event carrying an ID) was received on it."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:reconnect-retry-value": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
+ behavior="Reconnection delay honours the server-provided SSE retry value when one was sent.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:resume-stream-api": Requirement(
+ source="sdk",
+ behavior=(
+ "The client can capture a resumption token, reconnect with the same session id, and receive "
+ "the notifications it missed."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "client-transport:http:session-stored": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior=(
+ "The Mcp-Session-Id returned by initialize is stored by the client transport and sent on "
+ "every subsequent request."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "client-transport:http:sse-405-tolerated": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server",
+ behavior="Opening the standalone GET SSE stream tolerates a 405 response without failing the connection.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "client-transport:http:terminate-405-ok": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior="Session termination succeeds without error if the server answers 405 (termination unsupported).",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Client auth
+ # ═══════════════════════════════════════════════════════════════════════════
+ "client-auth:401-after-auth-throws": Requirement(
+ source="sdk",
+ behavior=(
+ "If the server still returns 401 after a successful authorization, the client fails instead of looping."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:401-triggers-flow": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#protected-resource-metadata-discovery-requirements",
+ behavior="A 401 on a request triggers the OAuth authorization flow once.",
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:403-scope-upgrade": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
+ behavior=(
+ "A 403 with WWW-Authenticate triggers a scope-upgrade authorization attempt; repeated 403s do not loop."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:as-metadata-discovery:priority-order": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
+ behavior=(
+ "The client discovers authorization-server metadata by trying, in order, the OAuth "
+ "path-inserted, OIDC path-inserted, and OIDC path-appended well-known URLs (with the "
+ "root-path forms when the issuer URL has no path)."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:bearer-header:every-request": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#token-requirements",
+ behavior=(
+ "Once authorized, the client sends the bearer token in the Authorization header on every HTTP "
+ "request to the MCP server, never in the query string."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:cimd": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#client-id-metadata-documents",
+ behavior="The client can use a client-ID metadata document URL as its OAuth client_id instead of registration.",
+ transports=("streamable-http",),
+ deferred="Not implemented in the SDK: client-ID metadata documents are not supported.",
+ ),
+ "client-auth:client-credentials": Requirement(
+ source="sdk",
+ behavior=(
+ "A client-credentials provider obtains a token without user interaction and the resulting "
+ "bearer token authorizes subsequent requests."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/auth/; interaction-level coverage "
+ "planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:dcr": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#dynamic-client-registration",
+ behavior=(
+ "The client performs dynamic client registration against the authorization server when no "
+ "client_id is preconfigured."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:invalid-client-clears-all": Requirement(
+ source="sdk",
+ behavior=(
+ "An invalid-client or unauthorized-client error during authorization invalidates all stored credentials."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:invalid-grant-clears-tokens": Requirement(
+ source="sdk",
+ behavior="An invalid-grant error during authorization invalidates only the stored tokens.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:pkce:refuse-if-unsupported": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection",
+ behavior=(
+ "The client refuses to proceed when the authorization server's metadata does not include "
+ "code_challenge_methods_supported, since PKCE support cannot be verified."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:pkce:s256": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection",
+ behavior=(
+ "The authorization request includes a PKCE S256 code challenge and the token request includes "
+ "the matching verifier."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:pre-registration": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#preregistration",
+ behavior=(
+ "A client with statically preconfigured credentials skips dynamic registration and uses them directly."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:private-key-jwt": Requirement(
+ source="sdk",
+ behavior="The client can authenticate the client-credentials grant with a signed JWT assertion.",
+ transports=("streamable-http",),
+ deferred="Not implemented in the SDK: JWT-assertion client authentication is not supported.",
+ ),
+ "client-auth:prm-discovery:fallback-order": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#protected-resource-metadata-discovery-requirements",
+ behavior=(
+ "The client uses resource_metadata from WWW-Authenticate when present, then falls back to the "
+ "well-known protected-resource locations in the documented order."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:prm-resource-mismatch": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location",
+ behavior=(
+ "The client refuses to proceed when the protected-resource metadata's resource field does not "
+ "match the server URL it is connecting to."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:resource-parameter": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#resource-parameter-implementation",
+ behavior=(
+ "The client includes the canonical server URI as the resource parameter in both the "
+ "authorization request and the token request."
+ ),
+ transports=("streamable-http",),
+ deferred=(
+ "Not yet covered here; existing coverage in tests/client/test_auth.py; interaction-level "
+ "coverage planned with the auth tests in this suite."
+ ),
+ ),
+ "client-auth:scope-selection:priority": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#scope-selection-strategy",
+ behavior=(
+ "The client selects the requested scope from WWW-Authenticate when present, then from the "
+ "protected-resource metadata, and otherwise omits scope."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:state:verify": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#open-redirection",
+ behavior=(
+ "A state parameter is included in the authorization URL, and authorization results with a "
+ "missing or mismatched state are discarded."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:token-endpoint-auth-method": Requirement(
+ source="sdk",
+ behavior="The client authenticates to the token endpoint using the auth method established at registration.",
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "client-auth:token-provenance": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#token-handling",
+ behavior=(
+ "The client sends the MCP server only tokens issued by that server's authorization server, "
+ "never tokens obtained elsewhere."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # stdio transport
+ # ═══════════════════════════════════════════════════════════════════════════
+ "transport:stdio:clean-shutdown": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#shutdown",
+ behavior="Closing the client transport closes the child process's stdin and the server exits cleanly.",
+ transports=("stdio",),
+ ),
+ "transport:stdio:stream-purity": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#stdio",
+ behavior=(
+ "Nothing that is not a valid MCP message is written to the server's stdout, and nothing that "
+ "is not a valid MCP message is written to its stdin."
+ ),
+ transports=("stdio",),
+ deferred="Not yet covered here: planned with the stdio end-to-end test.",
+ ),
+ "transport:stdio:no-embedded-newlines": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#stdio",
+ behavior="Serialized JSON-RPC messages on stdio contain no embedded newlines; one message per line.",
+ transports=("stdio",),
+ deferred="Not yet covered here: planned with the stdio end-to-end test.",
+ ),
+ "transport:stdio:shutdown-escalation": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/lifecycle#stdio",
+ behavior=(
+ "If the server process does not exit after stdin is closed, the client transport terminates "
+ "it (and kills it if still alive) after a grace period."
+ ),
+ transports=("stdio",),
+ deferred="Not yet covered here; existing coverage in tests/client/test_stdio.py.",
+ ),
+ "transport:stdio:stderr-passthrough": Requirement(
+ source="sdk",
+ behavior="Server stderr is available to the client and is not consumed by the transport.",
+ transports=("stdio",),
+ deferred="Not yet covered here; existing coverage in tests/client/test_stdio.py.",
+ ),
+ # ═══════════════════════════════════════════════════════════════════════════
+ # Composite end-to-end flows
+ # ═══════════════════════════════════════════════════════════════════════════
+ "flow:compat:dual-transport-server": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility",
+ behavior=(
+ "A single server instance can serve streamable HTTP and the legacy SSE transport "
+ "concurrently; clients on either transport can call the same tools."
+ ),
+ transports=("streamable-http", "sse"),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "flow:compat:streamable-then-sse-fallback": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility",
+ behavior=(
+ "When a streamable HTTP initialize fails with 400, 404, or 405, falling back to the legacy "
+ "SSE client transport against the same server connects successfully."
+ ),
+ transports=("streamable-http", "sse"),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "flow:elicitation:multi-step-form": Requirement(
+ source="sdk",
+ behavior=(
+ "A single tool handler issues sequential elicitations; an accept on one step feeds the next, "
+ "and a decline or cancel at any step short-circuits to a final result."
+ ),
+ deferred="Not yet covered here: planned gap test (multi-step elicitation flow).",
+ ),
+ "flow:elicitation:url-at-session-init": Requirement(
+ source="sdk",
+ behavior=(
+ "The server can issue a URL-mode elicitation over the standalone GET stream immediately after "
+ "session initialization, before any client request."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "flow:elicitation:url-required-then-retry": Requirement(
+ source=f"{SPEC_BASE_URL}/client/elicitation#url-elicitation-required-error",
+ behavior=(
+ "A tool call rejected with the URL-elicitation-required error can be retried successfully "
+ "after the client completes the URL flow and the server announces completion."
+ ),
+ deferred="Not yet covered here: planned gap test (full URL-elicitation-required retry flow).",
+ ),
+ "flow:multi-client:stateful-isolation": Requirement(
+ source="sdk",
+ behavior=(
+ "Independent clients connected to one stateful server each receive a distinct session and "
+ "only the notifications produced by their own requests."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "flow:oauth:authorization-code-roundtrip": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/authorization#authorization-flow-steps",
+ behavior=(
+ "Connecting to a protected server walks the authorization-code flow end to end: the first "
+ "attempt requires authorization, the code is exchanged, and a subsequent connection succeeds."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the auth interaction tests in this suite.",
+ ),
+ "flow:resume:tool-call-resumption-token": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery",
+ behavior=(
+ "A tool call interrupted mid-stream can be resumed with the captured resumption token, "
+ "delivering only the remaining notifications and the final result."
+ ),
+ transports=("streamable-http",),
+ deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
+ ),
+ "flow:session:terminate-then-reconnect": Requirement(
+ source=f"{SPEC_BASE_URL}/basic/transports#session-management",
+ behavior=("After terminating a session, a fresh connection obtains a new session id and operations succeed."),
+ transports=("streamable-http",),
+ deferred="Not yet covered here: planned with the transport conformance work.",
+ ),
+ "flow:tool-result:resource-link-follow": Requirement(
+ source=f"{SPEC_BASE_URL}/server/tools#resource-links",
+ behavior=(
+ "A resource_link returned by a tool call can be followed with resources/read on the linked "
+ "URI to retrieve the referenced contents."
+ ),
+ deferred="Not yet covered here: planned gap test (follow a resource link returned by a tool).",
+ ),
+}
+
+
+def requirement(requirement_id: str) -> Callable[[_TestFn], _TestFn]:
+ """Mark a test as exercising a requirement from :data:`REQUIREMENTS`.
+
+ Applies the `requirement` pytest marker and records the coverage link checked by
+ `test_coverage.py`. Unknown IDs fail at import time so a typo surfaces as a collection
+ error on the offending test, not as a missing-coverage report later.
+ """
+ if requirement_id not in REQUIREMENTS:
+ raise KeyError(f"Unknown requirement id {requirement_id!r}: add it to REQUIREMENTS in {__name__}")
+
+ def apply(test_fn: _TestFn) -> _TestFn:
+ covered_by(requirement_id).append(f"{test_fn.__module__}.{test_fn.__qualname__}")
+ return pytest.mark.requirement(requirement_id)(test_fn)
+
+ return apply
+
+
+_COVERAGE: dict[str, list[str]] = {}
+
+
+def covered_by(requirement_id: str) -> list[str]:
+ """Return the (mutable) list of test names recorded as exercising `requirement_id`."""
+ return _COVERAGE.setdefault(requirement_id, [])
diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py
new file mode 100644
index 0000000000..c2ace45077
--- /dev/null
+++ b/tests/interaction/conftest.py
@@ -0,0 +1,23 @@
+"""Shared fixtures for the interaction suite."""
+
+import pytest
+
+from tests.interaction._connect import Connect, connect_in_memory, connect_over_sse, connect_over_streamable_http
+
+_FACTORIES: dict[str, Connect] = {
+ "in-memory": connect_in_memory,
+ "streamable-http": connect_over_streamable_http,
+ "sse": connect_over_sse,
+}
+
+
+@pytest.fixture(params=sorted(_FACTORIES))
+def connect(request: pytest.FixtureRequest) -> Connect:
+ """The transport-parametrized connection factory: a test using it runs once per transport.
+
+ Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests,
+ the transport-specific tests under transports/) do not use this fixture and connect directly.
+ """
+ transport_name = request.param
+ assert isinstance(transport_name, str)
+ return _FACTORIES[transport_name]
diff --git a/tests/interaction/lowlevel/__init__.py b/tests/interaction/lowlevel/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py
new file mode 100644
index 0000000000..eb07ef9404
--- /dev/null
+++ b/tests/interaction/lowlevel/test_cancellation.py
@@ -0,0 +1,139 @@
+"""Cancellation interactions against the low-level Server, driven through the public Client API.
+
+There is no client-side cancellation API: cancelling means sending a CancelledNotification
+carrying the request id, which only the server-side handler can observe (`ctx.request_id`), so
+these tests capture the id from inside the blocked handler before cancelling. The handler blocks
+on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
+"""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import CallToolResult, ErrorData, TextContent
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("protocol:cancel:in-flight")
+@requirement("protocol:cancel:handler-abort-propagates")
+async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None:
+ """Cancelling an in-flight request interrupts its handler and fails the pending call.
+
+ The server answers the cancelled request with an error response (the spec says it should
+ not respond at all; see the divergence note on the requirement), so the caller's pending
+ request raises rather than hanging.
+ """
+ started = anyio.Event()
+ handler_cancelled = anyio.Event()
+ request_ids: list[types.RequestId] = []
+ errors: list[ErrorData] = []
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "block"
+ assert ctx.request_id is not None
+ request_ids.append(ctx.request_id)
+ started.set()
+ try:
+ await anyio.Event().wait() # blocks until cancelled; nothing ever sets this event
+ except anyio.get_cancelled_exc_class():
+ handler_cancelled.set()
+ raise
+ raise NotImplementedError # unreachable: the wait above never completes normally
+
+ server = Server("blocker", on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ with anyio.fail_after(5):
+ async with anyio.create_task_group() as task_group:
+
+ async def call_and_capture_error() -> None:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("block", {})
+ errors.append(exc_info.value.error)
+
+ task_group.start_soon(call_and_capture_error)
+ await started.wait()
+ await client.session.send_notification(
+ types.CancelledNotification(
+ params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted")
+ )
+ )
+
+ await handler_cancelled.wait()
+
+ assert errors == snapshot([ErrorData(code=0, message="Request cancelled")])
+
+
+@requirement("protocol:cancel:server-survives")
+async def test_session_serves_requests_after_cancellation(connect: Connect) -> None:
+ """A request cancelled mid-flight does not poison the session: the next request succeeds."""
+ started = anyio.Event()
+ request_ids: list[types.RequestId] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(name="block", input_schema={"type": "object"}),
+ types.Tool(name="echo", input_schema={"type": "object"}),
+ ]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ if params.name == "echo":
+ return CallToolResult(content=[TextContent(text="still alive")])
+ assert ctx.request_id is not None
+ request_ids.append(ctx.request_id)
+ started.set()
+ await anyio.Event().wait() # blocks until cancelled
+ raise NotImplementedError # unreachable
+
+ server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ with anyio.fail_after(5):
+ async with anyio.create_task_group() as task_group:
+
+ async def call_and_swallow_cancellation_error() -> None:
+ with pytest.raises(MCPError):
+ await client.call_tool("block", {})
+
+ task_group.start_soon(call_and_swallow_cancellation_error)
+ await started.wait()
+ await client.session.send_notification(
+ types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0]))
+ )
+
+ result = await client.call_tool("echo", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
+
+
+@requirement("protocol:cancel:unknown-id-ignored")
+async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None:
+ """A cancellation referencing a request id that is not in flight is ignored without error."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="echo", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "echo"
+ return CallToolResult(content=[TextContent(text="unbothered")])
+
+ server = Server("calm", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ await client.session.send_notification(
+ types.CancelledNotification(params=types.CancelledNotificationParams(request_id=9999))
+ )
+ result = await client.call_tool("echo", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
diff --git a/tests/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py
new file mode 100644
index 0000000000..e036d48c3c
--- /dev/null
+++ b/tests/interaction/lowlevel/test_completion.py
@@ -0,0 +1,108 @@
+"""Completion interactions against the low-level Server, driven through the public Client API."""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ METHOD_NOT_FOUND,
+ CompleteResult,
+ Completion,
+ ErrorData,
+ PromptReference,
+ ResourceTemplateReference,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("completion:prompt-arg")
+@requirement("completion:result-shape")
+async def test_complete_prompt_argument(connect: Connect) -> None:
+ """Completing a prompt argument delivers the ref, argument name, and current value to the handler.
+
+ The returned values are filtered by the argument's value, proving the value reached the handler.
+ """
+
+ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
+ assert isinstance(params.ref, PromptReference)
+ assert params.ref.name == "code_review"
+ assert params.argument.name == "language"
+ candidates = ["python", "pytorch", "ruby"]
+ matches = [candidate for candidate in candidates if candidate.startswith(params.argument.value)]
+ return CompleteResult(completion=Completion(values=matches, total=len(matches), has_more=False))
+
+ server = Server("completer", on_completion=completion)
+
+ async with connect(server) as client:
+ result = await client.complete(
+ PromptReference(name="code_review"), argument={"name": "language", "value": "py"}
+ )
+
+ assert result == snapshot(
+ CompleteResult(completion=Completion(values=["python", "pytorch"], total=2, has_more=False))
+ )
+
+
+@requirement("completion:resource-template-arg")
+async def test_complete_resource_template_variable(connect: Connect) -> None:
+ """Completing a URI template variable delivers the template URI and variable name to the handler."""
+
+ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
+ assert isinstance(params.ref, ResourceTemplateReference)
+ assert params.ref.uri == "github://repos/{owner}/{repo}"
+ assert params.argument.name == "owner"
+ return CompleteResult(completion=Completion(values=[f"{params.argument.value}contextprotocol"]))
+
+ server = Server("completer", on_completion=completion)
+
+ async with connect(server) as client:
+ result = await client.complete(
+ ResourceTemplateReference(uri="github://repos/{owner}/{repo}"),
+ argument={"name": "owner", "value": "model"},
+ )
+
+ assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"])))
+
+
+@requirement("completion:context-arguments")
+async def test_complete_receives_context_arguments(connect: Connect) -> None:
+ """Previously-resolved arguments passed as completion context reach the handler.
+
+ The returned value is derived from the context, proving it arrived.
+ """
+
+ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
+ assert params.argument.name == "repo"
+ assert params.context is not None
+ assert params.context.arguments is not None
+ return CompleteResult(completion=Completion(values=[f"{params.context.arguments['owner']}/python-sdk"]))
+
+ server = Server("completer", on_completion=completion)
+
+ async with connect(server) as client:
+ result = await client.complete(
+ ResourceTemplateReference(uri="github://repos/{owner}/{repo}"),
+ argument={"name": "repo", "value": ""},
+ context_arguments={"owner": "modelcontextprotocol"},
+ )
+
+ assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"])))
+
+
+@requirement("completion:complete:not-supported")
+@requirement("protocol:error:method-not-found")
+async def test_complete_without_handler_is_method_not_found(connect: Connect) -> None:
+ """A server with no completion handler advertises no completions capability and rejects the request."""
+ server = Server("incomplete")
+
+ async with connect(server) as client:
+ assert client.initialize_result.capabilities.completions is None
+
+ with pytest.raises(MCPError) as exc_info:
+ await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""})
+
+ assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
diff --git a/tests/interaction/lowlevel/test_elicitation.py b/tests/interaction/lowlevel/test_elicitation.py
new file mode 100644
index 0000000000..d27613dd36
--- /dev/null
+++ b/tests/interaction/lowlevel/test_elicitation.py
@@ -0,0 +1,375 @@
+"""Form- and URL-mode elicitation against the low-level Server, driven through the public Client API."""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, UrlElicitationRequiredError, types
+from mcp.client import ClientRequestContext
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ CallToolResult,
+ ElicitCompleteNotification,
+ ElicitCompleteNotificationParams,
+ ElicitRequestFormParams,
+ ElicitRequestURLParams,
+ ElicitResult,
+ ErrorData,
+ TextContent,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._helpers import IncomingMessage
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+REQUESTED_SCHEMA: dict[str, object] = {
+ "type": "object",
+ "properties": {
+ "username": {"type": "string"},
+ "newsletter": {"type": "boolean"},
+ },
+ "required": ["username"],
+}
+
+
+@requirement("elicitation:form:action:accept")
+@requirement("elicitation:form:basic")
+@requirement("tools:call:elicitation-roundtrip")
+async def test_elicit_form_accepted_content_returns_to_handler(connect: Connect) -> None:
+ """An accepted form elicitation returns the user's content to the requesting handler.
+
+ The tool reports the action as text and the received content as structured content, proving
+ the client's answer made it back into the tool's own result.
+ """
+ received: list[types.ElicitRequestParams] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="signup", description="Register the user.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "signup"
+ answer = await ctx.session.elicit_form("Choose a username.", REQUESTED_SCHEMA)
+ return CallToolResult(content=[TextContent(text=answer.action)], structured_content=answer.content)
+
+ server = Server("registrar", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
+ received.append(params)
+ return ElicitResult(action="accept", content={"username": "ada", "newsletter": True})
+
+ async with connect(server, elicitation_callback=answer_form) as client:
+ result = await client.call_tool("signup", {})
+
+ assert received == snapshot(
+ [
+ ElicitRequestFormParams(
+ _meta={},
+ message="Choose a username.",
+ requested_schema={
+ "type": "object",
+ "properties": {
+ "username": {"type": "string"},
+ "newsletter": {"type": "boolean"},
+ },
+ "required": ["username"],
+ },
+ )
+ ]
+ )
+ assert result == snapshot(
+ CallToolResult(
+ content=[TextContent(text="accept")],
+ structured_content={"username": "ada", "newsletter": True},
+ )
+ )
+
+
+@requirement("elicitation:form:action:decline")
+async def test_elicit_form_decline_returns_no_content(connect: Connect) -> None:
+ """A declined form elicitation returns the decline action to the handler with no content."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "confirm"
+ answer = await ctx.session.elicit_form("Proceed?", {"type": "object", "properties": {}})
+ return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
+
+ server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
+ return ElicitResult(action="decline")
+
+ async with connect(server, elicitation_callback=answer_form) as client:
+ result = await client.call_tool("confirm", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="decline content=None")]))
+
+
+@requirement("elicitation:form:action:cancel")
+async def test_elicit_form_cancel_returns_no_content(connect: Connect) -> None:
+ """A cancelled form elicitation returns the cancel action to the handler with no content."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "confirm"
+ answer = await ctx.session.elicit_form("Proceed?", {"type": "object", "properties": {}})
+ return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
+
+ server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
+ return ElicitResult(action="cancel")
+
+ async with connect(server, elicitation_callback=answer_form) as client:
+ result = await client.call_tool("confirm", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="cancel content=None")]))
+
+
+@requirement("elicitation:form:not-supported")
+async def test_elicit_form_without_callback_is_error(connect: Connect) -> None:
+ """Eliciting from a client that configured no elicitation callback fails with an error.
+
+ The client's default callback answers with an Invalid request error, which the server-side
+ elicit call raises as an MCPError; the tool reports the code and message it caught. The spec
+ requires -32602 for an undeclared mode (see the divergence note on the requirement).
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="ask", description="Ask the user.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "ask"
+ try:
+ await ctx.session.elicit_form("Anyone there?", {"type": "object", "properties": {}})
+ except MCPError as exc:
+ return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
+ raise NotImplementedError # elicit_form cannot succeed without a client callback
+
+ server = Server("asker", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("ask", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: Elicitation not supported")]))
+
+
+@requirement("elicitation:url:action:accept-no-content")
+@requirement("elicitation:url:basic")
+async def test_elicit_url_delivers_url_and_returns_accept_without_content(connect: Connect) -> None:
+ """A URL elicitation delivers the message, URL, and elicitation id to the client; accepting it
+ returns the action with no content.
+
+ Accept means the user agreed to visit the URL, not that the out-of-band interaction finished,
+ so there is never form content to return.
+ """
+ received: list[types.ElicitRequestParams] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="authorize", description="Link an account.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "authorize"
+ answer = await ctx.session.elicit_url(
+ "Authorize access to your calendar.", "https://example.com/oauth/authorize", "auth-001"
+ )
+ return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
+
+ server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
+ received.append(params)
+ return ElicitResult(action="accept")
+
+ async with connect(server, elicitation_callback=answer_url) as client:
+ result = await client.call_tool("authorize", {})
+
+ assert received == snapshot(
+ [
+ ElicitRequestURLParams(
+ _meta={},
+ message="Authorize access to your calendar.",
+ url="https://example.com/oauth/authorize",
+ elicitation_id="auth-001",
+ )
+ ]
+ )
+ assert result == snapshot(CallToolResult(content=[TextContent(text="accept content=None")]))
+
+
+@requirement("elicitation:url:decline")
+async def test_elicit_url_decline_returns_no_content(connect: Connect) -> None:
+ """A declined URL elicitation returns the decline action to the handler with no content."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="authorize", description="Link an account.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "authorize"
+ answer = await ctx.session.elicit_url(
+ "Authorize access to your calendar.", "https://example.com/oauth/authorize", "auth-001"
+ )
+ return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
+
+ server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
+ return ElicitResult(action="decline")
+
+ async with connect(server, elicitation_callback=answer_url) as client:
+ result = await client.call_tool("authorize", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="decline content=None")]))
+
+
+@requirement("elicitation:url:cancel")
+async def test_elicit_url_cancel_returns_no_content(connect: Connect) -> None:
+ """A cancelled URL elicitation returns the cancel action to the handler with no content."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="authorize", description="Link an account.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "authorize"
+ answer = await ctx.session.elicit_url(
+ "Authorize access to your calendar.", "https://example.com/oauth/authorize", "auth-001"
+ )
+ return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
+
+ server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
+ return ElicitResult(action="cancel")
+
+ async with connect(server, elicitation_callback=answer_url) as client:
+ result = await client.call_tool("authorize", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="cancel content=None")]))
+
+
+@requirement("elicitation:url:complete-notification")
+async def test_elicitation_complete_notification_carries_the_elicited_id_back_to_the_client(connect: Connect) -> None:
+ """After a URL elicitation finishes, the server announces it with a notification carrying the same id.
+
+ The lifecycle under test: the tool elicits a URL interaction with an elicitationId, the user
+ agrees to visit the URL, the out-of-band interaction finishes, and the server emits
+ elicitation/complete so the client can correlate the completion with the elicitation it
+ accepted earlier. Both messages arrive before the tool call returns, so a plain collected
+ list needs no synchronisation.
+ """
+ elicitation_id = "auth-001"
+ elicited_ids: list[str] = []
+ received: list[IncomingMessage] = []
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="link_account", description="Link an account.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "link_account"
+ answer = await ctx.session.elicit_url(
+ "Authorize access to your files.", "https://example.com/oauth/authorize", elicitation_id
+ )
+ assert answer.action == "accept"
+ await ctx.session.send_elicit_complete(elicitation_id)
+ return CallToolResult(content=[TextContent(text="linked")])
+
+ server = Server("authorizer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
+ assert isinstance(params, ElicitRequestURLParams)
+ elicited_ids.append(params.elicitation_id)
+ return ElicitResult(action="accept")
+
+ async with connect(server, message_handler=collect, elicitation_callback=answer_url) as client:
+ await client.call_tool("link_account", {})
+
+ # The completion notification refers to the same elicitation the client accepted.
+ assert elicited_ids == [elicitation_id]
+ assert received == snapshot(
+ [ElicitCompleteNotification(params=ElicitCompleteNotificationParams(elicitation_id="auth-001"))]
+ )
+
+
+@requirement("elicitation:url:required-error")
+async def test_url_elicitation_required_error_carries_pending_elicitations(connect: Connect) -> None:
+ """A request that cannot proceed until a URL interaction completes is rejected with error -32042.
+
+ This is the non-interactive alternative to elicit_url: instead of asking and waiting, the
+ handler rejects the whole request and lists the required URL elicitations in the error data.
+ The client is expected to present those URLs, wait for the matching elicitation/complete
+ notifications, and retry the original request.
+ """
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "read_files"
+ raise UrlElicitationRequiredError(
+ [
+ ElicitRequestURLParams(
+ message="Authorization required for your files.",
+ url="https://example.com/oauth/authorize",
+ elicitation_id="auth-001",
+ )
+ ]
+ )
+
+ server = Server("authorizer", on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("read_files", {})
+
+ assert exc_info.value.error == snapshot(
+ ErrorData(
+ code=-32042,
+ message="URL elicitation required",
+ data={
+ "elicitations": [
+ {
+ "mode": "url",
+ "message": "Authorization required for your files.",
+ "url": "https://example.com/oauth/authorize",
+ "elicitationId": "auth-001",
+ }
+ ]
+ },
+ )
+ )
diff --git a/tests/interaction/lowlevel/test_initialize.py b/tests/interaction/lowlevel/test_initialize.py
new file mode 100644
index 0000000000..32da2f3338
--- /dev/null
+++ b/tests/interaction/lowlevel/test_initialize.py
@@ -0,0 +1,333 @@
+"""Initialization handshake against the low-level Server, driven through the public Client API.
+
+The later tests drive a bare ClientSession over an InMemoryTransport instead: Client always
+performs the full handshake with the latest protocol version, so skipping initialization or
+requesting a different version can only be expressed one level down. The final test goes one step
+further and plays the server's side of the wire by hand, because no real Server can be made to
+answer initialize with an unsupported protocol version.
+"""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.client import ClientRequestContext, ClientSession
+from mcp.client._memory import InMemoryTransport
+from mcp.server import Server, ServerRequestContext
+from mcp.shared.memory import create_client_server_memory_streams
+from mcp.shared.message import SessionMessage
+from mcp.types import (
+ INVALID_PARAMS,
+ CallToolResult,
+ ClientCapabilities,
+ CompletionsCapability,
+ EmptyResult,
+ ErrorData,
+ Icon,
+ Implementation,
+ InitializeRequest,
+ InitializeRequestParams,
+ InitializeResult,
+ JSONRPCRequest,
+ JSONRPCResponse,
+ ListToolsRequest,
+ ListToolsResult,
+ LoggingCapability,
+ PromptsCapability,
+ ResourcesCapability,
+ ServerCapabilities,
+ TextContent,
+ ToolsCapability,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("lifecycle:initialize:basic")
+@requirement("lifecycle:initialize:server-info")
+async def test_initialize_returns_server_info(connect: Connect) -> None:
+ """Every identity field the server declares is returned to the client in server_info."""
+ server = Server(
+ "greeter",
+ version="1.2.3",
+ title="Greeter",
+ description="Greets people.",
+ website_url="https://example.com/greeter",
+ icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
+ )
+
+ async with connect(server) as client:
+ server_info = client.initialize_result.server_info
+
+ assert server_info == snapshot(
+ Implementation(
+ name="greeter",
+ title="Greeter",
+ description="Greets people.",
+ version="1.2.3",
+ website_url="https://example.com/greeter",
+ icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
+ )
+ )
+
+
+@requirement("lifecycle:initialize:instructions")
+async def test_initialize_returns_instructions(connect: Connect) -> None:
+ """Instructions are returned when the server declares them and omitted when it does not."""
+ async with connect(Server("guided", instructions="Call the add tool.")) as client:
+ assert client.initialize_result.instructions == snapshot("Call the add tool.")
+
+ async with connect(Server("unguided")) as client:
+ assert client.initialize_result.instructions is None
+
+
+@requirement("lifecycle:initialize:capabilities:from-handlers")
+@requirement("tools:capability:declared")
+@requirement("resources:capability:declared")
+@requirement("prompts:capability:declared")
+@requirement("completion:capability:declared")
+async def test_initialize_capabilities_reflect_registered_handlers(connect: Connect) -> None:
+ """Each feature area with a registered handler is advertised as a capability.
+
+ The in-memory transport connects with default initialization options, so the
+ list_changed flags are always False regardless of the server's notification behaviour.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ """Registered only so the tools capability is advertised; never called."""
+ raise NotImplementedError
+
+ async def list_resources(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListResourcesResult:
+ """Registered only so the resources capability is advertised; never called."""
+ raise NotImplementedError
+
+ async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> types.EmptyResult:
+ """Registered only so the subscribe sub-capability is advertised; never called."""
+ raise NotImplementedError
+
+ async def list_prompts(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListPromptsResult:
+ """Registered only so the prompts capability is advertised; never called."""
+ raise NotImplementedError
+
+ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> types.EmptyResult:
+ """Registered only so the logging capability is advertised; never called."""
+ raise NotImplementedError
+
+ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult:
+ """Registered only so the completions capability is advertised; never called."""
+ raise NotImplementedError
+
+ server = Server(
+ "full",
+ on_list_tools=list_tools,
+ on_list_resources=list_resources,
+ on_subscribe_resource=subscribe_resource,
+ on_list_prompts=list_prompts,
+ on_set_logging_level=set_logging_level,
+ on_completion=completion,
+ )
+
+ async with connect(server) as client:
+ capabilities = client.initialize_result.capabilities
+
+ assert capabilities == snapshot(
+ ServerCapabilities(
+ experimental={},
+ logging=LoggingCapability(),
+ prompts=PromptsCapability(list_changed=False),
+ resources=ResourcesCapability(subscribe=True, list_changed=False),
+ tools=ToolsCapability(list_changed=False),
+ completions=CompletionsCapability(),
+ )
+ )
+
+
+@requirement("lifecycle:initialize:capabilities:minimal")
+async def test_initialize_minimal_server_advertises_no_capabilities(connect: Connect) -> None:
+ """A server with no feature handlers advertises no feature capabilities."""
+ async with connect(Server("bare")) as client:
+ capabilities = client.initialize_result.capabilities
+
+ assert capabilities == snapshot(ServerCapabilities(experimental={}))
+
+
+@requirement("lifecycle:initialize:client-info")
+async def test_initialize_server_sees_client_info(connect: Connect) -> None:
+ """The client identity supplied to Client is visible to server handlers after initialization."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="whoami", description="Report the caller.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "whoami"
+ assert ctx.session.client_params is not None
+ client_info = ctx.session.client_params.client_info
+ return CallToolResult(content=[TextContent(text=f"{client_info.name} {client_info.version}")])
+
+ server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
+ async with connect(server, client_info=Implementation(name="acme-agent", version="9.9.9")) as client:
+ result = await client.call_tool("whoami", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="acme-agent 9.9.9")]))
+
+
+@requirement("lifecycle:initialize:client-capabilities")
+async def test_initialize_server_sees_client_capabilities(connect: Connect) -> None:
+ """The client capabilities visible to the server reflect which callbacks the client configured."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="abilities", description="Report capabilities.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "abilities"
+ assert ctx.session.client_params is not None
+ capabilities = ctx.session.client_params.capabilities
+ declared = [
+ name
+ for name, value in (
+ ("sampling", capabilities.sampling),
+ ("elicitation", capabilities.elicitation),
+ )
+ if value is not None
+ ]
+ if capabilities.roots is not None:
+ declared.append(f"roots(list_changed={capabilities.roots.list_changed})")
+ return CallToolResult(content=[TextContent(text=",".join(declared) or "none")])
+
+ async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
+ """Registered only so the client declares the roots capability; never called."""
+ raise NotImplementedError
+
+ server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("abilities", {})
+ assert result == snapshot(CallToolResult(content=[TextContent(text="none")]))
+
+ async with connect(server, list_roots_callback=list_roots) as client:
+ result = await client.call_tool("abilities", {})
+ assert result == snapshot(CallToolResult(content=[TextContent(text="roots(list_changed=True)")]))
+
+
+@requirement("lifecycle:requests-before-initialized")
+async def test_request_before_initialization_is_rejected() -> None:
+ """A feature request sent before the handshake completes is rejected; ping is exempt.
+
+ Client always initializes on entry, so this drives a bare ClientSession that never sends
+ initialize. The server's stated reason for the rejection never reaches the client: the error
+ is reported as a generic invalid-params failure.
+ """
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ """Registered so the request is routed to a real handler; never reached."""
+ raise NotImplementedError
+
+ server = Server("strict", on_list_tools=list_tools)
+
+ async with InMemoryTransport(server) as (read_stream, write_stream):
+ async with ClientSession(read_stream, write_stream) as session:
+ with anyio.fail_after(5):
+ with pytest.raises(MCPError) as exc_info:
+ await session.send_request(ListToolsRequest(), ListToolsResult)
+
+ # Ping is explicitly permitted before initialization completes.
+ pong = await session.send_ping()
+
+ assert exc_info.value.error == snapshot(
+ ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
+ )
+ assert pong == snapshot(EmptyResult())
+
+
+@requirement("lifecycle:version:match")
+@requirement("lifecycle:version:server-fallback-latest")
+async def test_initialize_negotiates_protocol_version() -> None:
+ """The server echoes a supported requested version and answers an unsupported one with its latest.
+
+ Client always requests the latest version, so each half hand-builds an InitializeRequest on a
+ bare ClientSession to control the requested version.
+ """
+ server = Server("negotiator")
+
+ def initialize_request(protocol_version: str) -> InitializeRequest:
+ return InitializeRequest(
+ params=InitializeRequestParams(
+ protocol_version=protocol_version,
+ capabilities=ClientCapabilities(),
+ client_info=Implementation(name="time-traveller", version="0.0.1"),
+ )
+ )
+
+ async with InMemoryTransport(server) as (read_stream, write_stream):
+ async with ClientSession(read_stream, write_stream) as session:
+ with anyio.fail_after(5):
+ result = await session.send_request(initialize_request("2025-03-26"), InitializeResult)
+ assert result.protocol_version == snapshot("2025-03-26")
+
+ async with InMemoryTransport(server) as (read_stream, write_stream):
+ async with ClientSession(read_stream, write_stream) as session:
+ with anyio.fail_after(5):
+ result = await session.send_request(initialize_request("1999-01-01"), InitializeResult)
+ assert result.protocol_version == snapshot("2025-11-25")
+
+
+@requirement("lifecycle:version:reject-unsupported")
+async def test_unsupported_server_protocol_version_fails_initialization() -> None:
+ """An initialize response carrying a protocol version the client does not support fails initialization.
+
+ A real Server only ever answers with a version it supports, so this test alone plays the
+ server's side of the wire by hand: it reads the initialize request off the raw stream and
+ answers it with a hand-built result. Reserve this pattern for behaviour no real server can
+ be made to produce.
+ """
+ async with create_client_server_memory_streams() as (client_streams, server_streams):
+ client_read, client_write = client_streams
+ server_read, server_write = server_streams
+
+ async def scripted_server() -> None:
+ message = await server_read.receive()
+ assert isinstance(message, SessionMessage)
+ request = message.message
+ assert isinstance(request, JSONRPCRequest)
+ assert request.method == "initialize"
+ result = InitializeResult(
+ protocol_version="1991-08-06",
+ capabilities=ServerCapabilities(),
+ server_info=Implementation(name="relic", version="0.0.1"),
+ )
+ await server_write.send(
+ SessionMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=request.id,
+ # Serialized exactly as a real server serializes results onto the wire.
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+
+ async with anyio.create_task_group() as tg:
+ tg.start_soon(scripted_server)
+ async with ClientSession(client_read, client_write) as session:
+ with anyio.fail_after(5):
+ with pytest.raises(RuntimeError) as exc_info:
+ await session.initialize()
+
+ assert str(exc_info.value) == snapshot("Unsupported protocol version from the server: 1991-08-06")
diff --git a/tests/interaction/lowlevel/test_list_changed.py b/tests/interaction/lowlevel/test_list_changed.py
new file mode 100644
index 0000000000..eb20db207b
--- /dev/null
+++ b/tests/interaction/lowlevel/test_list_changed.py
@@ -0,0 +1,103 @@
+"""List-changed notifications from the low-level Server, driven through the public Client API.
+
+The notifications are emitted from inside a tool call, so the ordering guarantee described in
+test_logging.py applies: they reach the client's message handler before the tool call returns,
+and the tests assert on a plain collected list with no synchronisation. The collector records
+every message the handler receives, so the assertions also prove nothing else was delivered.
+"""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ CallToolResult,
+ PromptListChangedNotification,
+ ResourceListChangedNotification,
+ TextContent,
+ ToolListChangedNotification,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._helpers import IncomingMessage
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("tools:list-changed")
+async def test_tool_list_changed_notification(connect: Connect) -> None:
+ """A tools/list_changed notification sent during a tool call reaches the client's message handler."""
+ received: list[IncomingMessage] = []
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="install", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "install"
+ await ctx.session.send_tool_list_changed()
+ return CallToolResult(content=[TextContent(text="installed")])
+
+ server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server, message_handler=collect) as client:
+ await client.call_tool("install", {})
+
+ assert received == snapshot([ToolListChangedNotification()])
+
+
+@requirement("resources:list-changed")
+async def test_resource_list_changed_notification(connect: Connect) -> None:
+ """A resources/list_changed notification sent during a tool call reaches the client's message handler."""
+ received: list[IncomingMessage] = []
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="mount", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "mount"
+ await ctx.session.send_resource_list_changed()
+ return CallToolResult(content=[TextContent(text="mounted")])
+
+ server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server, message_handler=collect) as client:
+ await client.call_tool("mount", {})
+
+ assert received == snapshot([ResourceListChangedNotification()])
+
+
+@requirement("prompts:list-changed")
+async def test_prompt_list_changed_notification(connect: Connect) -> None:
+ """A prompts/list_changed notification sent during a tool call reaches the client's message handler."""
+ received: list[IncomingMessage] = []
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="learn", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "learn"
+ await ctx.session.send_prompt_list_changed()
+ return CallToolResult(content=[TextContent(text="learned")])
+
+ server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server, message_handler=collect) as client:
+ await client.call_tool("learn", {})
+
+ assert received == snapshot([PromptListChangedNotification()])
diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py
new file mode 100644
index 0000000000..792334ecd2
--- /dev/null
+++ b/tests/interaction/lowlevel/test_logging.py
@@ -0,0 +1,113 @@
+"""Logging interactions against the low-level Server, driven through the public Client API.
+
+Notification ordering: the in-memory transport delivers every server-to-client message on one
+ordered stream, and the client's receive loop dispatches each incoming message to completion
+before reading the next one. Together these guarantee that every notification the server sends
+before its response reaches the client callback before the originating request returns, so tests
+collect notifications into a plain list and assert after the request completes -- no events, no
+waiting. This does not generalise to transports that split messages across streams (the
+streamable HTTP standalone GET stream); tests over those transports must synchronise explicitly.
+"""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+ALL_LEVELS: tuple[types.LoggingLevel, ...] = (
+ "debug",
+ "info",
+ "notice",
+ "warning",
+ "error",
+ "critical",
+ "alert",
+ "emergency",
+)
+
+
+@requirement("logging:set-level")
+async def test_set_logging_level_reaches_handler(connect: Connect) -> None:
+ """The level requested by the client is delivered to the server's handler verbatim."""
+
+ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
+ assert params.level == "warning"
+ return EmptyResult()
+
+ server = Server("logger", on_set_logging_level=set_logging_level)
+
+ async with connect(server) as client:
+ result = await client.set_logging_level("warning")
+
+ assert result == snapshot(EmptyResult())
+
+
+@requirement("logging:message:fields")
+@requirement("tools:call:logging-mid-execution")
+async def test_log_messages_reach_logging_callback_in_order(connect: Connect) -> None:
+ """Log messages sent during a tool call arrive at the logging callback, in order, before the call returns.
+
+ The two messages pin the full notification shape: severity, optional logger name, and both
+ string and structured data payloads.
+ """
+ received: list[LoggingMessageNotificationParams] = []
+
+ async def collect(params: LoggingMessageNotificationParams) -> None:
+ received.append(params)
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="chatty", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "chatty"
+ await ctx.session.send_log_message(level="info", data="starting up", logger="app.lifecycle")
+ await ctx.session.send_log_message(level="error", data={"code": 502, "retryable": True})
+ return CallToolResult(content=[TextContent(text="done")])
+
+ server = Server("logger", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server, logging_callback=collect) as client:
+ result = await client.call_tool("chatty", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="done")]))
+ assert received == snapshot(
+ [
+ LoggingMessageNotificationParams(level="info", logger="app.lifecycle", data="starting up"),
+ LoggingMessageNotificationParams(level="error", data={"code": 502, "retryable": True}),
+ ]
+ )
+
+
+@requirement("logging:message:all-levels")
+async def test_log_messages_at_every_severity_level(connect: Connect) -> None:
+ """Each of the eight RFC 5424 severity levels is deliverable as a log message notification."""
+ received: list[LoggingMessageNotificationParams] = []
+
+ async def collect(params: LoggingMessageNotificationParams) -> None:
+ received.append(params)
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="siren", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "siren"
+ for level in ALL_LEVELS:
+ await ctx.session.send_log_message(level=level, data=f"a {level} message")
+ return CallToolResult(content=[TextContent(text="logged")])
+
+ server = Server("logger", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server, logging_callback=collect) as client:
+ await client.call_tool("siren", {})
+
+ assert [params.level for params in received] == list(ALL_LEVELS)
diff --git a/tests/interaction/lowlevel/test_meta.py b/tests/interaction/lowlevel/test_meta.py
new file mode 100644
index 0000000000..a9e4f994d8
--- /dev/null
+++ b/tests/interaction/lowlevel/test_meta.py
@@ -0,0 +1,63 @@
+"""Request and result _meta round trips against the low-level Server, through the public Client API.
+
+Meta is opaque pass-through data, so these tests assert identity against the value that was sent
+rather than snapshotting a literal: the expected value and the sent value are the same variable,
+which also proves the SDK injected nothing alongside it.
+"""
+
+import pytest
+
+from mcp import types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import CallToolResult, RequestParamsMeta, TextContent
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("meta:request-to-handler")
+async def test_request_meta_reaches_handler(connect: Connect) -> None:
+ """The _meta object the client attaches to a request arrives at the tool handler unchanged."""
+ request_meta: RequestParamsMeta = {"example.com/trace": "abc-123"}
+ observed_metas: list[dict[str, object]] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="traced", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "traced"
+ assert ctx.meta is not None
+ observed_metas.append(dict(ctx.meta))
+ return CallToolResult(content=[TextContent(text="traced")])
+
+ server = Server("observability", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ await client.call_tool("traced", {}, meta=request_meta)
+
+ assert observed_metas == [dict(request_meta)]
+
+
+@requirement("meta:result-to-client")
+async def test_result_meta_reaches_client(connect: Connect) -> None:
+ """The _meta object a handler attaches to its result is delivered to the client unchanged."""
+ result_meta = {"example.com/cost": 3}
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="metered", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "metered"
+ return CallToolResult(content=[TextContent(text="done")], _meta=result_meta)
+
+ server = Server("observability", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("metered", {})
+
+ assert result == CallToolResult(content=[TextContent(text="done")], _meta=result_meta)
diff --git a/tests/interaction/lowlevel/test_pagination.py b/tests/interaction/lowlevel/test_pagination.py
new file mode 100644
index 0000000000..1b6ac3e66a
--- /dev/null
+++ b/tests/interaction/lowlevel/test_pagination.py
@@ -0,0 +1,174 @@
+"""Cursor pagination of the list operations against the low-level Server.
+
+The cursor is an opaque string chosen by the server: the suite only asserts that whatever the
+handler returns as next_cursor comes back verbatim on the client's next call, not any particular
+pagination scheme.
+"""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ ListPromptsResult,
+ ListResourcesResult,
+ ListResourceTemplatesResult,
+ ListToolsResult,
+ Prompt,
+ Resource,
+ ResourceTemplate,
+ Tool,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("tools:list:pagination")
+async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None:
+ """The next_cursor a list handler returns reaches the client, and the cursor the client sends
+ back on the following call reaches the handler verbatim.
+ """
+ seen_cursors: list[str | None] = []
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ assert params is not None # the client always sends params, even without a cursor
+ seen_cursors.append(params.cursor)
+ if params.cursor is None:
+ return ListToolsResult(
+ tools=[Tool(name="alpha", input_schema={"type": "object"})],
+ next_cursor="page-2",
+ )
+ return ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])
+
+ server = Server("paginated", on_list_tools=list_tools)
+
+ async with connect(server) as client:
+ first_page = await client.list_tools()
+ second_page = await client.list_tools(cursor="page-2")
+
+ assert first_page == snapshot(
+ ListToolsResult(tools=[Tool(name="alpha", input_schema={"type": "object"})], next_cursor="page-2")
+ )
+ assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]))
+ assert seen_cursors == snapshot([None, "page-2"])
+
+
+@requirement("pagination:exhaustion")
+@requirement("tools:list:pagination")
+async def test_paginating_until_next_cursor_is_absent_yields_every_page(connect: Connect) -> None:
+ """Following next_cursor until it is absent visits every page exactly once, in order."""
+ pages: dict[str | None, tuple[str, str | None]] = {
+ None: ("alpha", "page-2"),
+ "page-2": ("beta", "page-3"),
+ "page-3": ("gamma", None),
+ }
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ assert params is not None
+ tool_name, next_cursor = pages[params.cursor]
+ return ListToolsResult(tools=[Tool(name=tool_name, input_schema={"type": "object"})], next_cursor=next_cursor)
+
+ server = Server("paginated", on_list_tools=list_tools)
+
+ collected: list[str] = []
+ cursor: str | None = None
+ requests_made = 0
+ async with connect(server) as client:
+ while True:
+ result = await client.list_tools(cursor=cursor)
+ requests_made += 1
+ assert requests_made <= len(pages), "the server kept returning next_cursor past the last page"
+ collected.extend(tool.name for tool in result.tools)
+ if result.next_cursor is None:
+ break
+ cursor = result.next_cursor
+
+ assert collected == snapshot(["alpha", "beta", "gamma"])
+ assert requests_made == len(pages)
+
+
+@requirement("resources:list:pagination")
+async def test_resources_list_supports_cursor_pagination(connect: Connect) -> None:
+ """resources/list round-trips the cursor like every other list operation."""
+ seen_cursors: list[str | None] = []
+
+ async def list_resources(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> ListResourcesResult:
+ assert params is not None
+ seen_cursors.append(params.cursor)
+ if params.cursor is None:
+ return ListResourcesResult(resources=[Resource(uri="memo://1", name="first")], next_cursor="page-2")
+ return ListResourcesResult(resources=[Resource(uri="memo://2", name="second")])
+
+ server = Server("paginated", on_list_resources=list_resources)
+
+ async with connect(server) as client:
+ first_page = await client.list_resources()
+ second_page = await client.list_resources(cursor="page-2")
+
+ assert seen_cursors == snapshot([None, "page-2"])
+ assert [resource.name for resource in first_page.resources] == ["first"]
+ assert first_page.next_cursor == "page-2"
+ assert [resource.name for resource in second_page.resources] == ["second"]
+ assert second_page.next_cursor is None
+
+
+@requirement("resources:templates:pagination")
+async def test_resource_templates_list_supports_cursor_pagination(connect: Connect) -> None:
+ """resources/templates/list round-trips the cursor like every other list operation."""
+ seen_cursors: list[str | None] = []
+
+ async def list_resource_templates(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> ListResourceTemplatesResult:
+ assert params is not None
+ seen_cursors.append(params.cursor)
+ if params.cursor is None:
+ return ListResourceTemplatesResult(
+ resource_templates=[ResourceTemplate(name="first", uri_template="users://{id}")],
+ next_cursor="page-2",
+ )
+ return ListResourceTemplatesResult(
+ resource_templates=[ResourceTemplate(name="second", uri_template="teams://{id}")]
+ )
+
+ server = Server("paginated", on_list_resource_templates=list_resource_templates)
+
+ async with connect(server) as client:
+ first_page = await client.list_resource_templates()
+ second_page = await client.list_resource_templates(cursor="page-2")
+
+ assert seen_cursors == snapshot([None, "page-2"])
+ assert [template.name for template in first_page.resource_templates] == ["first"]
+ assert first_page.next_cursor == "page-2"
+ assert [template.name for template in second_page.resource_templates] == ["second"]
+ assert second_page.next_cursor is None
+
+
+@requirement("prompts:list:pagination")
+async def test_prompts_list_supports_cursor_pagination(connect: Connect) -> None:
+ """prompts/list round-trips the cursor like every other list operation."""
+ seen_cursors: list[str | None] = []
+
+ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
+ assert params is not None
+ seen_cursors.append(params.cursor)
+ if params.cursor is None:
+ return ListPromptsResult(prompts=[Prompt(name="first")], next_cursor="page-2")
+ return ListPromptsResult(prompts=[Prompt(name="second")])
+
+ server = Server("paginated", on_list_prompts=list_prompts)
+
+ async with connect(server) as client:
+ first_page = await client.list_prompts()
+ second_page = await client.list_prompts(cursor="page-2")
+
+ assert seen_cursors == snapshot([None, "page-2"])
+ assert [prompt.name for prompt in first_page.prompts] == ["first"]
+ assert first_page.next_cursor == "page-2"
+ assert [prompt.name for prompt in second_page.prompts] == ["second"]
+ assert second_page.next_cursor is None
diff --git a/tests/interaction/lowlevel/test_ping.py b/tests/interaction/lowlevel/test_ping.py
new file mode 100644
index 0000000000..797e20dc35
--- /dev/null
+++ b/tests/interaction/lowlevel/test_ping.py
@@ -0,0 +1,53 @@
+"""Ping interactions against the low-level Server, driven through the public Client API."""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import CallToolResult, EmptyResult, TextContent
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("lifecycle:ping")
+@requirement("ping:client-to-server")
+async def test_client_ping_returns_empty_result(connect: Connect) -> None:
+ """A client ping is answered with an empty result, even by a server with no handlers."""
+ server = Server("silent")
+
+ async with connect(server) as client:
+ result = await client.send_ping()
+
+ assert result == snapshot(EmptyResult())
+
+
+@requirement("lifecycle:ping")
+@requirement("ping:server-to-client")
+async def test_server_ping_returns_empty_result(connect: Connect) -> None:
+ """A server-initiated ping sent while a request is in flight is answered by the client.
+
+ The tool returns the type of the ping response, proving the round trip completed inside
+ the handler before the tool result was produced.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="ping_back", description="Ping the client.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "ping_back"
+ pong = await ctx.session.send_ping()
+ return CallToolResult(content=[TextContent(text=type(pong).__name__)])
+
+ server = Server("pinger", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("ping_back", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="EmptyResult")]))
diff --git a/tests/interaction/lowlevel/test_progress.py b/tests/interaction/lowlevel/test_progress.py
new file mode 100644
index 0000000000..56eae40d7d
--- /dev/null
+++ b/tests/interaction/lowlevel/test_progress.py
@@ -0,0 +1,128 @@
+"""Progress interactions against the low-level Server, driven through the public Client API.
+
+Server-to-client progress emitted during a request follows the same ordering guarantee as
+logging notifications (see test_logging.py): everything the server sends before its response is
+dispatched to the progress callback before the request returns, so no synchronisation is needed.
+The client-to-server direction is a standalone notification with no response to await, so that
+test waits on an event set by the server's handler.
+"""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import CallToolResult, ProgressNotificationParams, TextContent
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("protocol:progress:callback")
+@requirement("tools:call:progress")
+async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect) -> None:
+ """Progress notifications emitted by a tool handler reach the caller's progress callback in order."""
+ received: list[tuple[float, float | None, str | None]] = []
+
+ async def collect(progress: float, total: float | None, message: str | None) -> None:
+ received.append((progress, total, message))
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="download", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "download"
+ assert ctx.meta is not None
+ token = ctx.meta.get("progress_token")
+ assert token is not None
+ await ctx.session.send_progress_notification(token, 1.0, total=3.0, message="first chunk")
+ await ctx.session.send_progress_notification(token, 2.0, total=3.0, message="second chunk")
+ await ctx.session.send_progress_notification(token, 3.0, total=3.0, message="done")
+ return CallToolResult(content=[TextContent(text="downloaded")])
+
+ server = Server("downloader", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("download", {}, progress_callback=collect)
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="downloaded")]))
+ assert received == snapshot([(1.0, 3.0, "first chunk"), (2.0, 3.0, "second chunk"), (3.0, 3.0, "done")])
+
+
+@requirement("protocol:progress:token-injected")
+async def test_progress_token_visible_to_handler(connect: Connect) -> None:
+ """Supplying a progress callback attaches a progress token that the handler can read from the request meta."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="inspect", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "inspect"
+ assert ctx.meta is not None
+ return CallToolResult(content=[TextContent(text=str(ctx.meta.get("progress_token")))])
+
+ server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def ignore(progress: float, total: float | None, message: str | None) -> None:
+ """A progress callback that is never invoked; the tool only inspects the token."""
+ raise NotImplementedError
+
+ async with connect(server) as client:
+ result = await client.call_tool("inspect", {}, progress_callback=ignore)
+
+ # The token is the request id of the tools/call request itself (initialize is request 0).
+ assert result == snapshot(CallToolResult(content=[TextContent(text="1")]))
+
+
+@requirement("protocol:progress:no-token")
+async def test_no_progress_callback_means_no_token(connect: Connect) -> None:
+ """Without a progress callback the request carries no progress token.
+
+ The low-level API has no way to report request-scoped progress without a token, so a handler
+ that sees no token has nothing to send progress against.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="inspect", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "inspect"
+ assert ctx.meta is not None
+ return CallToolResult(content=[TextContent(text=str(ctx.meta.get("progress_token")))])
+
+ server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("inspect", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="None")]))
+
+
+@requirement("protocol:progress:client-to-server")
+async def test_client_progress_notification_reaches_server_handler(connect: Connect) -> None:
+ """A progress notification sent by the client is delivered to the server's progress handler."""
+ received: list[ProgressNotificationParams] = []
+ delivered = anyio.Event()
+
+ async def on_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None:
+ received.append(params)
+ delivered.set()
+
+ server = Server("observer", on_progress=on_progress)
+
+ async with connect(server) as client:
+ await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway")
+ with anyio.fail_after(5):
+ await delivered.wait()
+
+ assert received == snapshot(
+ [ProgressNotificationParams(progress_token="upload-1", progress=0.5, total=1.0, message="halfway")]
+ )
diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py
new file mode 100644
index 0000000000..b09f765755
--- /dev/null
+++ b/tests/interaction/lowlevel/test_prompts.py
@@ -0,0 +1,139 @@
+"""Prompt interactions against the low-level Server, driven through the public Client API."""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ INVALID_PARAMS,
+ ErrorData,
+ GetPromptResult,
+ Icon,
+ ListPromptsResult,
+ Prompt,
+ PromptArgument,
+ PromptMessage,
+ TextContent,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("prompts:list:basic")
+async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None:
+ """The prompts returned by the handler reach the client with their argument declarations intact."""
+
+ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
+ return ListPromptsResult(
+ prompts=[
+ Prompt(
+ name="code_review",
+ description="Review a piece of code.",
+ arguments=[
+ PromptArgument(name="code", description="The code to review.", required=True),
+ PromptArgument(name="style_guide", description="Optional style guide to apply."),
+ ],
+ icons=[Icon(src="https://example.com/review.png", mime_type="image/png", sizes=["48x48"])],
+ ),
+ Prompt(name="daily_standup"),
+ ]
+ )
+
+ server = Server("prompter", on_list_prompts=list_prompts)
+
+ async with connect(server) as client:
+ result = await client.list_prompts()
+
+ assert result == snapshot(
+ ListPromptsResult(
+ prompts=[
+ Prompt(
+ name="code_review",
+ description="Review a piece of code.",
+ arguments=[
+ PromptArgument(name="code", description="The code to review.", required=True),
+ PromptArgument(name="style_guide", description="Optional style guide to apply."),
+ ],
+ icons=[Icon(src="https://example.com/review.png", mime_type="image/png", sizes=["48x48"])],
+ ),
+ Prompt(name="daily_standup"),
+ ]
+ )
+ )
+
+
+@requirement("prompts:get:with-args")
+async def test_get_prompt_substitutes_arguments(connect: Connect) -> None:
+ """Arguments supplied by the client reach the prompt handler; the templated message comes back."""
+
+ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
+ assert params.name == "greet"
+ assert params.arguments is not None
+ return GetPromptResult(
+ description="A personalised greeting.",
+ messages=[PromptMessage(role="user", content=TextContent(text=f"Hello, {params.arguments['name']}!"))],
+ )
+
+ server = Server("prompter", on_get_prompt=get_prompt)
+
+ async with connect(server) as client:
+ result = await client.get_prompt("greet", {"name": "Ada"})
+
+ assert result == snapshot(
+ GetPromptResult(
+ description="A personalised greeting.",
+ messages=[PromptMessage(role="user", content=TextContent(text="Hello, Ada!"))],
+ )
+ )
+
+
+@requirement("prompts:get:multi-message")
+async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect) -> None:
+ """A prompt returning a user/assistant conversation reaches the client with roles and order intact."""
+
+ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
+ assert params.name == "geography_quiz"
+ return GetPromptResult(
+ messages=[
+ PromptMessage(role="user", content=TextContent(text="What is the capital of France?")),
+ PromptMessage(role="assistant", content=TextContent(text="The capital of France is Paris.")),
+ PromptMessage(role="user", content=TextContent(text="And of Italy?")),
+ ]
+ )
+
+ server = Server("prompter", on_get_prompt=get_prompt)
+
+ async with connect(server) as client:
+ result = await client.get_prompt("geography_quiz")
+
+ assert result == snapshot(
+ GetPromptResult(
+ messages=[
+ PromptMessage(role="user", content=TextContent(text="What is the capital of France?")),
+ PromptMessage(role="assistant", content=TextContent(text="The capital of France is Paris.")),
+ PromptMessage(role="user", content=TextContent(text="And of Italy?")),
+ ]
+ )
+ )
+
+
+@requirement("prompts:get:unknown-name")
+async def test_get_prompt_unknown_name_is_protocol_error(connect: Connect) -> None:
+ """A handler that rejects an unrecognised prompt name with MCPError produces a JSON-RPC error.
+
+ The error's code and message chosen by the handler reach the client verbatim.
+ """
+
+ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
+ raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {params.name}")
+
+ server = Server("prompter", on_get_prompt=get_prompt)
+
+ async with connect(server) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.get_prompt("nope")
+
+ assert exc_info.value.error == snapshot(ErrorData(code=INVALID_PARAMS, message="Unknown prompt: nope"))
diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py
new file mode 100644
index 0000000000..1d29a62e07
--- /dev/null
+++ b/tests/interaction/lowlevel/test_resources.py
@@ -0,0 +1,253 @@
+"""Resource interactions against the low-level Server, driven through the public Client API."""
+
+import base64
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ Annotations,
+ BlobResourceContents,
+ CallToolResult,
+ EmptyResult,
+ ErrorData,
+ Icon,
+ ListResourcesResult,
+ ListResourceTemplatesResult,
+ ReadResourceResult,
+ Resource,
+ ResourceTemplate,
+ ResourceUpdatedNotification,
+ ResourceUpdatedNotificationParams,
+ TextContent,
+ TextResourceContents,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._helpers import IncomingMessage
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("resources:list:basic")
+async def test_list_resources_returns_registered_resources(connect: Connect) -> None:
+ """Listed resources reach the client with their URIs, names, and optional descriptive fields intact."""
+
+ async def list_resources(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> ListResourcesResult:
+ return ListResourcesResult(
+ resources=[
+ Resource(uri="memo://minimal", name="minimal"),
+ Resource(
+ uri="file:///project/README.md",
+ name="readme",
+ title="Project README",
+ description="The project's front page.",
+ mime_type="text/markdown",
+ size=1024,
+ annotations=Annotations(audience=["user", "assistant"], priority=0.8),
+ icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])],
+ ),
+ ]
+ )
+
+ server = Server("library", on_list_resources=list_resources)
+
+ async with connect(server) as client:
+ result = await client.list_resources()
+
+ assert result == snapshot(
+ ListResourcesResult(
+ resources=[
+ Resource(uri="memo://minimal", name="minimal"),
+ Resource(
+ uri="file:///project/README.md",
+ name="readme",
+ title="Project README",
+ description="The project's front page.",
+ mime_type="text/markdown",
+ size=1024,
+ annotations=Annotations(audience=["user", "assistant"], priority=0.8),
+ icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])],
+ ),
+ ]
+ )
+ )
+
+
+@requirement("resources:read:text")
+async def test_read_resource_text(connect: Connect) -> None:
+ """Reading a text resource returns its contents with the URI, MIME type, and text supplied by the handler."""
+
+ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
+ return ReadResourceResult(
+ contents=[TextResourceContents(uri=params.uri, mime_type="text/plain", text="Hello, world!")]
+ )
+
+ server = Server("library", on_read_resource=read_resource)
+
+ async with connect(server) as client:
+ result = await client.read_resource("file:///greeting.txt")
+
+ assert result == snapshot(
+ ReadResourceResult(
+ contents=[TextResourceContents(uri="file:///greeting.txt", mime_type="text/plain", text="Hello, world!")]
+ )
+ )
+
+
+@requirement("resources:read:blob")
+async def test_read_resource_binary(connect: Connect) -> None:
+ """Reading a binary resource returns its contents base64-encoded in the blob field."""
+
+ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
+ return ReadResourceResult(
+ contents=[
+ BlobResourceContents(
+ uri=params.uri,
+ mime_type="image/png",
+ blob=base64.b64encode(b"\x89PNG").decode(),
+ )
+ ]
+ )
+
+ server = Server("library", on_read_resource=read_resource)
+
+ async with connect(server) as client:
+ result = await client.read_resource("file:///pixel.png")
+
+ assert result == snapshot(
+ ReadResourceResult(
+ contents=[BlobResourceContents(uri="file:///pixel.png", mime_type="image/png", blob="iVBORw==")]
+ )
+ )
+
+
+@requirement("resources:read:unknown-uri")
+async def test_read_resource_unknown_uri_is_protocol_error(connect: Connect) -> None:
+ """A handler that rejects an unrecognised URI with MCPError produces a JSON-RPC error.
+
+ The spec reserves -32002 for resource-not-found; the code is the handler's choice and reaches
+ the client verbatim.
+ """
+
+ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
+ raise MCPError(code=-32002, message=f"Resource not found: {params.uri}")
+
+ server = Server("library", on_read_resource=read_resource)
+
+ async with connect(server) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.read_resource("file:///missing.txt")
+
+ assert exc_info.value.error == snapshot(ErrorData(code=-32002, message="Resource not found: file:///missing.txt"))
+
+
+@requirement("resources:templates:list")
+async def test_list_resource_templates_returns_registered_templates(connect: Connect) -> None:
+ """Listed resource templates reach the client with their URI templates and descriptive fields intact."""
+
+ async def list_resource_templates(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> ListResourceTemplatesResult:
+ return ListResourceTemplatesResult(
+ resource_templates=[
+ ResourceTemplate(uri_template="users://{user_id}", name="user"),
+ ResourceTemplate(
+ uri_template="logs://{service}/{date}",
+ name="service_logs",
+ title="Service logs",
+ description="One day of logs for one service.",
+ mime_type="text/plain",
+ icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])],
+ ),
+ ]
+ )
+
+ server = Server("library", on_list_resource_templates=list_resource_templates)
+
+ async with connect(server) as client:
+ result = await client.list_resource_templates()
+
+ assert result == snapshot(
+ ListResourceTemplatesResult(
+ resource_templates=[
+ ResourceTemplate(uri_template="users://{user_id}", name="user"),
+ ResourceTemplate(
+ uri_template="logs://{service}/{date}",
+ name="service_logs",
+ title="Service logs",
+ description="One day of logs for one service.",
+ mime_type="text/plain",
+ icons=[Icon(src="https://example.com/logs.png", mime_type="image/png", sizes=["48x48"])],
+ ),
+ ]
+ )
+ )
+
+
+@requirement("resources:subscribe")
+async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
+ """Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result."""
+
+ async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult:
+ assert params.uri == "file:///watched.txt"
+ return EmptyResult()
+
+ server = Server("library", on_subscribe_resource=subscribe_resource)
+
+ async with connect(server) as client:
+ result = await client.subscribe_resource("file:///watched.txt")
+
+ assert result == snapshot(EmptyResult())
+
+
+@requirement("resources:unsubscribe")
+async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
+ """Unsubscribing from a resource delivers the URI to the server's unsubscribe handler."""
+
+ async def unsubscribe_resource(ctx: ServerRequestContext, params: types.UnsubscribeRequestParams) -> EmptyResult:
+ assert params.uri == "file:///watched.txt"
+ return EmptyResult()
+
+ server = Server("library", on_unsubscribe_resource=unsubscribe_resource)
+
+ async with connect(server) as client:
+ result = await client.unsubscribe_resource("file:///watched.txt")
+
+ assert result == snapshot(EmptyResult())
+
+
+@requirement("resources:updated-notification")
+async def test_resource_updated_notification_reaches_client(connect: Connect) -> None:
+ """A resources/updated notification sent during a tool call reaches the client with the resource URI.
+
+ The collector records every message the handler receives, so the assertion also proves nothing
+ else was delivered.
+ """
+ received: list[IncomingMessage] = []
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="touch", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "touch"
+ await ctx.session.send_resource_updated("file:///watched.txt")
+ return CallToolResult(content=[TextContent(text="touched")])
+
+ server = Server("library", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server, message_handler=collect) as client:
+ await client.call_tool("touch", {})
+
+ assert received == snapshot(
+ [ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
+ )
diff --git a/tests/interaction/lowlevel/test_roots.py b/tests/interaction/lowlevel/test_roots.py
new file mode 100644
index 0000000000..577b99819c
--- /dev/null
+++ b/tests/interaction/lowlevel/test_roots.py
@@ -0,0 +1,162 @@
+"""Roots interactions against the low-level Server, driven through the public Client API."""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+from pydantic import FileUrl
+
+from mcp import MCPError, types
+from mcp.client import ClientRequestContext
+from mcp.server import Server, ServerRequestContext
+from mcp.types import INTERNAL_ERROR, CallToolResult, ErrorData, ListRootsResult, Root, TextContent
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("roots:list:basic")
+async def test_list_roots_round_trip(connect: Connect) -> None:
+ """A roots/list request from a tool handler is answered by the client's roots callback.
+
+ The tool reports the URIs and names it received, proving the client's roots reached the server.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "show_roots"
+ result = await ctx.session.list_roots()
+ lines = [f"{root.uri} name={root.name}" for root in result.roots]
+ return CallToolResult(content=[TextContent(text="\n".join(lines))])
+
+ server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def list_roots(context: ClientRequestContext) -> ListRootsResult:
+ return ListRootsResult(
+ roots=[
+ Root(uri=FileUrl("file:///home/alice/project"), name="project"),
+ Root(uri=FileUrl("file:///home/alice/scratch")),
+ ]
+ )
+
+ async with connect(server, list_roots_callback=list_roots) as client:
+ result = await client.call_tool("show_roots", {})
+
+ assert result == snapshot(
+ CallToolResult(
+ content=[TextContent(text="file:///home/alice/project name=project\nfile:///home/alice/scratch name=None")]
+ )
+ )
+
+
+@requirement("roots:list:empty")
+async def test_list_roots_empty(connect: Connect) -> None:
+ """A client with no roots to offer answers roots/list with an empty list, not an error."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="count_roots", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "count_roots"
+ result = await ctx.session.list_roots()
+ return CallToolResult(content=[TextContent(text=str(len(result.roots)))])
+
+ server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def list_roots(context: ClientRequestContext) -> ListRootsResult:
+ return ListRootsResult(roots=[])
+
+ async with connect(server, list_roots_callback=list_roots) as client:
+ result = await client.call_tool("count_roots", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="0")]))
+
+
+@requirement("roots:list:not-supported")
+async def test_list_roots_without_callback_is_error(connect: Connect) -> None:
+ """A roots/list request to a client with no roots callback fails with an error the handler can observe.
+
+ The client's default callback answers with INVALID_REQUEST rather than leaving the server
+ hanging; the spec names -32601 for this case (see the divergence note on the requirement).
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "show_roots"
+ try:
+ await ctx.session.list_roots()
+ except MCPError as exc:
+ return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
+ raise NotImplementedError # list_roots cannot succeed without a client callback
+
+ server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("show_roots", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: List roots not supported")]))
+
+
+@requirement("roots:list:client-error")
+async def test_list_roots_callback_error_surfaces_to_the_handler(connect: Connect) -> None:
+ """A roots callback that answers with an error fails the roots/list request with that exact error.
+
+ The callback's code and message reach the requesting handler verbatim as an MCPError.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "show_roots"
+ try:
+ await ctx.session.list_roots()
+ except MCPError as exc:
+ return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
+ raise NotImplementedError # the callback always answers with an error
+
+ server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def list_roots(context: ClientRequestContext) -> ErrorData:
+ return ErrorData(code=INTERNAL_ERROR, message="roots provider crashed")
+
+ async with connect(server, list_roots_callback=list_roots) as client:
+ result = await client.call_tool("show_roots", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="-32603: roots provider crashed")]))
+
+
+@requirement("roots:list-changed")
+async def test_roots_list_changed_reaches_server_handler(connect: Connect) -> None:
+ """A roots/list_changed notification from the client is delivered to the server's handler.
+
+ Unlike a request, a notification has no response to await: the handler sets an event and the
+ test waits on it, which is the only synchronisation point proving delivery.
+ """
+ delivered = anyio.Event()
+ received: list[types.NotificationParams | None] = []
+
+ async def roots_list_changed(ctx: ServerRequestContext, params: types.NotificationParams | None) -> None:
+ received.append(params)
+ delivered.set()
+
+ server = Server("rooted", on_roots_list_changed=roots_list_changed)
+
+ async with connect(server) as client:
+ await client.send_roots_list_changed()
+ with anyio.fail_after(5):
+ await delivered.wait()
+
+ assert received == snapshot([None])
diff --git a/tests/interaction/lowlevel/test_sampling.py b/tests/interaction/lowlevel/test_sampling.py
new file mode 100644
index 0000000000..85eb8c3455
--- /dev/null
+++ b/tests/interaction/lowlevel/test_sampling.py
@@ -0,0 +1,391 @@
+"""Sampling interactions against the low-level Server, driven through the public Client API.
+
+Each test nests a sampling/createMessage request inside a tool call: the tool handler calls
+ctx.session.create_message(), the client's sampling callback answers it, and the handler
+round-trips what it received back to the test through its tool result.
+"""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.client import ClientRequestContext
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ CallToolResult,
+ CreateMessageRequestParams,
+ CreateMessageResult,
+ ErrorData,
+ ImageContent,
+ ModelHint,
+ ModelPreferences,
+ SamplingMessage,
+ TextContent,
+ ToolResultContent,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("sampling:create:basic")
+@requirement("tools:call:sampling-roundtrip")
+async def test_create_message_round_trip(connect: Connect) -> None:
+ """A handler's sampling request is answered by the client callback, and the callback's result
+ (role, content, model, stop reason) is returned to the handler.
+ """
+ received: list[CreateMessageRequestParams] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "ask_model"
+ result = await ctx.session.create_message(
+ messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
+ max_tokens=100,
+ )
+ assert isinstance(result.content, TextContent)
+ return CallToolResult(content=[TextContent(text=f"{result.model}/{result.stop_reason}: {result.content.text}")])
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def sampling_callback(
+ context: ClientRequestContext, params: CreateMessageRequestParams
+ ) -> CreateMessageResult:
+ received.append(params)
+ return CreateMessageResult(
+ role="assistant",
+ content=TextContent(text="Hello to you too."),
+ model="mock-llm-1",
+ stop_reason="endTurn",
+ )
+
+ async with connect(server, sampling_callback=sampling_callback) as client:
+ result = await client.call_tool("ask_model", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="mock-llm-1/endTurn: Hello to you too.")]))
+ assert received == snapshot(
+ [
+ CreateMessageRequestParams(
+ _meta={},
+ messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
+ max_tokens=100,
+ )
+ ]
+ )
+
+
+@requirement("sampling:create:include-context")
+@requirement("sampling:create:model-preferences")
+@requirement("sampling:create:system-prompt")
+async def test_create_message_params_reach_callback(connect: Connect) -> None:
+ """Every sampling parameter the handler supplies arrives at the client callback unchanged."""
+ received: list[CreateMessageRequestParams] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "ask_model"
+ result = await ctx.session.create_message(
+ messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))],
+ max_tokens=50,
+ system_prompt="You are terse.",
+ include_context="thisServer",
+ temperature=0.7,
+ stop_sequences=["\n\n", "END"],
+ model_preferences=ModelPreferences(
+ hints=[ModelHint(name="claude"), ModelHint(name="gpt")],
+ cost_priority=0.2,
+ speed_priority=0.3,
+ intelligence_priority=0.9,
+ ),
+ )
+ assert isinstance(result.content, TextContent)
+ return CallToolResult(content=[TextContent(text=result.content.text)])
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def sampling_callback(
+ context: ClientRequestContext, params: CreateMessageRequestParams
+ ) -> CreateMessageResult:
+ received.append(params)
+ return CreateMessageResult(role="assistant", content=TextContent(text="ok"), model="mock-llm-1")
+
+ async with connect(server, sampling_callback=sampling_callback) as client:
+ result = await client.call_tool("ask_model", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
+ assert received == snapshot(
+ [
+ CreateMessageRequestParams(
+ _meta={},
+ messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))],
+ model_preferences=ModelPreferences(
+ hints=[ModelHint(name="claude"), ModelHint(name="gpt")],
+ cost_priority=0.2,
+ speed_priority=0.3,
+ intelligence_priority=0.9,
+ ),
+ system_prompt="You are terse.",
+ include_context="thisServer",
+ temperature=0.7,
+ max_tokens=50,
+ stop_sequences=["\n\n", "END"],
+ )
+ ]
+ )
+
+
+@requirement("sampling:create-message:image-content")
+async def test_create_message_request_with_image_content_reaches_callback(connect: Connect) -> None:
+ """A sampling request message carrying image content arrives at the client callback intact.
+
+ This is the server-to-client direction: the server includes an image in the conversation it
+ asks the client to sample from.
+ """
+ received: list[CreateMessageRequestParams] = []
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="describe_image", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "describe_image"
+ result = await ctx.session.create_message(
+ messages=[SamplingMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png"))],
+ max_tokens=100,
+ )
+ assert isinstance(result.content, TextContent)
+ return CallToolResult(content=[TextContent(text=result.content.text)])
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def sampling_callback(
+ context: ClientRequestContext, params: CreateMessageRequestParams
+ ) -> CreateMessageResult:
+ received.append(params)
+ image = params.messages[0].content
+ assert isinstance(image, ImageContent)
+ return CreateMessageResult(
+ role="assistant",
+ content=TextContent(text=f"described {image.mime_type} ({image.data})"),
+ model="mock-vision-1",
+ )
+
+ async with connect(server, sampling_callback=sampling_callback) as client:
+ result = await client.call_tool("describe_image", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="described image/png (aW1n)")]))
+ assert received == snapshot(
+ [
+ CreateMessageRequestParams(
+ _meta={},
+ messages=[SamplingMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png"))],
+ max_tokens=100,
+ )
+ ]
+ )
+
+
+@requirement("sampling:create-message:image-content")
+async def test_create_message_result_with_image_content_returns_to_handler(connect: Connect) -> None:
+ """A sampling result whose content is an image is returned to the requesting handler intact.
+
+ This is the client-to-server direction: the model's response is an image rather than text.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="draw", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "draw"
+ result = await ctx.session.create_message(
+ messages=[SamplingMessage(role="user", content=TextContent(text="Draw a cat."))],
+ max_tokens=100,
+ )
+ image = result.content
+ assert isinstance(image, ImageContent)
+ return CallToolResult(content=[TextContent(text=f"{result.model}: {image.mime_type} {image.data}")])
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def sampling_callback(
+ context: ClientRequestContext, params: CreateMessageRequestParams
+ ) -> CreateMessageResult:
+ return CreateMessageResult(
+ role="assistant",
+ content=ImageContent(data="Y2F0", mime_type="image/png"),
+ model="mock-vision-1",
+ )
+
+ async with connect(server, sampling_callback=sampling_callback) as client:
+ result = await client.call_tool("draw", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="mock-vision-1: image/png Y2F0")]))
+
+
+@requirement("sampling:error:user-rejected")
+async def test_create_message_callback_error(connect: Connect) -> None:
+ """A sampling callback that answers with an error surfaces to the requesting handler as an MCPError.
+
+ The error here is the spec's own example for a user rejecting a sampling request (code -1);
+ the callback's code and message reach the handler verbatim, whatever they are.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "ask_model"
+ try:
+ await ctx.session.create_message(
+ messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
+ max_tokens=100,
+ )
+ except MCPError as exc:
+ return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
+ raise NotImplementedError # the callback always answers with an error
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> ErrorData:
+ return ErrorData(code=-1, message="User rejected sampling request")
+
+ async with connect(server, sampling_callback=sampling_callback) as client:
+ result = await client.call_tool("ask_model", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="-1: User rejected sampling request")]))
+
+
+@requirement("sampling:create-message:not-supported")
+async def test_create_message_without_callback_is_error(connect: Connect) -> None:
+ """A sampling request to a client with no sampling callback fails with the SDK's default error."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "ask_model"
+ try:
+ await ctx.session.create_message(
+ messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
+ max_tokens=100,
+ )
+ except MCPError as exc:
+ return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
+ raise NotImplementedError # create_message cannot succeed without a client callback
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("ask_model", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: Sampling not supported")]))
+
+
+@requirement("sampling:tools:server-gated-by-capability")
+async def test_create_message_with_tools_is_rejected_for_unsupporting_client(connect: Connect) -> None:
+ """A tool-enabled sampling request to a client that has not declared sampling.tools never leaves the server.
+
+ The client supports plain sampling but cannot declare the tools sub-capability (Client does not
+ expose it), so the server-side validator rejects the request before anything reaches the wire.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "ask_model"
+ try:
+ await ctx.session.create_message(
+ messages=[SamplingMessage(role="user", content=TextContent(text="What is the weather?"))],
+ max_tokens=100,
+ tools=[types.Tool(name="get_weather", input_schema={"type": "object"})],
+ )
+ except MCPError as exc:
+ return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
+ raise NotImplementedError # the validator rejects every tool-enabled request
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def sampling_callback(
+ context: ClientRequestContext, params: CreateMessageRequestParams
+ ) -> CreateMessageResult:
+ """Declares the plain sampling capability; never invoked because the request is rejected first."""
+ raise NotImplementedError
+
+ async with connect(server, sampling_callback=sampling_callback) as client:
+ result = await client.call_tool("ask_model", {})
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="-32602: Client does not support sampling tools capability")])
+ )
+
+
+@requirement("sampling:tool-result:no-mixed-content")
+async def test_create_message_with_unbalanced_tool_messages_is_rejected(connect: Connect) -> None:
+ """A sampling request whose messages mix tool results with other content never leaves the server.
+
+ The message-structure validation runs inside create_message before the request is sent, even
+ when no tools are passed, so the client callback is never invoked and the handler observes the
+ ValueError directly.
+ """
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="summarise_tools", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "summarise_tools"
+ try:
+ await ctx.session.create_message(
+ messages=[
+ SamplingMessage(
+ role="user",
+ content=[
+ ToolResultContent(tool_use_id="call-1", content=[TextContent(text="42")]),
+ TextContent(text="Also, a comment alongside the result."),
+ ],
+ )
+ ],
+ max_tokens=100,
+ )
+ except ValueError as exc:
+ return CallToolResult(content=[TextContent(text=f"{type(exc).__name__}: {exc}")])
+ raise NotImplementedError # the validator rejects the malformed messages before sending
+
+ server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async def sampling_callback(
+ context: ClientRequestContext, params: CreateMessageRequestParams
+ ) -> CreateMessageResult:
+ """Declares the sampling capability; never invoked because the request is rejected first."""
+ raise NotImplementedError
+
+ async with connect(server, sampling_callback=sampling_callback) as client:
+ result = await client.call_tool("summarise_tools", {})
+
+ assert result == snapshot(
+ CallToolResult(
+ content=[
+ TextContent(text="ValueError: The last message must contain only tool_result content if any is present")
+ ]
+ )
+ )
diff --git a/tests/interaction/lowlevel/test_timeouts.py b/tests/interaction/lowlevel/test_timeouts.py
new file mode 100644
index 0000000000..a9c83d641d
--- /dev/null
+++ b/tests/interaction/lowlevel/test_timeouts.py
@@ -0,0 +1,114 @@
+"""Request timeouts against the low-level Server, driven through the public Client API.
+
+The handler blocks on an event that is never set, so the awaited response can never arrive and
+any positive timeout fires deterministically on the next event-loop pass. The timeout is therefore
+set to an effectively-zero duration: the tests add no wall-clock time to the suite. (Zero itself
+cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
+"""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.client.client import Client
+from mcp.server import Server, ServerRequestContext
+from mcp.types import REQUEST_TIMEOUT, CallToolResult, ErrorData, TextContent
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("protocol:timeout:basic")
+@requirement("protocol:timeout:sends-cancellation")
+async def test_request_timeout_fails_the_pending_call() -> None:
+ """A request whose response does not arrive within its read timeout fails with a timeout error.
+
+ No cancellation is sent to the server (see the divergence note on the requirement): the handler
+ starts and is still running after the caller has already given up. The test waits for the
+ handler to have started only after the timeout has fired, so the timeout itself races nothing.
+ """
+ handler_started = anyio.Event()
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "block"
+ handler_started.set()
+ await anyio.Event().wait() # blocks until the session is torn down
+ raise NotImplementedError # unreachable
+
+ server = Server("blocker", on_call_tool=call_tool)
+
+ async with Client(server) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("block", {}, read_timeout_seconds=0.000001)
+
+ # The request was already on the wire: the handler still runs even though the caller gave up.
+ with anyio.fail_after(5):
+ await handler_started.wait()
+
+ assert exc_info.value.error == snapshot(
+ ErrorData(
+ code=REQUEST_TIMEOUT,
+ message="Timed out while waiting for response to CallToolRequest. Waited 1e-06 seconds.",
+ )
+ )
+
+
+@requirement("protocol:timeout:session-survives")
+async def test_session_serves_requests_after_timeout() -> None:
+ """A timed-out request does not poison the session: the next request succeeds."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(name="block", input_schema={"type": "object"}),
+ types.Tool(name="echo", input_schema={"type": "object"}),
+ ]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ if params.name == "echo":
+ return CallToolResult(content=[TextContent(text="still alive")])
+ await anyio.Event().wait() # blocks until the session is torn down
+ raise NotImplementedError # unreachable
+
+ server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with Client(server) as client:
+ with pytest.raises(MCPError):
+ await client.call_tool("block", {}, read_timeout_seconds=0.000001)
+
+ result = await client.call_tool("echo", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
+
+
+@requirement("protocol:timeout:session-default")
+async def test_session_level_timeout_applies_to_every_request() -> None:
+ """A read timeout configured on the client applies to requests that do not set their own."""
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "block"
+ await anyio.Event().wait() # blocks until the session is torn down
+ raise NotImplementedError # unreachable
+
+ server = Server("blocker", on_call_tool=call_tool)
+
+ # The one real wall-clock wait in the suite, and it cannot be made effectively zero like the
+ # per-request timeouts: a session-level timeout also governs the initialize handshake, so the
+ # value must be long enough for the in-process handshake to complete before the blocked tool
+ # call waits it out in full. 50ms buys a ~50x safety margin over the handshake's actual
+ # latency; lowering it only erodes the margin against CI scheduler jitter without saving
+ # anything perceptible.
+ async with Client(server, read_timeout_seconds=0.05) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("block", {})
+
+ assert exc_info.value.error == snapshot(
+ ErrorData(
+ code=REQUEST_TIMEOUT,
+ message="Timed out while waiting for response to CallToolRequest. Waited 0.05 seconds.",
+ )
+ )
diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py
new file mode 100644
index 0000000000..49b04db2fa
--- /dev/null
+++ b/tests/interaction/lowlevel/test_tools.py
@@ -0,0 +1,355 @@
+"""Tool interactions against the low-level Server, driven through the public Client API."""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError, types
+from mcp.server import Server, ServerRequestContext
+from mcp.types import (
+ INVALID_PARAMS,
+ AudioContent,
+ CallToolResult,
+ EmbeddedResource,
+ ErrorData,
+ Icon,
+ ImageContent,
+ ListToolsResult,
+ ResourceLink,
+ TextContent,
+ TextResourceContents,
+ Tool,
+ ToolAnnotations,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("tools:call:content:text")
+async def test_call_tool_returns_text_content(connect: Connect) -> None:
+ """Arguments reach the tool handler; its content comes back as the call result."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(
+ tools=[types.Tool(name="add", description="Add two integers.", input_schema={"type": "object"})]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "add"
+ assert params.arguments is not None
+ return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
+
+ server = Server("adder", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("add", {"a": 2, "b": 3})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
+
+
+@requirement("tools:call:is-error")
+async def test_call_tool_execution_error_is_returned_as_result(connect: Connect) -> None:
+ """A tool reporting its own failure with is_error=True reaches the client as a result, not an exception.
+
+ Tool execution errors are part of the result so the caller (typically a model) can see
+ them; only protocol-level failures become JSON-RPC errors.
+ """
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "flux"
+ return CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
+
+ server = Server("errors", on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("flux", {})
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
+ )
+
+
+@requirement("tools:call:unknown-name")
+async def test_call_tool_unknown_tool_is_protocol_error(connect: Connect) -> None:
+ """A handler that rejects an unrecognised tool name with MCPError produces a JSON-RPC error.
+
+ The error's code, message, and data chosen by the handler reach the client verbatim.
+ """
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {params.name}", data={"requested": params.name})
+
+ server = Server("errors", on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("nope", {})
+
+ assert exc_info.value.error == snapshot(
+ ErrorData(code=INVALID_PARAMS, message="Unknown tool: nope", data={"requested": "nope"})
+ )
+
+
+@requirement("protocol:error:internal-error")
+async def test_call_tool_uncaught_exception_becomes_error_response(connect: Connect) -> None:
+ """An uncaught exception in the tool handler surfaces to the client as a JSON-RPC error.
+
+ The low-level server reports it with code 0 and the exception text as the message; see the
+ divergence note on the requirement.
+ """
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "explode"
+ raise ValueError("boom")
+
+ server = Server("errors", on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("explode", {})
+
+ assert exc_info.value.error == snapshot(ErrorData(code=0, message="boom"))
+
+
+@requirement("tools:list:basic")
+async def test_list_tools_returns_registered_tools(connect: Connect) -> None:
+ """The tools advertised by the server's list handler arrive at the client unchanged."""
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(
+ tools=[
+ Tool(
+ name="add",
+ description="Add two integers.",
+ input_schema={
+ "type": "object",
+ "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
+ "required": ["a", "b"],
+ },
+ ),
+ Tool(name="reset", description="Reset the calculator.", input_schema={"type": "object"}),
+ ]
+ )
+
+ server = Server("calculator", on_list_tools=list_tools)
+
+ async with connect(server) as client:
+ result = await client.list_tools()
+
+ assert result == snapshot(
+ ListToolsResult(
+ tools=[
+ Tool(
+ name="add",
+ description="Add two integers.",
+ input_schema={
+ "type": "object",
+ "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
+ "required": ["a", "b"],
+ },
+ ),
+ Tool(name="reset", description="Reset the calculator.", input_schema={"type": "object"}),
+ ]
+ )
+ )
+
+
+@requirement("tools:list:metadata")
+async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None:
+ """Every optional Tool field the server supplies reaches the client unchanged."""
+
+ tool = Tool(
+ name="annotated",
+ title="Annotated tool",
+ description="A tool carrying every optional field.",
+ input_schema={"type": "object"},
+ output_schema={"type": "object", "properties": {"answer": {"type": "integer"}}},
+ icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
+ annotations=ToolAnnotations(title="Display title", read_only_hint=True, idempotent_hint=True),
+ _meta={"example.com/source": "interaction-suite"},
+ )
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(tools=[tool])
+
+ server = Server("annotated", on_list_tools=list_tools)
+
+ async with connect(server) as client:
+ result = await client.list_tools()
+
+ assert result == snapshot(
+ ListToolsResult(
+ tools=[
+ Tool(
+ name="annotated",
+ title="Annotated tool",
+ description="A tool carrying every optional field.",
+ input_schema={"type": "object"},
+ output_schema={"type": "object", "properties": {"answer": {"type": "integer"}}},
+ icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
+ annotations=ToolAnnotations(title="Display title", read_only_hint=True, idempotent_hint=True),
+ _meta={"example.com/source": "interaction-suite"},
+ )
+ ]
+ )
+ )
+
+
+@requirement("tools:call:content:mixed")
+@requirement("tools:call:content:image")
+@requirement("tools:call:content:audio")
+@requirement("tools:call:content:resource-link")
+@requirement("tools:call:content:embedded-resource")
+async def test_call_tool_multiple_content_block_types(connect: Connect) -> None:
+ """A tool result can mix every content block type; all of them arrive in order.
+
+ The payloads are tiny fixed base64 strings ("aW1n" is b"img", "YXVk" is b"aud") so the
+ snapshot pins the exact bytes the client receives.
+ """
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(tools=[Tool(name="render", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "render"
+ return CallToolResult(
+ content=[
+ TextContent(text="all five content block types"),
+ ImageContent(data="aW1n", mime_type="image/png"),
+ AudioContent(data="YXVk", mime_type="audio/wav"),
+ ResourceLink(name="report", uri="resource://reports/1", description="The full report"),
+ EmbeddedResource(
+ resource=TextResourceContents(uri="resource://reports/1", mime_type="text/plain", text="contents")
+ ),
+ ]
+ )
+
+ server = Server("renderer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("render", {})
+
+ assert result == snapshot(
+ CallToolResult(
+ content=[
+ TextContent(text="all five content block types"),
+ ImageContent(data="aW1n", mime_type="image/png"),
+ AudioContent(data="YXVk", mime_type="audio/wav"),
+ ResourceLink(name="report", uri="resource://reports/1", description="The full report"),
+ EmbeddedResource(
+ resource=TextResourceContents(uri="resource://reports/1", mime_type="text/plain", text="contents")
+ ),
+ ]
+ )
+ )
+
+
+@requirement("tools:call:structured-content")
+async def test_call_tool_structured_content(connect: Connect) -> None:
+ """A tool result carrying structured content alongside content delivers both to the client."""
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(tools=[Tool(name="sum", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "sum"
+ return CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5})
+
+ server = Server("calculator", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ result = await client.call_tool("sum", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5}))
+
+
+@requirement("tools:call:concurrent")
+async def test_concurrent_tool_calls_complete_independently(connect: Connect) -> None:
+ """Two tool calls in flight at once run concurrently and each caller gets its own answer.
+
+ Both handlers are held on a shared event after signalling that they have started, and the test
+ only releases them once both signals have arrived -- a server that processed requests
+ sequentially would never start the second handler and the test would time out instead.
+ """
+ started: list[str] = []
+ started_events = {"first": anyio.Event(), "second": anyio.Event()}
+ release = anyio.Event()
+ results: dict[str, CallToolResult] = {}
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "echo"
+ assert params.arguments is not None
+ tag = params.arguments["tag"]
+ assert isinstance(tag, str)
+ started.append(tag)
+ started_events[tag].set()
+ await release.wait()
+ return CallToolResult(content=[TextContent(text=tag)])
+
+ server = Server("echoer", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ with anyio.fail_after(5):
+ async with anyio.create_task_group() as task_group:
+
+ async def call_and_record(tag: str) -> None:
+ results[tag] = await client.call_tool("echo", {"tag": tag})
+
+ task_group.start_soon(call_and_record, "first")
+ task_group.start_soon(call_and_record, "second")
+
+ # Both handlers are running at the same time before either is allowed to finish.
+ await started_events["first"].wait()
+ await started_events["second"].wait()
+ release.set()
+
+ assert sorted(started) == ["first", "second"]
+ assert results == snapshot(
+ {
+ "first": CallToolResult(content=[TextContent(text="first")]),
+ "second": CallToolResult(content=[TextContent(text="second")]),
+ }
+ )
+
+
+@requirement("client:output-schema:validate")
+async def test_call_tool_structured_content_violating_output_schema_is_rejected_by_the_client(connect: Connect) -> None:
+ """A result whose structured content does not conform to the tool's declared output schema never
+ reaches the caller: the client validates it against the schema cached from tools/list and raises.
+ """
+
+ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(
+ tools=[
+ Tool(
+ name="forecast",
+ input_schema={"type": "object"},
+ output_schema={
+ "type": "object",
+ "properties": {"temperature": {"type": "number"}},
+ "required": ["temperature"],
+ },
+ )
+ ]
+ )
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "forecast"
+ return CallToolResult(content=[TextContent(text="warm")], structured_content={"temperature": "warm"})
+
+ server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
+
+ async with connect(server) as client:
+ await client.list_tools()
+ with pytest.raises(RuntimeError) as exc_info:
+ await client.call_tool("forecast", {})
+
+ # The message embeds the jsonschema validation error, so only the SDK-authored prefix is pinned.
+ assert str(exc_info.value).startswith("Invalid structured content returned by tool forecast")
diff --git a/tests/interaction/lowlevel/test_wire.py b/tests/interaction/lowlevel/test_wire.py
new file mode 100644
index 0000000000..f7e55ecaf3
--- /dev/null
+++ b/tests/interaction/lowlevel/test_wire.py
@@ -0,0 +1,121 @@
+"""Wire-level invariants observed at the client's transport boundary.
+
+These behaviours are invisible to API callers -- they are properties of the raw JSON-RPC frames.
+The tests wrap the in-memory transport in a RecordingTransport, which tees every message crossing
+the transport seam into a list without touching the session, so the assertions hold for whatever
+the session implementation sends rather than for what its API returns.
+"""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import types
+from mcp.client._memory import InMemoryTransport
+from mcp.client.client import Client
+from mcp.server import Server, ServerRequestContext
+from mcp.shared.message import SessionMessage
+from mcp.types import CallToolResult, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, TextContent
+from tests.interaction._helpers import RecordingTransport, _RecordingReadStream
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+def _echo_server() -> Server:
+ """A server with one echo tool, used by every test in this module."""
+
+ async def list_tools(
+ ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
+ ) -> types.ListToolsResult:
+ return types.ListToolsResult(tools=[types.Tool(name="echo", input_schema={"type": "object"})])
+
+ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
+ assert params.name == "echo"
+ return CallToolResult(content=[TextContent(text="ok")])
+
+ return Server("wire", on_list_tools=list_tools, on_call_tool=call_tool)
+
+
+@requirement("protocol:request-id:unique")
+async def test_request_ids_are_unique_and_never_null() -> None:
+ """Every request the client sends carries a distinct, non-null id.
+
+ The id sequence is pinned: sequential integers from zero, in send order.
+ """
+ recording = RecordingTransport(InMemoryTransport(_echo_server()))
+
+ async with Client(recording) as client:
+ await client.list_tools()
+ await client.call_tool("echo", {})
+ await client.call_tool("echo", {})
+ await client.send_ping()
+
+ sent = [message.message for message in recording.sent]
+ request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
+ assert all(request_id is not None for request_id in request_ids)
+ assert len(request_ids) == len(set(request_ids))
+ # initialize, tools/list, tools/call, tools/call, ping -- the client does not issue a
+ # schema-cache refresh here because the explicit tools/list already populated the cache.
+ assert request_ids == snapshot([0, 1, 2, 3, 4])
+
+
+@requirement("protocol:notifications:no-response")
+async def test_notifications_are_never_answered() -> None:
+ """A notification produces no response: everything the server sends back answers a request.
+
+ The client sends two notifications (initialized and roots/list_changed) and several requests;
+ the messages received from the server must be exactly one response per request, each carrying
+ the id of the request it answers, and nothing else.
+ """
+ recording = RecordingTransport(InMemoryTransport(_echo_server()))
+
+ async with Client(recording) as client:
+ await client.send_roots_list_changed()
+ await client.send_ping()
+
+ sent = [message.message for message in recording.sent]
+ sent_request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
+ sent_notifications = [message for message in sent if isinstance(message, JSONRPCNotification)]
+ received = [message.message for message in recording.received if isinstance(message, SessionMessage)]
+ received_responses = [message for message in received if isinstance(message, JSONRPCResponse)]
+
+ assert len(sent_notifications) == 2 # notifications/initialized and notifications/roots/list_changed
+ assert len(received_responses) == len(received) # nothing the server sent was anything but a response
+ assert [message.id for message in received_responses] == sent_request_ids
+
+
+async def test_recording_read_stream_ends_iteration_when_the_sender_closes() -> None:
+ """The recording wrapper preserves the end-of-stream behaviour of the stream it wraps.
+
+ This exercises the helper itself rather than an interaction-model behaviour: a transport whose
+ far end closes must end the client's receive loop cleanly, and the wrapper must not swallow or
+ mistranslate that.
+ """
+ send_stream, receive_stream = anyio.create_memory_object_stream[SessionMessage | Exception](1)
+ log: list[SessionMessage | Exception] = []
+ async with send_stream, _RecordingReadStream(receive_stream, log) as wrapped:
+ await send_stream.aclose()
+ items = [item async for item in wrapped]
+ assert items == []
+ assert log == []
+
+
+@requirement("lifecycle:initialized-notification")
+async def test_exactly_one_initialized_notification_is_sent_after_the_handshake() -> None:
+ """The client sends initialized exactly once, between the initialize response and its first request.
+
+ The full method sequence the client puts on the wire is pinned in send order.
+ """
+ recording = RecordingTransport(InMemoryTransport(_echo_server()))
+
+ async with Client(recording) as client:
+ await client.list_tools()
+
+ sent_methods = [
+ message.message.method
+ for message in recording.sent
+ if isinstance(message.message, JSONRPCRequest | JSONRPCNotification)
+ ]
+ assert sent_methods.count("notifications/initialized") == 1
+ assert sent_methods == snapshot(["initialize", "notifications/initialized", "tools/list"])
diff --git a/tests/interaction/mcpserver/__init__.py b/tests/interaction/mcpserver/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py
new file mode 100644
index 0000000000..e7ae4b94d9
--- /dev/null
+++ b/tests/interaction/mcpserver/test_context.py
@@ -0,0 +1,243 @@
+"""The Context convenience methods MCPServer injects into tool functions, observed from the client."""
+
+import pytest
+from inline_snapshot import snapshot
+from pydantic import BaseModel
+
+from mcp import MCPError
+from mcp.client import ClientRequestContext
+from mcp.server.elicitation import AcceptedElicitation
+from mcp.server.mcpserver import Context, MCPServer
+from mcp.types import (
+ METHOD_NOT_FOUND,
+ CallToolResult,
+ ElicitRequestFormParams,
+ ElicitRequestParams,
+ ElicitResult,
+ ErrorData,
+ LoggingMessageNotification,
+ LoggingMessageNotificationParams,
+ TextContent,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._helpers import IncomingMessage
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("mcpserver:context:logging")
+@requirement("logging:capability:declared")
+async def test_context_logging_helpers_send_log_notifications(connect: Connect) -> None:
+ """Each Context logging helper sends a log message notification at the matching severity.
+
+ All four notifications reach the client's logging callback before the tool call returns; none
+ of them carry a logger name unless one is passed explicitly. The server emits these without
+ advertising the logging capability (see the divergence note on logging:capability).
+ """
+ received: list[LoggingMessageNotificationParams] = []
+ mcp = MCPServer("chatty")
+
+ @mcp.tool()
+ async def narrate(ctx: Context) -> str:
+ await ctx.debug("d")
+ await ctx.info("i")
+ await ctx.warning("w")
+ await ctx.error("e")
+ return "done"
+
+ async def collect(params: LoggingMessageNotificationParams) -> None:
+ received.append(params)
+
+ async with connect(mcp, logging_callback=collect) as client:
+ result = await client.call_tool("narrate", {})
+ advertised_logging = client.initialize_result.capabilities.logging
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="done")], structured_content={"result": "done"}))
+ assert received == snapshot(
+ [
+ LoggingMessageNotificationParams(level="debug", data="d"),
+ LoggingMessageNotificationParams(level="info", data="i"),
+ LoggingMessageNotificationParams(level="warning", data="w"),
+ LoggingMessageNotificationParams(level="error", data="e"),
+ ]
+ )
+ # The spec requires servers that emit log notifications to declare the logging capability.
+ assert advertised_logging is None
+
+
+@requirement("mcpserver:context:progress")
+async def test_context_report_progress_sends_progress_notifications(connect: Connect) -> None:
+ """Context.report_progress sends progress notifications correlated to the calling request.
+
+ The caller's progress callback receives each report, in order, before the tool call returns.
+ """
+ received: list[tuple[float, float | None, str | None]] = []
+ mcp = MCPServer("worker")
+
+ @mcp.tool()
+ async def crunch(ctx: Context) -> str:
+ await ctx.report_progress(1, 3)
+ await ctx.report_progress(2, 3, "halfway there")
+ return "crunched"
+
+ async def on_progress(progress: float, total: float | None, message: str | None) -> None:
+ received.append((progress, total, message))
+
+ async with connect(mcp) as client:
+ result = await client.call_tool("crunch", {}, progress_callback=on_progress)
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="crunched")], structured_content={"result": "crunched"})
+ )
+ assert received == snapshot([(1.0, 3.0, None), (2.0, 3.0, "halfway there")])
+
+
+@requirement("protocol:progress:no-token")
+async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None:
+ """When the caller supplied no progress callback, Context.report_progress is a silent no-op.
+
+ The tool also emits one log message as a sentinel: the message handler receives only that,
+ proving the notification pipeline works and no progress notification was sent for the
+ token-less request.
+ """
+ received: list[IncomingMessage] = []
+ mcp = MCPServer("quiet")
+
+ @mcp.tool()
+ async def mill(ctx: Context) -> str:
+ await ctx.report_progress(1, 3)
+ await ctx.info("milling done")
+ return "milled"
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+
+ async with connect(mcp, message_handler=collect) as client:
+ result = await client.call_tool("mill", {})
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="milled")], structured_content={"result": "milled"})
+ )
+ assert received == snapshot(
+ [LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="milling done"))]
+ )
+
+
+@requirement("mcpserver:context:elicit")
+@requirement("tools:call:elicitation-roundtrip")
+async def test_context_elicit_returns_typed_result(connect: Connect) -> None:
+ """Context.elicit sends a form elicitation built from a pydantic schema and returns a typed result.
+
+ The client sees the JSON schema generated from the model; the accepted content is validated
+ back into the model and handed to the tool as result.data.
+ """
+ received: list[ElicitRequestParams] = []
+ mcp = MCPServer("travel")
+
+ class TravelPreferences(BaseModel):
+ destination: str
+ window_seat: bool
+
+ @mcp.tool()
+ async def book_flight(ctx: Context) -> str:
+ answer = await ctx.elicit("Where to?", TravelPreferences)
+ assert isinstance(answer, AcceptedElicitation)
+ return f"{answer.action}: {answer.data.destination} window={answer.data.window_seat}"
+
+ async def answer_form(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
+ received.append(params)
+ return ElicitResult(action="accept", content={"destination": "Lisbon", "window_seat": True})
+
+ async with connect(mcp, elicitation_callback=answer_form) as client:
+ result = await client.call_tool("book_flight", {})
+
+ assert received == snapshot(
+ [
+ ElicitRequestFormParams(
+ _meta={},
+ message="Where to?",
+ requested_schema={
+ "properties": {
+ "destination": {"title": "Destination", "type": "string"},
+ "window_seat": {"title": "Window Seat", "type": "boolean"},
+ },
+ "required": ["destination", "window_seat"],
+ "title": "TravelPreferences",
+ "type": "object",
+ },
+ )
+ ]
+ )
+ assert result == snapshot(
+ CallToolResult(
+ content=[TextContent(text="accept: Lisbon window=True")],
+ structured_content={"result": "accept: Lisbon window=True"},
+ )
+ )
+
+
+@requirement("mcpserver:context:read-resource")
+async def test_context_read_resource_reads_registered_resource(connect: Connect) -> None:
+ """Context.read_resource lets a tool read a resource registered on the same server.
+
+ The tool reports the MIME type and content it read, proving the resource function ran and its
+ return value came back through the context.
+ """
+ mcp = MCPServer("library")
+
+ @mcp.resource("config://app")
+ def app_config() -> str:
+ """The application configuration."""
+ return "theme = dark"
+
+ @mcp.tool()
+ async def show_config(ctx: Context) -> str:
+ contents = list(await ctx.read_resource("config://app"))
+ return "\n".join(f"{item.mime_type}: {item.content!r}" for item in contents)
+
+ async with connect(mcp) as client:
+ result = await client.call_tool("show_config", {})
+
+ assert result == snapshot(
+ CallToolResult(
+ content=[TextContent(text="text/plain: 'theme = dark'")],
+ structured_content={"result": "text/plain: 'theme = dark'"},
+ )
+ )
+
+
+@requirement("logging:message:filtered")
+async def test_set_logging_level_is_rejected_and_messages_are_never_filtered(connect: Connect) -> None:
+ """MCPServer does not support logging/setLevel, so log messages are never filtered by severity.
+
+ The request is rejected with METHOD_NOT_FOUND because MCPServer registers no handler for it,
+ and every message a tool emits is delivered regardless of level. The spec says the server
+ should only send messages at or above the configured level; with no way to configure one,
+ everything is sent.
+ """
+ received: list[LoggingMessageNotificationParams] = []
+ mcp = MCPServer("unfilterable")
+
+ @mcp.tool()
+ async def chatter(ctx: Context) -> str:
+ await ctx.debug("noise")
+ await ctx.error("signal")
+ return "done"
+
+ async def collect(params: LoggingMessageNotificationParams) -> None:
+ received.append(params)
+
+ async with connect(mcp, logging_callback=collect) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.set_logging_level("error")
+
+ await client.call_tool("chatter", {})
+
+ assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
+ assert received == snapshot(
+ [
+ LoggingMessageNotificationParams(level="debug", data="noise"),
+ LoggingMessageNotificationParams(level="error", data="signal"),
+ ]
+ )
diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py
new file mode 100644
index 0000000000..e4cb03d8f5
--- /dev/null
+++ b/tests/interaction/mcpserver/test_prompts.py
@@ -0,0 +1,112 @@
+"""Prompt interactions against MCPServer, driven through the public Client API."""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError
+from mcp.server.mcpserver import MCPServer
+from mcp.types import (
+ ErrorData,
+ GetPromptResult,
+ ListPromptsResult,
+ Prompt,
+ PromptArgument,
+ PromptMessage,
+ TextContent,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("mcpserver:prompt:decorated")
+async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -> None:
+ """A decorated prompt is listed with arguments derived from the function signature.
+
+ Parameters without a default are required; the description comes from the docstring.
+ """
+ mcp = MCPServer("prompter")
+
+ @mcp.prompt()
+ def code_review(code: str, style_guide: str = "pep8") -> str:
+ """Review a piece of code."""
+ raise NotImplementedError # registered for listing only; never rendered
+
+ async with connect(mcp) as client:
+ result = await client.list_prompts()
+
+ assert result == snapshot(
+ ListPromptsResult(
+ prompts=[
+ Prompt(
+ name="code_review",
+ description="Review a piece of code.",
+ arguments=[
+ PromptArgument(name="code", required=True),
+ PromptArgument(name="style_guide", required=False),
+ ],
+ )
+ ]
+ )
+ )
+
+
+@requirement("mcpserver:prompt:decorated")
+async def test_get_prompt_renders_function_return(connect: Connect) -> None:
+ """The decorated function's string return value is rendered as a single user message."""
+ mcp = MCPServer("prompter")
+
+ @mcp.prompt()
+ def greet(name: str) -> str:
+ """A personalised greeting."""
+ return f"Say hello to {name}."
+
+ async with connect(mcp) as client:
+ result = await client.get_prompt("greet", {"name": "Ada"})
+
+ assert result == snapshot(
+ GetPromptResult(
+ description="A personalised greeting.",
+ messages=[PromptMessage(role="user", content=TextContent(text="Say hello to Ada."))],
+ )
+ )
+
+
+@requirement("mcpserver:prompt:unknown-name")
+async def test_get_unknown_prompt_is_error(connect: Connect) -> None:
+ """Getting a prompt name that was never registered fails with a JSON-RPC error."""
+ mcp = MCPServer("prompter")
+
+ @mcp.prompt()
+ def greet(name: str) -> str:
+ """A registered prompt; the test requests a different name."""
+ raise NotImplementedError
+
+ async with connect(mcp) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.get_prompt("nope")
+
+ assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown prompt: nope"))
+
+
+@requirement("prompts:get:missing-required-args")
+async def test_get_prompt_with_a_missing_required_argument_is_an_error(connect: Connect) -> None:
+ """Getting a prompt without one of its required arguments fails with a JSON-RPC error.
+
+ The missing argument is detected before the prompt function is called, but the spec's -32602
+ Invalid params is reported as error code 0 with the bare exception text (see the divergence
+ note on the requirement).
+ """
+ mcp = MCPServer("prompter")
+
+ @mcp.prompt()
+ def greet(name: str) -> str:
+ """A registered prompt; validation rejects the call before the function runs."""
+ raise NotImplementedError
+
+ async with connect(mcp) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.get_prompt("greet")
+
+ assert exc_info.value.error == snapshot(ErrorData(code=0, message="Missing required arguments: {'name'}"))
diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py
new file mode 100644
index 0000000000..8960eb2be2
--- /dev/null
+++ b/tests/interaction/mcpserver/test_resources.py
@@ -0,0 +1,130 @@
+"""Resource interactions against MCPServer, driven through the public Client API."""
+
+import pytest
+from inline_snapshot import snapshot
+
+from mcp import MCPError
+from mcp.server.mcpserver import MCPServer
+from mcp.types import (
+ ErrorData,
+ ListResourcesResult,
+ ListResourceTemplatesResult,
+ ReadResourceResult,
+ Resource,
+ ResourceTemplate,
+ TextResourceContents,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("mcpserver:resource:static")
+async def test_read_static_resource(connect: Connect) -> None:
+ """A function registered for a fixed URI is served at that URI with its return value as text."""
+ mcp = MCPServer("library")
+
+ @mcp.resource("config://app")
+ def app_config() -> str:
+ """The application configuration."""
+ return "theme = dark"
+
+ async with connect(mcp) as client:
+ result = await client.read_resource("config://app")
+
+ assert result == snapshot(
+ ReadResourceResult(
+ contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")]
+ )
+ )
+
+
+@requirement("mcpserver:resource:static")
+async def test_list_static_and_templated_resources(connect: Connect) -> None:
+ """Statically-registered resources appear in resources/list; templated ones only in templates/list.
+
+ The name and description are derived from the function name and docstring; the MIME type
+ defaults to text/plain.
+ """
+ mcp = MCPServer("library")
+
+ @mcp.resource("config://app")
+ def app_config() -> str:
+ """The application configuration."""
+ raise NotImplementedError # registered for listing only; never read
+
+ @mcp.resource("users://{user_id}/profile")
+ def user_profile(user_id: str) -> str:
+ """A user's profile."""
+ raise NotImplementedError # registered for listing only; never read
+
+ async with connect(mcp) as client:
+ resources = await client.list_resources()
+ templates = await client.list_resource_templates()
+
+ assert resources == snapshot(
+ ListResourcesResult(
+ resources=[
+ Resource(
+ name="app_config",
+ uri="config://app",
+ description="The application configuration.",
+ mime_type="text/plain",
+ )
+ ]
+ )
+ )
+ assert templates == snapshot(
+ ListResourceTemplatesResult(
+ resource_templates=[
+ ResourceTemplate(
+ name="user_profile",
+ uri_template="users://{user_id}/profile",
+ description="A user's profile.",
+ mime_type="text/plain",
+ )
+ ]
+ )
+ )
+
+
+@requirement("mcpserver:resource:template")
+@requirement("resources:read:template-vars")
+async def test_read_templated_resource(connect: Connect) -> None:
+ """Reading a URI that matches a registered template invokes the function with the extracted parameters."""
+ mcp = MCPServer("library")
+
+ @mcp.resource("users://{user_id}/profile")
+ def user_profile(user_id: str) -> str:
+ """A user's profile."""
+ return f"profile for {user_id}"
+
+ async with connect(mcp) as client:
+ result = await client.read_resource("users://42/profile")
+
+ assert result == snapshot(
+ ReadResourceResult(
+ contents=[TextResourceContents(uri="users://42/profile", mime_type="text/plain", text="profile for 42")]
+ )
+ )
+
+
+@requirement("mcpserver:resource:unknown-uri")
+async def test_read_unknown_uri_is_error(connect: Connect) -> None:
+ """Reading a URI that matches no registered resource fails with a JSON-RPC error.
+
+ The spec reserves -32002 for resource-not-found; see the divergence note on the requirement.
+ """
+ mcp = MCPServer("library")
+
+ @mcp.resource("config://app")
+ def app_config() -> str:
+ """A registered resource; the test reads a different URI."""
+ raise NotImplementedError
+
+ async with connect(mcp) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.read_resource("config://missing")
+
+ assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown resource: config://missing"))
diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py
new file mode 100644
index 0000000000..ac6fd59650
--- /dev/null
+++ b/tests/interaction/mcpserver/test_tools.py
@@ -0,0 +1,237 @@
+"""Tool interactions against MCPServer, driven through the public Client API."""
+
+import pytest
+from inline_snapshot import snapshot
+from pydantic import BaseModel
+
+from mcp.server.mcpserver import Context, MCPServer
+from mcp.server.mcpserver.exceptions import ToolError
+from mcp.types import (
+ CallToolResult,
+ LoggingMessageNotification,
+ LoggingMessageNotificationParams,
+ TextContent,
+)
+from tests.interaction._connect import Connect
+from tests.interaction._helpers import IncomingMessage
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+@requirement("tools:call:content:text")
+async def test_call_tool_returns_text_content(connect: Connect) -> None:
+ """Arguments reach the tool function; its return value comes back as text content.
+
+ MCPServer also derives an output schema from the return annotation and attaches the
+ matching structuredContent to the result.
+ """
+ mcp = MCPServer("adder")
+
+ @mcp.tool()
+ def add(a: int, b: int) -> str:
+ return str(a + b)
+
+ async with connect(mcp) as client:
+ result = await client.call_tool("add", {"a": 2, "b": 3})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"}))
+
+
+@requirement("mcpserver:tool:handler-throws")
+async def test_call_tool_function_exception_becomes_error_result(connect: Connect) -> None:
+ """An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error."""
+ mcp = MCPServer("errors")
+
+ @mcp.tool()
+ def explode() -> str:
+ raise ValueError("boom")
+
+ async with connect(mcp) as client:
+ result = await client.call_tool("explode", {})
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True)
+ )
+
+
+@requirement("mcpserver:tool:handler-throws")
+async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> None:
+ """A ToolError raised by a tool function is returned as an is_error result, not a JSON-RPC error."""
+ mcp = MCPServer("errors")
+
+ @mcp.tool()
+ def flux() -> str:
+ raise ToolError("flux capacitor offline")
+
+ async with connect(mcp) as client:
+ result = await client.call_tool("flux", {})
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True)
+ )
+
+
+@requirement("mcpserver:tool:unknown-name")
+async def test_call_tool_unknown_name_returns_error_result(connect: Connect) -> None:
+ """Calling a tool name that was never registered is reported as an is_error result.
+
+ The spec classifies unknown tools as a protocol error; see the divergence note on the
+ requirement.
+ """
+ mcp = MCPServer("errors")
+
+ @mcp.tool()
+ def add() -> None:
+ """A registered tool; the test calls a different name."""
+
+ async with connect(mcp) as client:
+ result = await client.call_tool("nope", {})
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True))
+
+
+@requirement("mcpserver:tool:output-schema:model")
+@requirement("tools:call:structured-content:text-mirror")
+async def test_call_tool_model_return_becomes_structured_content(connect: Connect) -> None:
+ """A tool returning a pydantic model advertises the model's schema as the tool's output schema
+ and returns the model's fields as structured content alongside a serialised text block.
+ """
+ mcp = MCPServer("weather")
+
+ class Weather(BaseModel):
+ temperature: float
+ conditions: str
+
+ @mcp.tool()
+ def get_weather() -> Weather:
+ return Weather(temperature=22.5, conditions="sunny")
+
+ async with connect(mcp) as client:
+ listed = await client.list_tools()
+ result = await client.call_tool("get_weather", {})
+
+ assert listed.tools[0].output_schema == snapshot(
+ {
+ "properties": {
+ "temperature": {"title": "Temperature", "type": "number"},
+ "conditions": {"title": "Conditions", "type": "string"},
+ },
+ "required": ["temperature", "conditions"],
+ "title": "Weather",
+ "type": "object",
+ }
+ )
+ assert result == snapshot(
+ CallToolResult(
+ content=[
+ TextContent(
+ text="""\
+{
+ "temperature": 22.5,
+ "conditions": "sunny"
+}\
+"""
+ )
+ ],
+ structured_content={"temperature": 22.5, "conditions": "sunny"},
+ )
+ )
+
+
+@requirement("mcpserver:tool:output-schema:wrapped")
+async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect) -> None:
+ """A tool returning a list wraps the value under a "result" key in both the generated output
+ schema and the structured content.
+ """
+ mcp = MCPServer("primes")
+
+ @mcp.tool()
+ def primes() -> list[int]:
+ return [2, 3, 5]
+
+ async with connect(mcp) as client:
+ listed = await client.list_tools()
+ result = await client.call_tool("primes", {})
+
+ assert listed.tools[0].output_schema == snapshot(
+ {
+ "properties": {"result": {"items": {"type": "integer"}, "title": "Result", "type": "array"}},
+ "required": ["result"],
+ "title": "primesOutput",
+ "type": "object",
+ }
+ )
+ assert result == snapshot(
+ CallToolResult(
+ content=[TextContent(text="2"), TextContent(text="3"), TextContent(text="5")],
+ structured_content={"result": [2, 3, 5]},
+ )
+ )
+
+
+@requirement("mcpserver:tool:input-validation")
+async def test_call_tool_invalid_arguments_become_error_result(connect: Connect) -> None:
+ """Arguments that fail validation against the tool's signature are reported as an is_error
+ result describing the failure, not as a protocol error.
+ """
+ mcp = MCPServer("adder")
+
+ @mcp.tool()
+ def add(a: int, b: int) -> str:
+ """Validation rejects the arguments before the function is ever called."""
+ raise NotImplementedError
+
+ async with connect(mcp) as client:
+ result = await client.call_tool("add", {"b": 3})
+
+ # The description is raw pydantic output -- it embeds a pydantic-version-specific
+ # errors.pydantic.dev URL and the internal `addArguments` model name -- so only the stable
+ # prefix is asserted; a full snapshot would break on every pydantic upgrade.
+ assert result.is_error is True
+ assert isinstance(result.content[0], TextContent)
+ assert result.content[0].text.startswith("Error executing tool add: 1 validation error")
+
+
+@requirement("mcpserver:register:post-connect")
+async def test_adding_and_removing_tools_does_not_notify_connected_clients(connect: Connect) -> None:
+ """Mutating the tool set on a running server changes tools/list but sends no notification.
+
+ add_tool and remove_tool only update the registry: a connected client that listed the tools
+ before the mutation has no way to learn it should list them again. The spec provides
+ notifications/tools/list_changed for exactly this; MCPServer never sends it. The tool emits
+ one log message as a sentinel so the test proves notifications do reach the collector -- the
+ log message arrives, a list_changed does not.
+ """
+ received: list[IncomingMessage] = []
+ mcp = MCPServer("mutable")
+
+ def extra() -> str:
+ """A tool registered at runtime; never called."""
+ raise NotImplementedError
+
+ @mcp.tool()
+ def doomed() -> str:
+ """A tool removed at runtime; never called."""
+ raise NotImplementedError
+
+ @mcp.tool()
+ async def grow(ctx: Context) -> str:
+ mcp.add_tool(extra, name="extra")
+ mcp.remove_tool("doomed")
+ await ctx.info("tool set changed")
+ return "mutated"
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+
+ async with connect(mcp, message_handler=collect) as client:
+ before = await client.list_tools()
+ await client.call_tool("grow", {})
+ after = await client.list_tools()
+
+ assert [tool.name for tool in before.tools] == ["doomed", "grow"]
+ assert [tool.name for tool in after.tools] == ["grow", "extra"]
+ assert received == snapshot(
+ [LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="tool set changed"))]
+ )
diff --git a/tests/interaction/test_coverage.py b/tests/interaction/test_coverage.py
new file mode 100644
index 0000000000..47b1b95e71
--- /dev/null
+++ b/tests/interaction/test_coverage.py
@@ -0,0 +1,104 @@
+"""Enforces the contract between the requirements manifest and the test suite.
+
+The contract runs in both directions: every non-deferred entry in :data:`REQUIREMENTS` must be
+exercised by at least one test, and every test in the suite must carry at least one
+`@requirement(...)` mark referencing a manifest entry. Deferral reasons that point at coverage
+elsewhere in the repo must point at paths that exist. Test modules are imported directly
+(rather than relying on pytest collection) so the check holds even when only this file is run.
+"""
+
+import importlib
+import re
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+from tests.interaction._requirements import REQUIREMENTS, Requirement, covered_by, requirement
+
+_SUITE_ROOT = Path(__file__).parent
+_REPO_ROOT = _SUITE_ROOT.parent.parent
+
+# Repo paths cited inside deferral reasons ("Covered by tests/... ").
+_CITED_PATH = re.compile(r"(?:tests|src)/[\w./-]*\w")
+
+# Tests that exercise the suite's own helpers rather than an interaction-model behaviour.
+# Anything listed here is exempt from the every-test-has-a-requirement check.
+_HARNESS_SELF_TESTS = {
+ "tests.interaction.lowlevel.test_wire.test_recording_read_stream_ends_iteration_when_the_sender_closes",
+ "tests.interaction.transports.test_bridge.test_response_chunks_arrive_as_the_application_sends_them",
+ "tests.interaction.transports.test_bridge.test_closing_the_response_delivers_a_disconnect_to_the_application",
+ "tests.interaction.transports.test_bridge.test_an_application_failure_before_the_response_starts_fails_the_request",
+ "tests.interaction.transports.test_bridge.test_disabling_cancel_on_close_lets_the_application_finish_after_disconnect",
+}
+
+
+def _import_all_test_modules() -> list[ModuleType]:
+ """Import every other test module in the suite so their `@requirement` decorators register."""
+ modules: list[ModuleType] = []
+ for path in sorted(_SUITE_ROOT.rglob("test_*.py")):
+ relative = path.relative_to(_SUITE_ROOT).with_suffix("")
+ name = f"{__package__}.{'.'.join(relative.parts)}"
+ if name != __name__:
+ modules.append(importlib.import_module(name))
+ return modules
+
+
+def test_every_requirement_is_exercised() -> None:
+ """Each non-deferred requirement is covered by at least one test (deferred ones by none)."""
+ _import_all_test_modules()
+
+ uncovered = [
+ requirement_id
+ for requirement_id, spec in sorted(REQUIREMENTS.items())
+ if spec.deferred is None and not covered_by(requirement_id)
+ ]
+ assert not uncovered, f"Requirements with no test and no deferred reason: {uncovered}"
+
+ stale_deferrals = [
+ requirement_id
+ for requirement_id, spec in sorted(REQUIREMENTS.items())
+ if spec.deferred is not None and covered_by(requirement_id)
+ ]
+ assert not stale_deferrals, f"Deferred requirements that now have tests (remove deferred): {stale_deferrals}"
+
+
+def test_every_test_exercises_a_requirement() -> None:
+ """Each test in the suite carries at least one `@requirement` mark (harness self-tests excepted)."""
+ all_tests = {
+ f"{module.__name__}.{name}"
+ for module in _import_all_test_modules()
+ for name in vars(module)
+ if name.startswith("test_")
+ }
+ linked_tests = {test_name for requirement_id in REQUIREMENTS for test_name in covered_by(requirement_id)}
+
+ unlinked = sorted(all_tests - linked_tests - _HARNESS_SELF_TESTS)
+ assert not unlinked, f"Tests with no @requirement mark: {unlinked}"
+
+ stale_exemptions = sorted(_HARNESS_SELF_TESTS - all_tests)
+ assert not stale_exemptions, f"Harness self-test exemptions that no longer exist: {stale_exemptions}"
+
+
+def test_deferral_reasons_cite_existing_paths() -> None:
+ """Every repo path named in a deferral reason exists, so coverage pointers cannot rot."""
+ missing = sorted(
+ f"{requirement_id}: {cited}"
+ for requirement_id, spec in REQUIREMENTS.items()
+ if spec.deferred is not None
+ for cited in _CITED_PATH.findall(spec.deferred)
+ if not (_REPO_ROOT / cited).exists()
+ )
+ assert not missing, f"Deferral reasons citing paths that do not exist: {missing}"
+
+
+def test_unknown_requirement_id_is_rejected() -> None:
+ """Marking a test with an ID that is not in the manifest fails at decoration time."""
+ with pytest.raises(KeyError, match="Unknown requirement id 'tools:call:does-not-exist'"):
+ requirement("tools:call:does-not-exist")
+
+
+def test_invalid_requirement_source_is_rejected() -> None:
+ """A requirement whose source is not a spec URL, 'sdk', or an issue reference fails at construction."""
+ with pytest.raises(ValueError, match="source must be a specification URL"):
+ Requirement(source="https://example.com/not-the-spec", behavior="Never constructed.")
diff --git a/tests/interaction/transports/__init__.py b/tests/interaction/transports/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/interaction/transports/_bridge.py b/tests/interaction/transports/_bridge.py
new file mode 100644
index 0000000000..6d0bfd62d4
--- /dev/null
+++ b/tests/interaction/transports/_bridge.py
@@ -0,0 +1,164 @@
+"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx.
+
+`httpx.ASGITransport` runs the application to completion and only then hands the buffered
+response to the caller, so a server that streams its response — the streamable HTTP transport's
+SSE responses — can never converse with the client mid-request: a server-initiated request
+nested inside a still-open call deadlocks. `StreamingASGITransport` removes that limitation by
+running the application as a background task and forwarding every `http.response.body` chunk to
+the client the moment it is sent. Everything happens on the one event loop: no sockets, no
+threads, no sleeps, no extra dependencies.
+
+The behavioural contract, pinned by `test_bridge.py`:
+
+- The request body is buffered before the application is invoked (MCP requests are small JSON
+ documents); the response streams chunk by chunk.
+- Closing the response — or the whole client — delivers `http.disconnect` to the application,
+ exactly as a real server sees when its peer goes away.
+- An exception the application raises before sending `http.response.start` fails the originating
+ request with that same exception. After the response has started, a failure is visible to the
+ client only through the response itself (status code, truncated body) — the same signal a real
+ server over a real socket would give.
+
+The transport owns an anyio task group for the application tasks; it is opened and closed by
+`httpx.AsyncClient`'s own context manager, so use the client as a context manager (the suite
+always does). Closing the transport cancels every running application task by default; set
+`cancel_on_close=False` to wait for the application's own disconnect handling instead.
+"""
+
+import math
+from collections.abc import AsyncIterator
+from types import TracebackType
+
+import anyio
+import anyio.abc
+import httpx
+from anyio.streams.memory import MemoryObjectReceiveStream
+from starlette.types import ASGIApp, Message, Scope
+
+
+class _StreamingResponseBody(httpx.AsyncByteStream):
+ """A response body that yields chunks as the application produces them.
+
+ Closing it tells the application the client has gone away (`http.disconnect`), mirroring a
+ peer that drops the connection mid-response.
+ """
+
+ def __init__(self, chunks: MemoryObjectReceiveStream[bytes], client_disconnected: anyio.Event) -> None:
+ self._chunks = chunks
+ self._client_disconnected = client_disconnected
+
+ async def __aiter__(self) -> AsyncIterator[bytes]:
+ async for chunk in self._chunks:
+ yield chunk
+
+ async def aclose(self) -> None:
+ self._client_disconnected.set()
+ await self._chunks.aclose()
+
+
+class StreamingASGITransport(httpx.AsyncBaseTransport):
+ """Drive an ASGI application in-process, streaming each response as it is produced.
+
+ With `cancel_on_close` (the default), closing the transport cancels every application task
+ still running so harness teardown can never hang. Setting it to False makes the transport wait
+ for the application's own disconnect handling to complete instead, which is the path the legacy
+ SSE server transport relies on for resource cleanup.
+ """
+
+ _task_group: anyio.abc.TaskGroup
+
+ def __init__(self, app: ASGIApp, *, cancel_on_close: bool = True) -> None:
+ self._app = app
+ self._cancel_on_close = cancel_on_close
+
+ async def __aenter__(self) -> "StreamingASGITransport":
+ self._task_group = anyio.create_task_group()
+ await self._task_group.__aenter__()
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None = None,
+ exc_value: BaseException | None = None,
+ traceback: TracebackType | None = None,
+ ) -> None:
+ # httpx closes every streamed response before closing the transport, so by now each
+ # application task has been delivered `http.disconnect`. Either cancel immediately, or wait
+ # for the application's own disconnect handling to unwind.
+ if self._cancel_on_close:
+ self._task_group.cancel_scope.cancel()
+ await self._task_group.__aexit__(exc_type, exc_value, traceback)
+
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
+ assert isinstance(request.stream, httpx.AsyncByteStream)
+ request_body = b"".join([chunk async for chunk in request.stream])
+
+ scope: Scope = {
+ "type": "http",
+ "asgi": {"version": "3.0"},
+ "http_version": "1.1",
+ "method": request.method,
+ "scheme": request.url.scheme,
+ "path": request.url.path,
+ "raw_path": request.url.raw_path.split(b"?", maxsplit=1)[0],
+ "query_string": request.url.query,
+ "root_path": "",
+ "headers": [(name.lower(), value) for name, value in request.headers.raw],
+ "server": (request.url.host, request.url.port),
+ "client": ("127.0.0.1", 1234),
+ }
+
+ request_delivered = False
+ client_disconnected = anyio.Event()
+ response_started = anyio.Event()
+ response_status = 0
+ response_headers: list[tuple[bytes, bytes]] = []
+ application_error: Exception | None = None
+ chunk_writer, chunk_reader = anyio.create_memory_object_stream[bytes](math.inf)
+
+ async def receive_request() -> Message:
+ nonlocal request_delivered
+ if not request_delivered:
+ request_delivered = True
+ return {"type": "http.request", "body": request_body, "more_body": False}
+ await client_disconnected.wait()
+ return {"type": "http.disconnect"}
+
+ async def send_response(message: Message) -> None:
+ nonlocal response_status, response_headers
+ if message["type"] == "http.response.start":
+ response_status = message["status"]
+ response_headers = list(message.get("headers", []))
+ response_started.set()
+ return
+ assert message["type"] == "http.response.body"
+ body: bytes = message.get("body", b"")
+ if body:
+ await chunk_writer.send(body)
+ if not message.get("more_body", False):
+ await chunk_writer.aclose()
+
+ async def run_application() -> None:
+ nonlocal application_error
+ try:
+ await self._app(scope, receive_request, send_response)
+ except Exception as exc: # The bridge is the application's outermost boundary: a crash
+ # must fail the originating request (or show up in the already-started response),
+ # never tear down the task group shared with every other in-flight request.
+ application_error = exc
+ finally:
+ response_started.set()
+ await chunk_writer.aclose()
+
+ self._task_group.start_soon(run_application)
+ await response_started.wait()
+ if application_error is not None:
+ # No response will be built, so close the reader the response body would have owned.
+ await chunk_reader.aclose()
+ raise application_error
+ return httpx.Response(
+ status_code=response_status,
+ headers=response_headers,
+ stream=_StreamingResponseBody(chunk_reader, client_disconnected),
+ request=request,
+ )
diff --git a/tests/interaction/transports/_stdio_server.py b/tests/interaction/transports/_stdio_server.py
new file mode 100644
index 0000000000..fbe7e614f7
--- /dev/null
+++ b/tests/interaction/transports/_stdio_server.py
@@ -0,0 +1,56 @@
+"""A real low-level Server over the stdio transport, for the suite's one subprocess test.
+
+Runnable as `python -m tests.interaction.transports._stdio_server` from the repo root; the test
+launches it that way via `stdio_client`. Kept separate from the test module so the server lives in
+its own importable file (subprocess coverage applies) while the test file follows the suite's
+test-only-functions convention.
+"""
+
+import sys
+
+import anyio
+
+from mcp.server import Server, ServerRequestContext
+from mcp.server.stdio import stdio_server
+from mcp.types import (
+ CallToolRequestParams,
+ CallToolResult,
+ ListToolsResult,
+ PaginatedRequestParams,
+ TextContent,
+ Tool,
+)
+
+
+async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
+ return ListToolsResult(
+ tools=[
+ Tool(
+ name="echo",
+ input_schema={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
+ )
+ ]
+ )
+
+
+async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
+ assert params.name == "echo"
+ assert params.arguments is not None
+ text = params.arguments["text"]
+ await ctx.session.send_log_message(level="info", data=f"echoing {text}", logger="echo")
+ return CallToolResult(content=[TextContent(text=text)])
+
+
+server = Server("stdio-echo", on_list_tools=list_tools, on_call_tool=call_tool)
+
+
+async def main() -> None:
+ async with stdio_server() as (read_stream, write_stream):
+ await server.run(read_stream, write_stream, server.create_initialization_options())
+ # Reached only when the run loop exits because stdin closed; if the process were terminated
+ # the test's stderr capture would not see this line.
+ print("stdio-echo: clean exit", file=sys.stderr, flush=True)
+
+
+if __name__ == "__main__":
+ anyio.run(main)
diff --git a/tests/interaction/transports/test_bridge.py b/tests/interaction/transports/test_bridge.py
new file mode 100644
index 0000000000..71be14ced0
--- /dev/null
+++ b/tests/interaction/transports/test_bridge.py
@@ -0,0 +1,92 @@
+"""Contract tests for the suite's streaming ASGI bridge.
+
+These pin what `StreamingASGITransport` itself guarantees — chunk-by-chunk delivery, disconnect
+propagation, and failure handling — against minimal hand-written ASGI applications, so the MCP
+transport tests built on top of it never have to wonder what the harness provides. They are
+harness self-tests, not interaction-model tests, and are exempted from the requirement-coverage
+contract in `test_coverage.py`.
+"""
+
+import anyio
+import httpx
+import pytest
+from starlette.types import Message, Receive, Scope, Send
+
+from tests.interaction.transports._bridge import StreamingASGITransport
+
+pytestmark = pytest.mark.anyio
+
+
+async def test_response_chunks_arrive_as_the_application_sends_them() -> None:
+ """Each body chunk is delivered as sent, empty chunks are skipped, and the stream ends with the application."""
+
+ async def chunked_app(scope: Scope, receive: Receive, send: Send) -> None:
+ assert scope["type"] == "http"
+ assert (await receive())["type"] == "http.request"
+ await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]})
+ await send({"type": "http.response.body", "body": b"first", "more_body": True})
+ await send({"type": "http.response.body", "body": b"", "more_body": True})
+ await send({"type": "http.response.body", "body": b"second", "more_body": False})
+
+ async with httpx.AsyncClient(transport=StreamingASGITransport(chunked_app), base_url="http://bridge") as http:
+ async with http.stream("GET", "/chunks") as response:
+ with anyio.fail_after(5):
+ chunks = [chunk async for chunk in response.aiter_raw()]
+
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "text/plain"
+ assert chunks == [b"first", b"second"]
+
+
+async def test_closing_the_response_delivers_a_disconnect_to_the_application() -> None:
+ """A client that closes the response early is seen by the application as an http.disconnect."""
+ seen_after_request: list[Message] = []
+ disconnect_seen = anyio.Event()
+
+ async def waiting_app(scope: Scope, receive: Receive, send: Send) -> None:
+ assert scope["type"] == "http"
+ assert (await receive())["type"] == "http.request"
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ seen_after_request.append(await receive())
+ disconnect_seen.set()
+
+ async with httpx.AsyncClient(transport=StreamingASGITransport(waiting_app), base_url="http://bridge") as http:
+ async with http.stream("GET", "/wait") as response:
+ assert response.status_code == 200
+ # Leaving the stream block closes the response while the application is still mid-response.
+ with anyio.fail_after(5):
+ await disconnect_seen.wait()
+
+ assert seen_after_request == [{"type": "http.disconnect"}]
+
+
+async def test_an_application_failure_before_the_response_starts_fails_the_request() -> None:
+ """An exception raised before http.response.start reaches the caller as that same exception."""
+
+ async def broken_app(scope: Scope, receive: Receive, send: Send) -> None:
+ raise RuntimeError("the demo application is broken")
+
+ async with httpx.AsyncClient(transport=StreamingASGITransport(broken_app), base_url="http://bridge") as http:
+ with pytest.raises(RuntimeError, match="the demo application is broken"):
+ await http.get("/broken")
+
+
+async def test_disabling_cancel_on_close_lets_the_application_finish_after_disconnect() -> None:
+ """With cancel_on_close=False, an application that runs cleanup after seeing http.disconnect
+ completes that cleanup before the transport finishes closing."""
+ cleanup_ran = anyio.Event()
+
+ async def lingering_app(scope: Scope, receive: Receive, send: Send) -> None:
+ assert scope["type"] == "http"
+ await receive()
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ assert (await receive())["type"] == "http.disconnect"
+ cleanup_ran.set()
+
+ transport = StreamingASGITransport(lingering_app, cancel_on_close=False)
+ async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http:
+ with anyio.fail_after(5):
+ async with http.stream("GET", "/linger") as response:
+ assert response.status_code == 200
+ assert not cleanup_ran.is_set()
+ assert cleanup_ran.is_set()
diff --git a/tests/interaction/transports/test_sse.py b/tests/interaction/transports/test_sse.py
new file mode 100644
index 0000000000..1d5434c160
--- /dev/null
+++ b/tests/interaction/transports/test_sse.py
@@ -0,0 +1,98 @@
+"""Behaviour specific to the legacy HTTP+SSE transport, exercised entirely in process.
+
+Transport-agnostic behaviour is covered by the `connect`-fixture matrix, which runs the rest of
+the suite over this transport as well; this file pins only what is observable on the SSE wiring
+itself: the GET-then-POST connection lifecycle, the endpoint event, and how the message endpoint
+rejects requests it cannot route to a session. Every test drives the server's real Starlette app
+through the suite's streaming ASGI bridge.
+"""
+
+import gc
+import warnings
+from uuid import UUID, uuid4
+
+import anyio
+import httpx
+import pytest
+from inline_snapshot import snapshot
+
+from mcp.client.client import Client
+from mcp.client.sse import sse_client
+from mcp.server import Server
+from mcp.types import EmptyResult
+from tests.interaction._connect import build_sse_app
+from tests.interaction._requirements import requirement
+from tests.interaction.transports._bridge import StreamingASGITransport
+
+pytestmark = pytest.mark.anyio
+
+_BASE_URL = "http://127.0.0.1:8000"
+
+
+@requirement("transport:sse")
+@requirement("transport:sse:endpoint-event")
+async def test_endpoint_event_names_the_message_endpoint_with_a_fresh_session_id() -> None:
+ """Connecting opens a GET stream whose first event names the POST endpoint and a fresh
+ session id; messages POSTed there are answered on that stream, and disconnecting releases the
+ server's session entry."""
+ app, sse = build_sse_app(Server("legacy"))
+ captured_session_id: list[str] = []
+
+ def httpx_client_factory(
+ headers: dict[str, str] | None = None,
+ timeout: httpx.Timeout | None = None,
+ auth: httpx.Auth | None = None,
+ ) -> httpx.AsyncClient:
+ return httpx.AsyncClient(
+ transport=StreamingASGITransport(app, cancel_on_close=False),
+ base_url=_BASE_URL,
+ headers=headers,
+ timeout=timeout,
+ auth=auth,
+ )
+
+ transport = sse_client(
+ f"{_BASE_URL}/sse", httpx_client_factory=httpx_client_factory, on_session_created=captured_session_id.append
+ )
+ with anyio.fail_after(5):
+ async with Client(transport) as client:
+ assert len(captured_session_id) == 1
+ assert UUID(hex=captured_session_id[0]) in sse._read_stream_writers
+ assert await client.send_ping() == snapshot(EmptyResult())
+
+ assert sse._read_stream_writers == {}
+ # See connect_over_sse: collect the one stream sse_starlette never closes on disconnect.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", ResourceWarning)
+ gc.collect()
+
+
+@requirement("transport:sse:post:session-routing")
+async def test_post_without_a_session_id_is_rejected() -> None:
+ """A POST to the message endpoint with no session_id query parameter is answered 400."""
+ app, _ = build_sse_app(Server("legacy"))
+ async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=_BASE_URL) as http:
+ response = await http.post("/messages/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
+ assert (response.status_code, response.text) == snapshot((400, "session_id is required"))
+
+
+@requirement("transport:sse:post:session-routing")
+async def test_post_with_a_malformed_session_id_is_rejected() -> None:
+ """A POST whose session_id query parameter is not a UUID is answered 400."""
+ app, _ = build_sse_app(Server("legacy"))
+ async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=_BASE_URL) as http:
+ response = await http.post(
+ "/messages/", params={"session_id": "not-a-uuid"}, json={"jsonrpc": "2.0", "method": "ping", "id": 1}
+ )
+ assert (response.status_code, response.text) == snapshot((400, "Invalid session ID"))
+
+
+@requirement("transport:sse:post:session-routing")
+async def test_post_for_an_unknown_session_is_rejected() -> None:
+ """A POST naming a well-formed session_id that no SSE stream owns is answered 404."""
+ app, _ = build_sse_app(Server("legacy"))
+ async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=_BASE_URL) as http:
+ response = await http.post(
+ "/messages/", params={"session_id": uuid4().hex}, json={"jsonrpc": "2.0", "method": "ping", "id": 1}
+ )
+ assert (response.status_code, response.text) == snapshot((404, "Could not find session"))
diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py
new file mode 100644
index 0000000000..e70a68225f
--- /dev/null
+++ b/tests/interaction/transports/test_stdio.py
@@ -0,0 +1,76 @@
+"""The suite's one stdio end-to-end test: a real SDK Server in a subprocess, driven by Client.
+
+Everything else in the suite runs in a single process; this test exists to prove the same
+client↔server round trip works over the stdio transport's real boundary (a child process whose
+stdin/stdout carry one newline-delimited JSON-RPC message per line). The server lives in
+`_stdio_server.py` and is launched via `python -m` so subprocess coverage measurement applies.
+
+stdio is deliberately not a leg of the `connect`-fixture matrix: spawning a subprocess per test
+would be slow, and the matrix already proves transport-agnosticism over three in-process
+transports. Process-lifecycle edge cases (escalation to terminate/kill, stderr handling, parse
+errors) are covered by `tests/client/test_stdio.py` and stay deferred here.
+"""
+
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+
+from mcp.client.client import Client
+from mcp.client.stdio import StdioServerParameters, stdio_client
+from mcp.types import CallToolResult, LoggingMessageNotificationParams, TextContent
+from tests.interaction._requirements import requirement
+from tests.interaction.transports import _stdio_server
+
+pytestmark = pytest.mark.anyio
+
+_REPO_ROOT = Path(__file__).parents[3]
+
+
+@requirement("transport:stdio")
+@requirement("transport:stdio:clean-shutdown")
+async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -> None:
+ """A Client connected over stdio initializes, calls a tool with arguments, receives the
+ server's log notification before the call returns, and the server exits when the transport
+ closes its stdin."""
+ received: list[LoggingMessageNotificationParams] = []
+
+ async def collect(params: LoggingMessageNotificationParams) -> None:
+ received.append(params)
+
+ with tempfile.TemporaryFile(mode="w+") as errlog:
+ transport = stdio_client(
+ StdioServerParameters(
+ command=sys.executable,
+ args=["-m", _stdio_server.__name__],
+ cwd=str(_REPO_ROOT),
+ # stdio_client deliberately filters the inherited environment to a safe minimum,
+ # which drops the variables coverage.py's subprocess support uses; pass them through
+ # so the server module is measured. Empty when not running under coverage.
+ env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")},
+ ),
+ errlog=errlog,
+ )
+
+ with anyio.fail_after(10):
+ async with Client(transport, logging_callback=collect) as client:
+ assert client.initialize_result.server_info.name == "stdio-echo"
+ result = await client.call_tool("echo", {"text": "across\nprocesses"})
+
+ errlog.seek(0)
+ captured_stderr = errlog.read()
+
+ assert result == snapshot(CallToolResult(content=[TextContent(text="across\nprocesses")]))
+ # stdio carries one ordered server→client stream, so the same notification-before-response
+ # guarantee holds here as for the in-memory transport.
+ assert received == snapshot(
+ [LoggingMessageNotificationParams(level="info", logger="echo", data="echoing across\nprocesses")]
+ )
+ # The server writes this line only after its run loop returns, which happens when stdin closes:
+ # seeing it proves the process exited on its own rather than via the transport's terminate
+ # escalation, without a timing-based assertion.
+ assert captured_stderr == snapshot("stdio-echo: clean exit\n")
diff --git a/tests/interaction/transports/test_streamable_http.py b/tests/interaction/transports/test_streamable_http.py
new file mode 100644
index 0000000000..f20fa44f05
--- /dev/null
+++ b/tests/interaction/transports/test_streamable_http.py
@@ -0,0 +1,166 @@
+"""Behaviour specific to the streamable HTTP transport, exercised entirely in process.
+
+Transport-agnostic behaviour is covered by the `connect`-fixture matrix, which runs the rest of
+the suite over this transport as well; this file only pins what cannot be observed in memory: the
+server's stateless and JSON-response modes, the standalone GET stream, and the full-duplex
+server-initiated exchange on a still-open call. Every test drives the server's real Starlette app
+through the suite's streaming ASGI bridge — no sockets, threads, or subprocesses.
+"""
+
+import anyio
+import pytest
+from inline_snapshot import snapshot
+from pydantic import BaseModel
+
+from mcp.client import ClientRequestContext
+from mcp.server.elicitation import AcceptedElicitation
+from mcp.server.mcpserver import Context, MCPServer
+from mcp.types import (
+ CallToolResult,
+ ElicitRequestParams,
+ ElicitResult,
+ LoggingMessageNotification,
+ LoggingMessageNotificationParams,
+ ResourceUpdatedNotification,
+ ResourceUpdatedNotificationParams,
+ TextContent,
+)
+from tests.interaction._connect import connect_over_streamable_http
+from tests.interaction._helpers import IncomingMessage
+from tests.interaction._requirements import requirement
+
+pytestmark = pytest.mark.anyio
+
+
+def _smoke_server() -> MCPServer:
+ """A server exercising each message shape the transport-specific tests need."""
+ mcp = MCPServer("smoke", instructions="Talk to the smoke server.")
+
+ @mcp.tool()
+ def echo(text: str) -> str:
+ """Echo the text back."""
+ return text
+
+ class Confirmation(BaseModel):
+ confirmed: bool
+
+ @mcp.tool()
+ async def ask(ctx: Context) -> str:
+ """Elicit a confirmation from the client and report the outcome."""
+ answer = await ctx.elicit("Proceed?", Confirmation)
+ # In stateless mode the elicit raises before this point: there is no session to call back through.
+ assert isinstance(answer, AcceptedElicitation)
+ return f"confirmed={answer.data.confirmed}"
+
+ @mcp.tool()
+ async def announce(ctx: Context) -> str:
+ """Send one notification related to this request and one that is not."""
+ await ctx.info("about to announce")
+ await ctx.session.send_resource_updated("file:///watched.txt")
+ return "announced"
+
+ return mcp
+
+
+@requirement("transport:streamable-http:json-response")
+async def test_tool_call_over_streamable_http_with_json_responses() -> None:
+ """The round trip works when the server answers with a single JSON body instead of an SSE stream."""
+ async with connect_over_streamable_http(_smoke_server(), json_response=True) as client:
+ assert client.initialize_result.server_info.name == "smoke"
+ result = await client.call_tool("echo", {"text": "as json"})
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="as json")], structured_content={"result": "as json"})
+ )
+
+
+@requirement("transport:streamable-http:stateless")
+async def test_tool_calls_over_stateless_streamable_http() -> None:
+ """Consecutive requests each succeed against a stateless server with no session to share."""
+ async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
+ first = await client.call_tool("echo", {"text": "first"})
+ second = await client.call_tool("echo", {"text": "second"})
+
+ assert first == snapshot(
+ CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"})
+ )
+ assert second == snapshot(
+ CallToolResult(content=[TextContent(text="second")], structured_content={"result": "second"})
+ )
+
+
+@requirement("transport:streamable-http:stateless-restrictions")
+async def test_stateless_streamable_http_rejects_server_initiated_requests() -> None:
+ """A handler that tries to call back to the client in stateless mode fails: there is no session."""
+ async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
+ result = await client.call_tool("ask", {})
+
+ assert result.is_error is True
+ assert isinstance(result.content[0], TextContent)
+ # The exact message is the StatelessModeNotSupported exception text wrapped by the tool-error
+ # path; pin the stable prefix rather than the full exception prose.
+ assert result.content[0].text.startswith("Error executing tool ask:")
+
+
+@requirement("transport:streamable-http:notifications")
+@requirement("transport:streamable-http:unrelated-messages")
+async def test_unrelated_server_messages_arrive_on_the_standalone_stream() -> None:
+ """A server message with no related request reaches the client through the standalone GET stream.
+
+ The log notification is related to the tool call and travels on that call's own SSE stream;
+ the resource-updated notification is not related to any request, so the only way it can reach
+ the client is the standalone stream the client opens after initialization. Delivery order
+ across the two streams is not guaranteed, so the unrelated message is awaited rather than
+ assumed to beat the tool result.
+ """
+ received: list[IncomingMessage] = []
+ resource_update_seen = anyio.Event()
+
+ async def collect(message: IncomingMessage) -> None:
+ received.append(message)
+ if isinstance(message, ResourceUpdatedNotification):
+ resource_update_seen.set()
+
+ async with connect_over_streamable_http(_smoke_server(), message_handler=collect) as client:
+ result = await client.call_tool("announce", {})
+ with anyio.fail_after(5):
+ await resource_update_seen.wait()
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="announced")], structured_content={"result": "announced"})
+ )
+ # The related log notification rides the call's stream; the unrelated resource-updated
+ # notification rides the standalone stream. Both arrive, nothing else does.
+ assert [message for message in received if isinstance(message, LoggingMessageNotification)] == snapshot(
+ [LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="about to announce"))]
+ )
+ assert [message for message in received if isinstance(message, ResourceUpdatedNotification)] == snapshot(
+ [ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
+ )
+ assert len(received) == 2
+
+
+@requirement("transport:streamable-http:stateful")
+@requirement("transport:streamable-http:server-to-client")
+async def test_server_initiated_elicitation_round_trips_during_a_tool_call() -> None:
+ """An elicitation issued mid-call reaches the client and its answer reaches the handler over stateful HTTP.
+
+ The elicitation request travels on the still-open SSE response of the tool call that triggered
+ it, and the client's answer arrives as a separate POST -- the full-duplex exchange the
+ streamable HTTP transport exists to provide.
+ """
+ asked: list[ElicitRequestParams] = []
+
+ async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
+ asked.append(params)
+ return ElicitResult(action="accept", content={"confirmed": True})
+
+ async with connect_over_streamable_http(_smoke_server(), elicitation_callback=answer) as client:
+ # Bounded because a harness regression here historically meant deadlock, not failure.
+ with anyio.fail_after(5):
+ result = await client.call_tool("ask", {})
+
+ assert result == snapshot(
+ CallToolResult(content=[TextContent(text="confirmed=True")], structured_content={"result": "confirmed=True"})
+ )
+ assert [params.message for params in asked] == snapshot(["Proceed?"])