chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
# 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-process and event-driven — including the streamable HTTP, SSE, and OAuth
flows — with a single subprocess test for stdio.
## 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)`. A test that must let in-flight deliveries
settle before teardown (an abandoned request's late error response, say) may use
`anyio.wait_all_tasks_blocked()`: the whole suite is single-loop and task-driven, so
quiescence is deterministic. The HTTP and OAuth 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 outside the one stdio test.
## 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 (sessions, resumability, framing)
auth/ OAuth flows against an in-process authorization server
```
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, over the server's
real streamable HTTP app driven in-process through the streaming bridge (in both stateful and
stateless configurations), and over the legacy SSE transport the same way. A test connects with
`async with connect(server, ...) as client:` and asserts the same output on every leg, because the
transport is not supposed to change observable behaviour. Requirements that need a server-to-client
back-channel or persisted session state are carved out of the stateless arm via `arm_exclusions`.
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.
A transport conformance test in `transports/` speaks raw `httpx` against the mounted ASGI app
**only** when its assertion is about HTTP semantics that `Client` cannot observe — status codes,
response headers, SSE event fields, which stream a message travels on. Any other behaviour is
asserted through a `Client`, connected to the mounted app via `client_via_http(http)` so several
clients can share one session manager.
## 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 has no test in this suite, with a precise
reason: the SDK does not implement it, the negative cannot be observed, the assertion is
schema-level rather than interaction-level, the feature is experimental (tasks), or the test
would require real-time waits the suite refuses.
- **`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.
- **`note`** carries free-form context that does not fit `divergence` or `deferred`.
- **`added_in`** / **`removed_in`** bound the spec versions the behaviour exists in, as a half-open
`[added_in, removed_in)` window.
- **`supersedes`** / **`superseded_by`** link a retired entry to its replacement; the link is
bidirectional and both ends must be versioned.
- **`arm_exclusions`** carve specific `(transport, spec_version)` matrix cells out with a typed
`ArmExclusionReason`.
- **`known_failures`** mark specific `(transport, spec_version)` cells as strict xfail.
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.
A requirement may carry both `divergence` and `deferred`: the divergence records that the SDK falls
short of the spec, and the deferral records why no test pins it (typically because the divergent
behaviour cannot be driven through the public API). Divergence alone implies a test pins the
divergent behaviour; divergence plus deferred means the gap is known but unpinned.
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).
### Spec versions and the era axis
`SPEC_VERSIONS` in `_requirements.py` is the ordered tuple of protocol revisions the suite
exercises. `SPEC_BASE_URL` (and `SPEC_2026_BASE_URL`) are pinned literals — not derived from
`SPEC_VERSIONS` — so growing the active axis never repoints existing `source` links. The
`connect` fixture fans out over `CONNECTABLE_TRANSPORTS × SPEC_VERSIONS`, but the grid is
filtered per test:
`pytest_generate_tests` reads the test's stacked `@requirement` marks and calls `compute_cells()`,
which intersects the admissible cells across every cited requirement — a cell survives only if
**all** of the test's requirements admit it.
`streamable-http-stateless` is the fourth connectable transport: the 2025-era unofficial stateless
mode where each request opens a fresh transport, no session id is issued, and there is no standalone
GET stream. Requirements that need a server→client back-channel or persisted session state are
excluded from that arm via `arm_exclusions` (reasons `server-initiated-request` and
`requires-session`).
What admits or excludes a cell:
- **`added_in` / `removed_in`** gate which spec versions a requirement exists in, as a half-open
`[added_in, removed_in)` window. A test runs only on versions inside every cited requirement's
window.
- **`arm_exclusions`** carve specific `(transport, spec_version)` cells out with a typed
`ArmExclusionReason`. The reason vocabulary doubles as a re-admission checklist: when the gap
closes, grep for the reason string to find every cell to re-admit.
- **`known_failures`** keep a cell in the grid but mark it as a strict xfail — the test runs and
must fail; an unexpected pass fails the suite.
- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently only
`sse` is locked to `2025-11-25`). A `(transport, version)` cell is dropped if the version is not
in the transport's entry; transports absent from the map serve every spec version. This is the
mechanism for cutting an entire transport off from a new revision (or admitting it).
- **`transports`** is descriptive metadata for the non-`connect` transport-specific suites under
`transports/` and does **not** drive cell generation. Only `arm_exclusions`, `added_in`,
`removed_in`, and `TRANSPORT_SPEC_VERSIONS` filter the grid.
- **`supersedes` / `superseded_by`** link a retired entry to its replacement. `test_coverage.py`
enforces that links are bidirectional and versioned: the retired entry carries `removed_in`, the
replacement carries `added_in`.
Node IDs stay `[transport]` while `len(SPEC_VERSIONS) == 1`, so today's test IDs are
byte-identical to before the era axis existed. They become `[transport-version]` the moment a
second version is appended to `SPEC_VERSIONS`.
When a new spec revision lands:
1. Append the version string to `SPEC_VERSIONS` (and to the `SpecVersion` `Literal`).
2. Walk the new revision's changelog.
3. For each affected requirement: set `removed_in` on retired behaviour, add a new entry with
`added_in` for its replacement, and link the pair with `supersedes` / `superseded_by`.
Behaviour that survives unchanged needs nothing beyond a re-audit of its `source` URL.
4. For requirements that cannot run on the new era's path, add an `arm_exclusions` entry with the
appropriate `ArmExclusionReason`.
5. Review `TRANSPORT_SPEC_VERSIONS`: any era-locked transport will not produce cells on the new
version unless its entry is extended (or removed); add an entry for any transport the new
revision retires.
## 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.
- A test that needs a peer no real `Server` or `Client` can play (a server that answers initialize
with an unsupported version, a client that sends malformed params) plays that side of the wire by
hand over `create_client_server_memory_streams()`. This scripted-peer pattern is the suite's only
way to drive behaviour the typed API cannot produce, and the docstring of every such test says so.
Stack a second `@requirement` decorator only when a test's natural assertions incidentally prove
another behaviour — one capabilities snapshot proving four `*:capability:declared` entries, one
input-schema identity check proving each preserved keyword. Do not build a test around covering
many requirements at once; if the assertions would be separate, write separate tests.
### 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 dispatcher starts a task per incoming notification in arrival order but does not
await it before reading the next message, so completion order is not structural. What still
holds: the in-memory transport delivers everything on one ordered stream, and a callback that
records synchronously (no `await` before the append) finishes its scheduling slice before the
awaited request's waiter — woken strictly later — resumes. So tests whose callbacks are plain
appends may still collect into a list and assert after the call. A callback that awaits before
recording loses that ordering and must synchronise. The other 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 `# type: ignore` or
`# noqa` comments; restructure instead. Two pragmas are sanctioned in this suite's test code, both
for known-upstream tracer bugs and only after restructuring has been tried: `# pragma: no branch`
on a `with`/`async with` line whose only fault is coverage.py mis-tracing the exit arc of a nested
async context (reserve it for shapes that cannot collapse — a sync `with` adjacent to an
`async with`); and `# pragma: lax no cover` on a single statement that 3.11's tracer drops because
the preceding `async with` unwinds via `coro.throw()` (python/cpython#106749, wontfix on 3.11) —
this hits any test that must run statements after a `ClientSession`/`streamable_http_client` exits
but still inside an outer `async with`, and no restructure can avoid it.
A handful of `# pragma: lax no cover` markers in `src/` cover teardown exception handlers whose
execution is timing-dependent under the in-process HTTP bridge — the POST-stream and
stateless-session `except Exception` handlers in `server/streamable_http*.py` and the
`_terminated` check in `message_router`. `strict-no-cover` does not check `lax` lines; do not
promote them to strict `no cover` without first making the teardown ordering deterministic. The
suite also relies on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that
closes a stream the SSE leg would otherwise leak.
View File
+402
View File
@@ -0,0 +1,402 @@
"""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.
"""
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from functools import partial
from typing import Any, Protocol
import httpx
from httpx_sse import ServerSentEvent, aconnect_sse
from mcp_types import (
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
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.extension import ClientExtension
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.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
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"
# DNS-rebinding protection validates Host/Origin headers against a real network attack that cannot
# exist for an in-process ASGI app, so the in-process factories disable it; tests that exercise the
# protection itself pass explicit settings (or transport_security=None to get the localhost
# auto-enable behaviour).
NO_DNS_REBINDING_PROTECTION = TransportSecuritySettings(enable_dns_rebinding_protection=False)
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,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> 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,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server over the in-memory transport.
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which drives the server through the DirectDispatcher peer-pair
(per-request `serve_one`, no initialize handshake) instead of the legacy stream pair.
"""
async with Client(
server,
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
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,
extensions=extensions,
) as client:
yield client
@asynccontextmanager
async def connect_over_streamable_http(
server: Server | MCPServer,
*,
stateless_http: bool = False,
json_response: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
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,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> 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 stateless
matrix arm binds `stateless_http=True` (see `connect_over_streamable_http_stateless`);
transport-specific tests pass `json_response` to select the other server mode, and the
resumability tests pass an `event_store` (with `retry_interval=0` so the client's
reconnection wait is a no-op).
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which adopts a synthesized DiscoverResult instead of running the legacy
initialize handshake.
"""
app = server.streamable_http_app(
stateless_http=stateless_http,
json_response=json_response,
event_store=event_store,
retry_interval=retry_interval,
transport_security=NO_DNS_REBINDING_PROTECTION,
)
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
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,
extensions=extensions,
) as client,
):
yield client
connect_over_streamable_http_stateless: Connect = partial(connect_over_streamable_http, stateless_http=True)
"""The streamable-http matrix arm with the server in stateless mode (fresh transport per request,
no session id, no standalone GET stream). The same shared Server instance backs every request --
stateless mode does not require a server factory."""
@asynccontextmanager
async def mounted_app(
server: Server | MCPServer,
*,
stateless_http: bool = False,
json_response: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
headers: dict[str, str] | None = None,
auth: AuthSettings | None = None,
token_verifier: TokenVerifier | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
) -> AsyncIterator[tuple[httpx.AsyncClient, StreamableHTTPSessionManager]]:
"""Mount the server's streamable HTTP app on the in-process bridge and yield an httpx client.
Yields the httpx client (rooted at the in-process origin) and the live session manager. Tests
use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
session manager. `on_request` observes every outgoing HTTP request before it leaves the
yielded client; `on_response` observes every HTTP response as its headers arrive (response
bodies of SSE streams are not yet read at that point).
DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
localhost auto-enable behaviour) to test the protection itself.
"""
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
app = lowlevel.streamable_http_app(
stateless_http=stateless_http,
json_response=json_response,
event_store=event_store,
retry_interval=retry_interval,
transport_security=transport_security,
auth=auth,
token_verifier=token_verifier,
auth_server_provider=auth_server_provider,
)
event_hooks: dict[str, list[Callable[..., Awaitable[None]]]] = {}
if on_request is not None:
event_hooks["request"] = [on_request]
if on_response is not None:
event_hooks["response"] = [on_response]
async with (
server.session_manager.run(),
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers
) as http_client,
):
yield http_client, server.session_manager
@asynccontextmanager
async def client_via_http(
http_client: httpx.AsyncClient,
*,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]:
"""Connect a `Client` over an already-mounted streamable HTTP app.
Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a
client-driven assertion can sit alongside raw-httpx assertions in the same test. The
underlying `httpx.AsyncClient` is left open when the `Client` exits.
"""
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
async with Client(
transport,
# Callers assert the legacy HTTP wire shape (session-id header, standalone GET stream,
# closing DELETE); the modern flow is sessionless and would silently change the subject.
mode="legacy",
logging_callback=logging_callback,
message_handler=message_handler,
elicitation_callback=elicitation_callback,
) as client:
yield client
def parse_sse_messages(events: Iterable[ServerSentEvent]) -> list[JSONRPCMessage]:
"""Decode SSE events into JSON-RPC messages, skipping priming events that carry no data."""
return [jsonrpc_message_adapter.validate_json(event.data) for event in events if event.data]
async def post_jsonrpc(
http: httpx.AsyncClient, body: dict[str, object], *, session_id: str | None = None
) -> tuple[httpx.Response, list[JSONRPCMessage]]:
"""POST a JSON-RPC body and read its SSE response stream to completion.
Returns the HTTP response (for header/status assertions) and the parsed JSON-RPC messages
that arrived on the response's SSE stream. Only meaningful for requests the server answers
with `text/event-stream`; for error responses or 202 notification acknowledgements, use
`httpx.AsyncClient.post` directly and assert on the response.
"""
async with aconnect_sse(http, "POST", "/mcp", json=body, headers=base_headers(session_id=session_id)) as source:
events = [event async for event in source.aiter_sse()]
return source.response, parse_sse_messages(events)
def base_headers(*, session_id: str | None = None) -> dict[str, str]:
"""Standard request headers for raw-httpx streamable-HTTP tests.
Every well-formed request carries these (Accept covering both response representations,
Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session
ID once one exists), so a test that wants to assert a specific rejection only varies the one
header under test.
"""
headers = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
"mcp-protocol-version": LATEST_HANDSHAKE_VERSION,
}
if session_id is not None:
headers["mcp-session-id"] = session_id
return headers
def initialize_body(request_id: int = 1) -> dict[str, object]:
"""A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
params = InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="raw", version="0.0.0"),
)
return JSONRPCRequest(
jsonrpc="2.0", id=request_id, method="initialize", params=params.model_dump(by_alias=True, exclude_none=True)
).model_dump(by_alias=True, exclude_none=True)
async def initialize_via_http(http: httpx.AsyncClient) -> str:
"""Perform the initialize handshake over a raw `httpx.AsyncClient` and return the session ID.
Validates the SSE response and sends the `notifications/initialized` follow-up, so the server
is fully ready for subsequent feature requests when this returns.
"""
async with aconnect_sse(http, "POST", "/mcp", json=initialize_body(), headers=base_headers()) as source:
assert source.response.status_code == 200
# An event-store-backed server opens the stream with a priming event (empty data); skip it.
events = [event async for event in source.aiter_sse() if event.data]
assert len(events) == 1
assert JSONRPCResponse.model_validate_json(events[0].data).id == 1
session_id = source.response.headers["mcp-session-id"]
initialized = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=base_headers(session_id=session_id),
)
assert initialized.status_code == 202
return session_id
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,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> 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)
async with Client(
transport,
# SSE is a legacy-only transport; the modern path has no SSE story.
mode="legacy",
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,
extensions=extensions,
) as client:
yield client
+108
View File
@@ -0,0 +1,108 @@
"""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 mcp_types import ClientResult, ServerNotification, ServerRequest
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
# 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:
# Record only after the inner send returns: a failed or cancelled send never reached the transport.
await self._inner.send(item)
self._log.append(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)
+78
View File
@@ -0,0 +1,78 @@
"""Guard against 2026-era protocol vocabulary leaking onto legacy (2025-era) exchanges.
The 2026-07-28 spec revision introduces wire vocabulary that did not exist before it --
result-envelope fields (`resultType`, `ttlMs`, `cacheScope`), namespaced
`io.modelcontextprotocol/*` `_meta` keys, the version literal itself, and the per-request HTTP
headers `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*`. None of that may appear on a connection
negotiated at an earlier protocol version: a test that records a plain legacy round trip and
runs it through :func:`assert_no_modern_vocabulary` will start failing the moment a 2026 change
leaks onto the existing wire.
Tests construct a :class:`RecordedExchange` from whatever instrumentation they have to hand --
the `on_request` / `on_response` hooks on :func:`tests.interaction._connect.mounted_app` for the
HTTP seam, and :class:`tests.interaction._helpers.RecordingTransport` for the JSON-RPC frames --
and pass it to the assertion. The helper scans header names and serialised bodies; it makes no
assumptions about which side produced what.
"""
from dataclasses import dataclass
import httpx
from mcp_types import JSONRPCMessage, jsonrpc_message_adapter
#: Substrings that must not appear anywhere in a request body or JSON-RPC frame on a legacy
#: exchange. Matching is by raw substring against the by-alias JSON serialisation, so a leaked
#: field name, `_meta` key prefix, or version literal is caught regardless of where in the
#: payload it sits.
MODERN_BODY_TOKENS: frozenset[str] = frozenset(
{
"resultType",
"ttlMs",
"cacheScope",
"io.modelcontextprotocol/",
"2026-07-28",
}
)
#: Lower-cased HTTP header names introduced by the 2026-07-28 transport.
MODERN_HEADER_NAMES: frozenset[str] = frozenset({"mcp-method", "mcp-name"})
#: Lower-cased prefix for the 2026-07-28 per-parameter header family.
MODERN_HEADER_PREFIX = "mcp-param-"
@dataclass
class RecordedExchange:
"""Everything a test captured from one streamable-HTTP conversation, for vocabulary scanning.
`requests` and `responses` are inspected for header names and (for requests) body bytes;
`frames` are re-serialised to their wire JSON and scanned as body text. Response bodies are
not read here -- streamable-HTTP responses are SSE streams that are consumed elsewhere -- so
the server-to-client body content must be supplied via `frames`.
"""
requests: list[httpx.Request]
responses: list[httpx.Response]
frames: list[JSONRPCMessage]
def assert_no_modern_vocabulary(recorded: RecordedExchange) -> None:
"""Fail if any 2026-era header name or body token appears anywhere in `recorded`.
All findings are collected before asserting so a single failure reports every leak.
"""
header_names = [name.lower() for request in recorded.requests for name in request.headers]
header_names += [name.lower() for response in recorded.responses for name in response.headers]
leaked = [
f"header {name!r}"
for name in header_names
if name in MODERN_HEADER_NAMES or name.startswith(MODERN_HEADER_PREFIX)
]
corpus = b"".join(request.content for request in recorded.requests).decode()
corpus += "".join(
jsonrpc_message_adapter.dump_json(frame, by_alias=True, exclude_none=True).decode() for frame in recorded.frames
)
leaked.extend(f"body token {token!r}" for token in MODERN_BODY_TOKENS if token in corpus)
assert not leaked, f"Modern (2026-07-28) protocol vocabulary on a legacy exchange: {leaked}"
File diff suppressed because it is too large Load Diff
View File
+484
View File
@@ -0,0 +1,484 @@
"""In-process harness for the auth interaction tests.
Co-hosts the SDK's authorization-server routes, protected-resource metadata route, and the
bearer-gated MCP endpoint on one Starlette app via `Server.streamable_http_app(auth=...,
token_verifier=..., auth_server_provider=...)`, drives that app through the streaming bridge
on a single `httpx.AsyncClient` carrying `auth=OAuthClientProvider(...)`, and completes the
authorize redirect headlessly by GETing the URL through the same bridge and parsing the code
from the 302 `Location`. The whole authorization-code flow runs in one event loop with no
sockets, no threads, and no real time.
"""
import json
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import parse_qs, parse_qsl, urlsplit
import httpx
from pydantic import AnyHttpUrl, AnyUrl, BaseModel
from starlette.types import ASGIApp, Receive, Scope, Send
from mcp.client.auth import OAuthClientProvider
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.auth.provider import AccessToken, ProviderTokenVerifier
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
from tests.interaction.transports._bridge import StreamingASGITransport
REDIRECT_URI = f"{BASE_URL}/oauth/callback"
AppShim = Callable[[ASGIApp], ASGIApp]
@dataclass
class RecordedRequest:
"""A snapshot of an `httpx.Request` at the moment it was sent.
The auth flow re-yields the same `httpx.Request` object after mutating its headers in
place for the retry, so tests that need to assert on the first attempt's headers must
capture a copy rather than a live reference. `record_requests` produces these.
"""
method: str
url: httpx.URL
headers: dict[str, str]
content: bytes
@property
def path(self) -> str:
return self.url.path
def record_requests() -> tuple[list[RecordedRequest], Callable[[httpx.Request], None]]:
"""Build an `on_request` callback that snapshots each request, and the list it appends to."""
recorded: list[RecordedRequest] = []
def on_request(request: httpx.Request) -> None:
recorded.append(
RecordedRequest(
method=request.method,
url=request.url,
headers=dict(request.headers),
content=bytes(request.content),
)
)
return recorded, on_request
def metadata_body(model: BaseModel, **extra: object) -> bytes:
"""Serialize a metadata model to a JSON body for `shimmed_app(serve=...)`.
`extra` keys are merged into the serialized object so a test can inject fields the model
does not declare (e.g. an unknown extension field, to prove the client's parser tolerates
unrecognized members per RFC 8414/9728 §3.2). The model itself would silently drop such
fields at construction, so they have to be added after serialization.
"""
document = model.model_dump(by_alias=True, mode="json", exclude_none=True)
document.update(extra)
return json.dumps(document).encode()
class StaticTokenVerifier:
"""A `TokenVerifier` backed by a fixed token→`AccessToken` mapping.
Any token string not in the mapping verifies to `None`, which the bearer middleware treats
as an unrecognized token. Tests seed the mapping with the exact token shapes (valid, expired,
wrong scope, wrong audience) they need so the resource-server gate's behaviour is asserted in
isolation from the authorization-server provider.
"""
def __init__(self, tokens: Mapping[str, AccessToken]) -> None:
self._tokens = dict(tokens)
async def verify_token(self, token: str) -> AccessToken | None:
return self._tokens.get(token)
class InMemoryTokenStorage:
"""A `TokenStorage` that holds tokens and client info as instance attributes.
Tests pre-seed `client_info` (via the constructor or by assignment) to drive the
pre-registered path, and read both attributes after the flow to assert what the SDK
persisted.
"""
def __init__(self, *, client_info: OAuthClientInformationFull | None = None) -> None:
self.tokens: OAuthToken | None = None
self.client_info: OAuthClientInformationFull | None = client_info
async def get_tokens(self) -> OAuthToken | None:
return self.tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self.tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self.client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self.client_info = client_info
class HeadlessOAuth:
"""Completes the authorize step in-process by following the redirect through the bridge.
`redirect_handler` GETs the authorize URL on the bound client (with `auth=None` so the
request does not re-enter the locked auth flow), parses `code` and `state` from the 302
`Location`, and stashes them; `callback_handler` returns the stashed pair. Tests inspect
`authorize_url` to assert what the SDK put on the authorize request.
`state_override`: when set, `callback_handler` returns this value as the state instead of
the one parsed from the redirect, so tests can drive the state-mismatch path.
`iss_override`: when set, `callback_handler` returns this value as the RFC 9207 issuer
instead of the one parsed from the redirect, so tests can drive the iss-mismatch path.
"""
def __init__(self, *, state_override: str | None = None, iss_override: str | None = None) -> None:
self.authorize_url: str | None = None
self.authorize_urls: list[str] = []
self.error: str | None = None
self._state_override = state_override
self._iss_override = iss_override
self._http: httpx.AsyncClient | None = None
self._code: str = ""
self._state: str | None = None
self._iss: str | None = None
def bind(self, http_client: httpx.AsyncClient) -> None:
self._http = http_client
async def redirect_handler(self, authorization_url: str) -> None:
assert self._http is not None
self.authorize_url = authorization_url
self.authorize_urls.append(authorization_url)
# auth=None is load-bearing: without it the GET re-enters OAuthClientProvider.async_auth_flow
# through its context lock and the flow deadlocks.
response = await self._http.get(authorization_url, follow_redirects=False, auth=None)
assert response.status_code == 302, f"authorize endpoint returned {response.status_code}: {response.text}"
params = parse_qs(urlsplit(response.headers["location"]).query)
self._code = params.get("code", [""])[0]
self._state = params.get("state", [None])[0]
self._iss = params.get("iss", [None])[0]
self.error = params.get("error", [None])[0]
async def callback_handler(self) -> AuthorizationCodeResult:
return AuthorizationCodeResult(
code=self._code,
state=self._state_override if self._state_override is not None else self._state,
iss=self._iss_override if self._iss_override is not None else self._iss,
)
def auth_settings(
*,
required_scopes: Sequence[str] = ("mcp",),
valid_scopes: Sequence[str] | None = None,
identity_assertion_enabled: bool = False,
) -> AuthSettings:
"""Build `AuthSettings` for the co-hosted authorization + resource server.
The issuer and resource URLs use the suite's loopback origin, which `validate_issuer_url`
accepts in lieu of HTTPS. Dynamic client registration is enabled. `valid_scopes` defaults
to `required_scopes` so a client requesting exactly those passes registration scope
validation; tests pass a wider set when they need the protected-resource metadata's
`scopes_supported` (which mirrors `required_scopes`) to differ from what the client may
register or when AS metadata should advertise additional scopes such as `offline_access`.
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
issue tokens.
"""
required = list(required_scopes)
valid = list(valid_scopes) if valid_scopes is not None else required
return AuthSettings(
issuer_url=AnyHttpUrl(BASE_URL),
resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"),
required_scopes=required,
client_registration_options=ClientRegistrationOptions(
enabled=True, valid_scopes=valid, default_scopes=required
),
revocation_options=RevocationOptions(enabled=False),
identity_assertion_enabled=identity_assertion_enabled,
)
def oauth_client_metadata() -> OAuthClientMetadata:
"""Build the client's registration metadata.
`scope` is left unset so the SDK's scope-selection strategy chooses one from the server's
metadata before registration.
"""
return OAuthClientMetadata(
client_name="interaction-suite",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
)
def shimmed_app(
app: ASGIApp,
*,
not_found: frozenset[str] = frozenset(),
serve: Mapping[str, bytes | tuple[int, bytes]] | None = None,
) -> ASGIApp:
"""Wrap an ASGI app so specific paths return canned responses before reaching the real app.
Paths in `serve` return the given body as `application/json` (status 200, or the supplied
status when the value is a `(status, body)` pair); paths in `not_found` return 404;
everything else reaches the wrapped app unchanged. Used by the discovery tests to make a
well-known endpoint 404 or return alternate metadata while keeping the real authorization
and MCP endpoints behind it.
"""
overrides: dict[str, tuple[int, bytes]] = {
path: value if isinstance(value, tuple) else (200, value) for path, value in (serve or {}).items()
}
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
path = scope["path"]
if path in overrides:
status, body = overrides[path]
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": body})
return
if path in not_found:
await send({"type": "http.response.start", "status": 404, "headers": []})
await send({"type": "http.response.body", "body": b""})
return
await app(scope, receive, send)
return wrapped
def shim(
*, not_found: frozenset[str] = frozenset(), serve: Mapping[str, bytes | tuple[int, bytes]] | None = None
) -> AppShim:
"""Build an `app_shim` for `connect_with_oauth` that applies `shimmed_app` with these overrides."""
return lambda app: shimmed_app(app, not_found=not_found, serve=serve)
@dataclass
class _FirstChallenge:
"""ASGI shim that answers the first request to a path with 401 + a given WWW-Authenticate.
Subsequent requests pass through to the wrapped app. Used to make the initial 401 carry
parameters (such as `scope=`) that the SDK's own bearer middleware cannot be configured
to emit, so client behaviour driven by those parameters is reachable end to end. Reserve
this pattern for behaviour the real server cannot be made to produce.
"""
app: ASGIApp
path: str
www_authenticate: str
_seen: set[str] = field(default_factory=set[str])
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["path"] == self.path and self.path not in self._seen:
self._seen.add(self.path)
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [(b"www-authenticate", self.www_authenticate.encode())],
}
)
await send({"type": "http.response.body", "body": b""})
return
await self.app(scope, receive, send)
def first_challenge_shim(www_authenticate: str, *, path: str = "/mcp") -> Callable[[ASGIApp], ASGIApp]:
"""Build an `app_shim` that 401s the first request to `path` with the given header value."""
return lambda app: _FirstChallenge(app, path, www_authenticate)
def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -> AppShim:
"""Build an `app_shim` that 403s the Nth authenticated POST to `/mcp` with the given challenge.
Subsequent requests pass through. Used to drive the client's `insufficient_scope` step-up
handling: the SDK's bearer middleware never emits `scope=` in its 403 challenge (see the
divergence on `hosting:auth:scope-403`), so the test supplies the 403 itself. Reserve this
pattern for behaviour the real server cannot be made to produce.
The default `on_nth_authenticated_post=2` targets the `notifications/initialized` POST: the
first authenticated POST is the auth flow's retry of the original initialize request (yielded
after the 401 branch, where the generator ends without inspecting the response), so a 403
there would not reach the step-up handler.
"""
seen = 0
fired = False
def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
nonlocal seen, fired
if (
not fired
and scope["type"] == "http"
and scope["path"] == "/mcp"
and scope["method"] == "POST"
and any(name == b"authorization" for name, _ in scope["headers"])
):
seen += 1
if seen < on_nth_authenticated_post:
await app(scope, receive, send)
return
fired = True
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [(b"www-authenticate", www_authenticate.encode())],
}
)
await send({"type": "http.response.body", "body": b""})
return
await app(scope, receive, send)
return wrapped
return factory
def m2m_token_shim(provider: InMemoryAuthorizationServerProvider, *, scopes: list[str]) -> AppShim:
"""Build an `app_shim` that handles `grant_type=client_credentials` at `/token`.
The SDK server's `TokenHandler` only routes `authorization_code` and `refresh_token`, so a
`client_credentials` request would fail discriminator validation. This shim mints a token via
`provider.mint_access_token` so the M2M client providers can complete e2e against the real
bearer middleware. The shim is harness; the SDK-under-test is the client provider, whose
outbound `/token` body the test asserts. The shim does not authenticate the client (no
credential check) because the test asserts the credentials on the recorded request, not on
the server's acceptance.
"""
def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["path"] == "/token" and scope["method"] == "POST":
# The streaming bridge buffers the request body and delivers it in a single
# http.request event, so one receive is sufficient.
message = await receive()
assert not message.get("more_body", False)
form = dict(parse_qsl(message.get("body", b"").decode()))
assert form.get("grant_type") == "client_credentials", (
f"m2m_token_shim only handles client_credentials; got {form.get('grant_type')!r}"
)
access = provider.mint_access_token(client_id="m2m", scopes=scopes, resource=form.get("resource"))
token = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
response_body = token.model_dump_json(exclude_none=True).encode()
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(response_body)).encode()),
(b"cache-control", b"no-store"),
],
}
)
await send({"type": "http.response.body", "body": response_body})
return
await app(scope, receive, send)
return wrapped
return factory
@asynccontextmanager
async def connect_with_oauth(
server: Server,
*,
provider: InMemoryAuthorizationServerProvider,
settings: AuthSettings | None = None,
storage: InMemoryTokenStorage | None = None,
client_metadata: OAuthClientMetadata | None = None,
client_metadata_url: str | None = None,
headless: HeadlessOAuth | None = None,
auth: httpx.Auth | None = None,
verify_tokens: bool = True,
app_shim: Callable[[ASGIApp], ASGIApp] | None = None,
on_request: Callable[[httpx.Request], None] | None = None,
) -> AsyncIterator[tuple[Client, HeadlessOAuth]]:
"""Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process.
Yields the connected `Client` and the `HeadlessOAuth` whose `authorize_url` records what the
SDK put on the authorize request. `on_request` records every HTTP request the underlying
`httpx.AsyncClient` issues, including those yielded from inside the auth flow.
`headless`: supply a pre-configured `HeadlessOAuth` to override the callback behaviour
(state mismatch, error redirects). `verify_tokens=False` mounts the MCP endpoint without
the bearer middleware so a flow driven by a shimmed 401 completes regardless of the granted
scopes. `app_shim` wraps the built Starlette app before it reaches the bridge transport,
for tests that need to intercept or rewrite specific server responses.
`auth`: supply a pre-built `httpx.Auth` (such as `ClientCredentialsOAuthProvider`) to use
instead of constructing the default `OAuthClientProvider`; in that case `storage`,
`client_metadata`, `client_metadata_url`, and `headless` are unused (the yielded
`HeadlessOAuth` is never invoked and its `authorize_url` stays None).
"""
settings = settings if settings is not None else auth_settings()
storage = storage if storage is not None else InMemoryTokenStorage()
client_metadata = client_metadata if client_metadata is not None else oauth_client_metadata()
headless = headless if headless is not None else HeadlessOAuth()
oauth = (
auth
if auth is not None
else OAuthClientProvider(
server_url=f"{BASE_URL}/mcp",
client_metadata=client_metadata,
storage=storage,
redirect_handler=headless.redirect_handler,
callback_handler=headless.callback_handler,
client_metadata_url=client_metadata_url,
)
)
app: ASGIApp = server.streamable_http_app(
auth=settings,
token_verifier=ProviderTokenVerifier(provider) if verify_tokens else None,
auth_server_provider=provider,
transport_security=NO_DNS_REBINDING_PROTECTION,
)
if app_shim is not None:
app = app_shim(app)
event_hooks: dict[str, list[Callable[..., Any]]] | None = None
if on_request is not None:
record = on_request
async def hook(request: httpx.Request) -> None:
record(request)
event_hooks = {"request": [hook]}
async with AsyncExitStack() as stack:
await stack.enter_async_context(server.session_manager.run())
http_client = await stack.enter_async_context(
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, auth=oauth, event_hooks=event_hooks
)
)
headless.bind(http_client)
client = await stack.enter_async_context(
# The auth flow tests snapshot the legacy initialize-handshake HTTP shape.
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), mode="legacy")
)
yield client, headless
+222
View File
@@ -0,0 +1,222 @@
"""An in-memory implementation of the SDK's OAuth authorization-server provider protocol.
The provider holds clients, authorization codes, refresh tokens and access tokens in plain
instance dicts so tests can inspect them; tokens are minted from `secrets.token_hex` so the
values are unique without being predictable. The behaviour mirrors what the SDK's authorization
handlers expect: `authorize` immediately mints a code and returns the redirect, `exchange_*`
issue and rotate tokens, and `load_*` are simple lookups. Only the parts the auth interaction
suite drives are implemented; methods the suite does not exercise raise `NotImplementedError`.
"""
import secrets
import time
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
TokenError,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from tests.interaction._connect import BASE_URL
_TOKEN_LIFETIME_SECONDS = 3600
# The only ID-JAG assertion the in-memory provider accepts; any other value is rejected with
# invalid_grant, standing in for the signature/policy validation a real AS performs.
VALID_ASSERTION = "valid-id-jag"
class InMemoryAuthorizationServerProvider(
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
):
"""An OAuth authorization-server provider backed by in-memory dicts.
Holds registered clients, issued codes, refresh tokens and access tokens as instance state
so tests can both drive the SDK's authorization handlers and inspect what was issued.
Knobs:
`default_scopes`: scopes granted when an authorize request supplies none.
`deny_authorize`: every authorize request returns an `error=access_denied` redirect.
`issue_expired_first`: the first issued token's `expires_in` is in the past so the
client immediately considers it expired and refreshes; the server-side
`AccessToken.expires_at` stays in the future so the bearer middleware accepts it
on the retry that completes the connect.
`fail_next_refresh`: the next refresh-token exchange raises `invalid_grant` once.
`reject_all_tokens`: `load_access_token` returns None for every token, so the bearer
middleware 401s every authenticated request.
"""
def __init__(
self,
*,
default_scopes: list[str] | None = None,
deny_authorize: bool = False,
issue_expired_first: bool = False,
fail_next_refresh: bool = False,
reject_all_tokens: bool = False,
issuer: str | None = None,
) -> None:
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
# The authorization-response iss must equal the AS metadata issuer the client recorded
# (RFC 9207 simple string comparison). `real_asm` builds the issuer from an AnyHttpUrl
# object, so it carries the trailing slash; the redirect iss matches it. Path-issuer
# tests pass the recorded issuer explicitly.
self._issuer = issuer if issuer is not None else f"{BASE_URL}/"
self._deny_authorize = deny_authorize
self._issue_expired_first = issue_expired_first
self._fail_next_refresh = fail_next_refresh
self._reject_all_tokens = reject_all_tokens
self._tokens_issued = 0
self.clients: dict[str, OAuthClientInformationFull] = {}
self.codes: dict[str, AuthorizationCode] = {}
self.refresh_tokens: dict[str, RefreshToken] = {}
self.access_tokens: dict[str, AccessToken] = {}
# The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion,
# for tests to assert what the client sent (None until the first exchange).
self.last_assertion_params: IdentityAssertionParams | None = None
def _next_expires_in(self) -> int:
self._tokens_issued += 1
if self._issue_expired_first and self._tokens_issued == 1:
return -_TOKEN_LIFETIME_SECONDS
return _TOKEN_LIFETIME_SECONDS
def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str | None = None) -> str:
"""Mint and store an access token, returning its value.
Used by the auth-code and refresh exchanges and by the M2M `/token` shim. The
server-side `expires_at` is always in the future regardless of `issue_expired_first`,
which only affects what the client is told.
"""
access = f"access_{secrets.token_hex(16)}"
self.access_tokens[access] = AccessToken(
token=access,
client_id=client_id,
scopes=scopes,
expires_at=int(time.time()) + _TOKEN_LIFETIME_SECONDS,
resource=resource,
)
return access
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
assert client_info.client_id is not None
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
"""Mint an authorization code immediately and return the redirect carrying it.
A real provider would interpose user consent here; the test provider grants
unconditionally so the headless redirect handler can complete the flow in-process.
When `deny_authorize` is set, returns an `error=access_denied` redirect instead.
"""
assert client.client_id is not None
if self._deny_authorize:
return construct_redirect_uri(
str(params.redirect_uri), error="access_denied", error_description="user denied", state=params.state
)
code = AuthorizationCode(
code=f"code_{secrets.token_hex(16)}",
client_id=client.client_id,
scopes=params.scopes or self._default_scopes,
expires_at=time.time() + 300,
code_challenge=params.code_challenge,
redirect_uri=params.redirect_uri,
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
resource=params.resource,
)
self.codes[code.code] = code
# `iss` is RFC 9207's authorization-response issuer identifier — an extra parameter many
# real authorization servers send. Including it on every success redirect proves the
# client tolerates unrecognized callback parameters (RFC 6749 §4.1.2 MUST) by virtue of
# every flow test passing unchanged.
return construct_redirect_uri(str(params.redirect_uri), code=code.code, state=params.state, iss=self._issuer)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
return self.codes.get(authorization_code)
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
"""Mint an access token and a refresh token for a valid authorization code, then consume the code."""
assert client.client_id is not None
access = self.mint_access_token(
client_id=client.client_id, scopes=authorization_code.scopes, resource=authorization_code.resource
)
refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[refresh] = RefreshToken(
token=refresh,
client_id=client.client_id,
scopes=authorization_code.scopes,
)
del self.codes[authorization_code.code]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(authorization_code.scopes),
refresh_token=refresh,
)
async def load_access_token(self, token: str) -> AccessToken | None:
if self._reject_all_tokens:
return None
return self.access_tokens.get(token)
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
return self.refresh_tokens.get(refresh_token)
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
"""Mint a new access token and rotate the refresh token, consuming the old one."""
assert client.client_id is not None
if self._fail_next_refresh:
self._fail_next_refresh = False
raise TokenError(error="invalid_grant", error_description="refresh denied by harness")
access = self.mint_access_token(client_id=client.client_id, scopes=scopes)
new_refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes)
del self.refresh_tokens[refresh_token.token]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
refresh_token=new_refresh,
)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
"""Validate the ID-JAG assertion and mint an MCP access token (RFC 7523 jwt-bearer / SEP-990).
Records `params` for inspection and rejects any assertion other than `VALID_ASSERTION` with
invalid_grant (standing in for signature/policy validation). The granted scopes are exactly
those the client requested; a real provider would derive them from the validated ID-JAG.
"""
self.last_assertion_params = params
assert client.client_id is not None
if params.assertion != VALID_ASSERTION:
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
scopes = params.scopes if params.scopes is not None else self._default_scopes
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource)
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
)
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
raise NotImplementedError
+300
View File
@@ -0,0 +1,300 @@
"""Error-plane behaviour of the SDK's bundled OAuth authorization-server handlers.
The end-to-end OAuth tests prove the handlers' happy paths; these tests drive the same
mounted authorization server directly with raw httpx so the assertions are the HTTP
semantics (status, redirect target, error body, headers) the OAuth RFCs mandate. Almost
every behaviour here is enforced by the SDK's own handlers; where the pinned output
deviates from the RFC, the manifest entry carries the divergence.
"""
import base64
import hashlib
import secrets
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from inline_snapshot import snapshot
from mcp.server import Server
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.shared.auth import OAuthClientInformationFull
from tests.interaction._connect import mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import REDIRECT_URI, auth_settings, oauth_client_metadata
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
@pytest.fixture
async def as_app() -> AsyncIterator[tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider]]:
"""Co-host the SDK's authorization-server routes and yield a raw httpx client against them."""
provider = InMemoryAuthorizationServerProvider()
settings = auth_settings()
async with mounted_app(
Server("guarded"),
auth=settings,
token_verifier=ProviderTokenVerifier(provider),
auth_server_provider=provider,
) as (http, _):
yield http, provider
def _pkce_pair() -> tuple[str, str]:
"""Generate a (code_verifier, code_challenge) pair the same way the SDK client does."""
verifier = secrets.token_urlsafe(48)[:64]
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
return verifier, challenge
async def _register_client(http: httpx.AsyncClient) -> OAuthClientInformationFull:
"""Dynamically register a client and return its full credentials."""
response = await http.post("/register", content=oauth_client_metadata().model_dump_json())
assert response.status_code == 201
return OAuthClientInformationFull.model_validate_json(response.content)
async def _mint_code(http: httpx.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
"""Register a client, complete a valid authorize step, and return (client_info, code, verifier)."""
client_info = await _register_client(http)
assert client_info.client_id is not None
verifier, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": "s",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
code = parse_qs(redirect.query)["code"][0]
return client_info, code, verifier
def _token_form(client_info: OAuthClientInformationFull, **overrides: str) -> dict[str, str]:
"""Build the form body for an authorization-code token request, with the defaults a real client would send."""
assert client_info.client_id is not None
assert client_info.client_secret is not None
form = {
"grant_type": "authorization_code",
"client_id": client_info.client_id,
"client_secret": client_info.client_secret,
"redirect_uri": REDIRECT_URI,
}
form.update(overrides)
return form
@requirement("hosting:auth:as:authorize-requires-pkce")
async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_request(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request omitting `code_challenge` is redirected back with `error=invalid_request`.
PKCE is mandatory: the bundled authorize handler models `code_challenge` as a required field, so
a code without a stored challenge can never be issued. That makes the PKCE-downgrade attack (a
token request carrying a verifier for a code minted without a challenge) structurally impossible
through these handlers, so no separate downgrade-guard test is needed.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"state": "abc",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
params = parse_qs(redirect.query)
assert params["error"] == ["invalid_request"]
assert params["state"] == ["abc"]
assert "code_challenge" in params["error_description"][0]
@requirement("hosting:auth:as:verifier-mismatch")
async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `code_verifier` does not hash to the stored challenge is rejected."""
http, _ = as_app
client_info, code, _ = await _mint_code(http)
response = await http.post("/token", data=_token_form(client_info, code=code, code_verifier="0" * 64))
assert response.status_code == 400
assert response.json() == snapshot({"error": "invalid_grant", "error_description": "incorrect code_verifier"})
@requirement("hosting:auth:as:code-single-use")
async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorization code can be exchanged exactly once; a second exchange is `invalid_grant`.
The handler does not track used codes itself: it returns `invalid_grant` whenever the provider's
`load_authorization_code` returns None, and the in-memory provider deletes the code on first
exchange. The test proves the combination enforces single-use; a provider that did not consume
codes would not get this guarantee from the handler.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
first = await http.post("/token", data=form)
assert first.status_code == 200
assert first.json()["token_type"] == "Bearer"
second = await http.post("/token", data=form)
assert second.status_code == 400
assert second.json() == snapshot(
{"error": "invalid_grant", "error_description": "authorization code does not exist"}
)
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_endpoint(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected.
This is the security-critical half of redirect-URI binding: a code intercepted via redirect
substitution cannot be redeemed because the attacker cannot reproduce the original authorize
redirect URI at the token endpoint. RFC 6749 §5.2 specifies `invalid_grant` for this case;
the SDK returns `invalid_request` (see the divergence on the requirement). The rejection
itself is the security property and is correct.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
response = await http.post(
"/token",
data=_token_form(client_info, code=code, code_verifier=verifier, redirect_uri=f"{REDIRECT_URI}/different"),
)
assert response.status_code == 400
assert response.json() == snapshot(
{
"error": "invalid_request",
"error_description": "redirect_uri did not match the one used when creating auth code",
}
)
@requirement("hosting:auth:as:token-cache-headers")
async def test_token_responses_carry_cache_control_no_store(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Every token-endpoint response (success and error) carries `Cache-Control: no-store`."""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
success = await http.post("/token", data=form)
assert success.status_code == 200
assert success.headers["cache-control"] == "no-store"
assert success.headers["pragma"] == "no-cache"
failure = await http.post("/token", data=form)
assert failure.status_code == 400
assert failure.headers["cache-control"] == "no-store"
assert failure.headers["pragma"] == "no-cache"
@requirement("hosting:auth:as:register-error-response")
async def test_registration_with_invalid_metadata_is_rejected_with_400(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Invalid client metadata at the registration endpoint returns 400 with an RFC 7591 error body."""
http, _ = as_app
malformed = await http.post("/register", json={"redirect_uris": ["not-a-url"]})
assert malformed.status_code == 400
assert malformed.json()["error"] == "invalid_client_metadata"
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
no_auth_code = await http.post("/register", json=body | {"grant_types": ["refresh_token"]})
assert no_auth_code.status_code == 400
assert no_auth_code.json() == snapshot(
{"error": "invalid_client_metadata", "error_description": "grant_types must include 'authorization_code'"}
)
bad_scope = await http.post("/register", json=body | {"scope": "forbidden"})
assert bad_scope.status_code == 400
body = bad_scope.json()
assert body["error"] == "invalid_client_metadata"
# The description embeds a set difference whose ordering is not stable, so assert the prefix.
assert body["error_description"].startswith("Requested scopes are not valid: ")
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request naming an unregistered `redirect_uri` returns 400 without redirecting to it.
The security property is that the authorization server never redirects to an unvalidated URI:
the response is a direct JSON error to the user agent, not a 302 to the attacker's host.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
_, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": "http://127.0.0.1:8000/evil",
"code_challenge": challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
assert response.status_code == 400
assert "location" not in response.headers
body = response.json()
assert body["error"] == "invalid_request"
assert "not registered" in body["error_description"]
@requirement("hosting:auth:as:redirect-uri-scheme")
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
The spec requires every redirect URI to be either HTTPS or a loopback host; the bundled
registration handler does not enforce this and registers `http://evil.example/callback`
successfully. See the divergence on the requirement.
"""
http, provider = as_app
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
body["redirect_uris"] = ["http://evil.example/callback"]
response = await http.post("/register", json=body)
assert response.status_code == 201
info = OAuthClientInformationFull.model_validate_json(response.content)
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
assert info.client_id in provider.clients
@@ -0,0 +1,417 @@
"""Authorization-request, token-request, and PKCE wire-level invariants of the SDK's OAuth client.
Every test connects a real `Client` end to end via `connect_with_oauth`; the assertions are on
the parsed authorize URL and the recorded `/token` form body, because those wire shapes are what
the spec mandates and `Client` cannot observe them. The recording uses `record_requests`, which
snapshots each request at send time so the auth flow's in-place header mutation on retry never
affects what was captured for the first attempt.
Tests #1/#2/#4/#5 share one `recorded_oauth_flow` fixture (one connect, several disjoint
assertions on its recording); the others connect fresh because each needs a different harness
configuration.
"""
import base64
import hashlib
import json
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
from urllib.parse import parse_qsl, quote, urlsplit
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import ListToolsResult, Tool
from pydantic import AnyHttpUrl, AnyUrl
from mcp.client.auth import OAuthFlowError
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from tests.interaction._connect import BASE_URL
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
REDIRECT_URI,
HeadlessOAuth,
InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
first_challenge_shim,
record_requests,
shimmed_app,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
PRM_PATH = "/.well-known/oauth-protected-resource/mcp"
ASM_PATH = "/.well-known/oauth-authorization-server"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
def authorize_params(authorize_url: str) -> dict[str, str]:
"""Parse the authorize URL's query string into a flat dict (one value per key)."""
return dict(parse_qsl(urlsplit(authorize_url).query))
def form_body(request: RecordedRequest) -> dict[str, str]:
"""Parse an `application/x-www-form-urlencoded` request body into a flat dict."""
return dict(parse_qsl(request.content.decode()))
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
"""Filter recorded requests by method and exact path."""
return [r for r in recorded if r.method == method and r.path == path]
@dataclass
class RecordedFlow:
"""One completed OAuth connect: every recorded request, plus the parsed authorize URL params."""
requests: list[RecordedRequest]
authorize_url: str
@property
def authorize(self) -> dict[str, str]:
return authorize_params(self.authorize_url)
@property
def token_request(self) -> RecordedRequest:
token_posts = find(self.requests, "POST", "/token")
assert len(token_posts) == 1
return token_posts[0]
@pytest.fixture
async def recorded_oauth_flow() -> AsyncIterator[RecordedFlow]:
"""Run one full OAuth connect with default configuration and yield its recorded wire traffic.
`valid_scopes` includes `offline_access` so the AS metadata advertises it and the SDK's
SEP-2207 auto-append (and the resulting `prompt=consent`) is exercised; `required_scopes`
stays at `["mcp"]` so the issued token still passes the bearer middleware.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "offline_access"])
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, settings=settings, on_request=on_request) as (
client,
headless,
):
await client.list_tools()
assert headless.authorize_url is not None
yield RecordedFlow(requests=recorded, authorize_url=headless.authorize_url)
@requirement("client-auth:pkce:s256")
@requirement("client-auth:resource-parameter")
@requirement("client-auth:authorize:offline-access-consent")
async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""Every spec-mandated parameter appears on the authorize URL with the right value.
The full key set is snapshotted so a parameter added or dropped fails the test. The
`code_challenge` length bound is the RFC 7636 §4.2 grammar; an S256 challenge is in
practice always 43 characters, so the upper bound is never approached.
"""
params = recorded_oauth_flow.authorize
assert sorted(params) == snapshot(
[
"client_id",
"code_challenge",
"code_challenge_method",
"prompt",
"redirect_uri",
"resource",
"response_type",
"scope",
"state",
]
)
assert params["response_type"] == "code"
assert params["code_challenge_method"] == "S256"
assert 43 <= len(params["code_challenge"]) <= 128
# The exact resource value depends on canonical-URI normalisation (a spec ambiguity); pin
# the stable prefix so the test does not lock in a trailing-slash decision.
assert params["resource"].startswith(BASE_URL)
assert params["state"] != ""
assert params["scope"].split(" ") == snapshot(["mcp", "offline_access"])
assert params["prompt"] == "consent"
@requirement("client-auth:pkce:s256")
async def test_the_code_verifier_on_the_token_request_hashes_to_the_code_challenge(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""The PKCE verifier sent on /token is the S256 pre-image of the challenge sent on /authorize.
The verifier is also checked against RFC 7636 §4.1's length and `unreserved` charset.
"""
challenge = recorded_oauth_flow.authorize["code_challenge"]
verifier = form_body(recorded_oauth_flow.token_request)["code_verifier"]
assert re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", verifier)
assert base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") == challenge
@requirement("client-auth:state:verify")
async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None:
"""A callback whose state does not match the value sent on /authorize raises and stops the flow.
The auth flow runs inside the streamable-HTTP client's task group, so the `OAuthFlowError`
reaches the test wrapped in nested single-element exception groups; `pytest.RaisesGroup`
asserts the leaf type and the SDK-authored message prefix (the full message embeds two
random tokens).
"""
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth(state_override="wrong-state")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^State parameter mismatch:"), flatten_subgroups=True
):
# Entering the connect raises during the OAuth handshake (inside `Client.__aenter__`),
# so an `async with` body would be unreachable; entering explicitly avoids dead code.
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
@requirement("client-auth:authorization-response:iss-verify")
async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None:
"""A callback whose RFC 9207 iss does not match the authorization server issuer aborts the flow.
`iss_override` makes the headless callback return an issuer the AS never advertised; the SDK
compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange.
"""
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth(iss_override="https://attacker.example.com")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True
):
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
@requirement("client-auth:resource-parameter")
async def test_the_authorization_code_token_request_carries_grant_type_code_redirect_and_resource(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""The /token form body has exactly the auth-code grant fields, with redirect_uri and resource matching /authorize.
`client_secret` is present because the SDK's dynamic-registration handler issues a secret
and the client defaults to `client_secret_post`.
"""
token_req = recorded_oauth_flow.token_request
body = form_body(token_req)
assert sorted(body) == snapshot(
["client_id", "client_secret", "code", "code_verifier", "grant_type", "redirect_uri", "resource"]
)
assert body["grant_type"] == "authorization_code"
assert body["code"] != ""
assert body["redirect_uri"] == recorded_oauth_flow.authorize["redirect_uri"]
assert body["resource"] == recorded_oauth_flow.authorize["resource"]
assert token_req.headers["content-type"] == "application/x-www-form-urlencoded"
@requirement("client-auth:bearer-header:every-request")
async def test_every_mcp_request_after_auth_carries_the_bearer_header_and_never_a_query_token(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""Every MCP request after the flow has `Authorization: Bearer ...` and never `?access_token=`.
The first /mcp POST is the unauthenticated trigger and is asserted to carry no Authorization
header; that assertion is only meaningful because the recording snapshots requests at send
time (the SDK mutates the same request object in place for the retry).
"""
mcp_posts = find(recorded_oauth_flow.requests, "POST", "/mcp")
assert len(mcp_posts) >= 3
assert "authorization" not in mcp_posts[0].headers
for r in mcp_posts[1:]:
assert r.headers["authorization"].startswith("Bearer ")
assert r.headers["authorization"] != "Bearer "
assert "access_token" not in dict(r.url.params)
@requirement("client-auth:token-endpoint-auth-method")
async def test_a_client_with_a_secret_authenticates_the_token_request_with_http_basic() -> None:
"""A `client_secret_basic` client sends URL-encoded credentials in HTTP Basic, not the body.
Credentials are URL-encoded before base64 per RFC 6749 §2.3.1; the secret contains `/` so
the encoding is observable.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
client_info = OAuthClientInformationFull(
client_id="cid",
client_secret="s/cret",
token_endpoint_auth_method="client_secret_basic",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
scope="mcp",
)
await provider.register_client(client_info)
storage = InMemoryTokenStorage(client_info=client_info)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
await client.list_tools()
assert find(recorded, "POST", "/register") == []
[token_req] = find(recorded, "POST", "/token")
decoded = base64.b64decode(token_req.headers["authorization"].removeprefix("Basic ")).decode()
assert decoded == f"{quote('cid', safe='')}:{quote('s/cret', safe='')}"
assert "client_secret" not in form_body(token_req)
@requirement("client-auth:token-endpoint-auth-method")
async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_advertised_methods() -> None:
"""The token-endpoint auth method comes from the registered client info, not from AS metadata.
The shim serves AS metadata advertising only `client_secret_basic`; the client dynamically
registers and the SDK's registration handler issues `client_secret_post`. The client uses
`client_secret_post` (secret in the body, no Basic header) because the SDK reads the
registered `token_endpoint_auth_method`, not `token_endpoint_auth_methods_supported`. Other
SDKs (TypeScript, Go) do consult the AS metadata; this test pins where the python SDK's
selection point lives.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
override = OAuthMetadata(
issuer=AnyHttpUrl(f"{BASE_URL}/"),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
scopes_supported=["mcp"],
grant_types_supported=["authorization_code", "refresh_token"],
code_challenge_methods_supported=["S256"],
token_endpoint_auth_methods_supported=["client_secret_basic"],
)
serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve), on_request=on_request
) as (client, _):
await client.list_tools()
[register] = find(recorded, "POST", "/register")
assert json.loads(register.content).get("token_endpoint_auth_method") is None
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert "client_secret" in body
assert body["client_secret"] != ""
assert "authorization" not in token_req.headers
@requirement("client-auth:scope-selection:priority")
async def test_scope_is_selected_from_the_www_authenticate_challenge_over_prm_metadata() -> None:
"""When the 401 challenge carries `scope=`, that value is requested instead of the PRM scopes.
The SDK's bearer middleware never emits `scope=` in WWW-Authenticate (see the divergence
on `hosting:auth:scope-403`), so the test supplies the first 401 itself via
`first_challenge_shim` and disables token verification so the post-auth retry succeeds
regardless of the granted scope. PRM advertises `["from-prm"]` (it mirrors
`required_scopes`); the challenge says `from-header`; the authorize URL must carry
`from-header`.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(default_scopes=["from-header"])
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["from-prm"], valid_scopes=["from-header", "from-prm"])
challenge = f'Bearer scope="from-header", resource_metadata="{BASE_URL}{PRM_PATH}"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
settings=settings,
verify_tokens=False,
app_shim=first_challenge_shim(challenge),
on_request=on_request,
) as (client, headless):
await client.list_tools()
assert headless.authorize_url is not None
assert authorize_params(headless.authorize_url)["scope"] == "from-header"
[register] = find(recorded, "POST", "/register")
assert json.loads(register.content)["scope"] == "from-header"
@requirement("client-auth:pkce:refuse-if-unsupported")
async def test_pkce_is_still_sent_when_as_metadata_omits_code_challenge_methods_supported() -> None:
"""AS metadata without `code_challenge_methods_supported` does not stop the client sending PKCE.
The spec says the client MUST refuse to proceed in this case; the SDK proceeds and the flow
completes. See the divergence on the requirement.
"""
override = OAuthMetadata(
issuer=AnyHttpUrl(f"{BASE_URL}/"),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
scopes_supported=["mcp"],
grant_types_supported=["authorization_code", "refresh_token"],
)
assert override.code_challenge_methods_supported is None
serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve)
) as (client, headless):
result = await client.list_tools()
assert headless.authorize_url is not None
params = authorize_params(headless.authorize_url)
assert params["code_challenge_method"] == "S256"
assert params["code_challenge"] != ""
assert result.tools[0].name == "echo"
@requirement("client-auth:authorize:error-surfaces")
async def test_an_authorize_error_on_the_callback_aborts_the_flow_before_the_token_request() -> None:
"""An `error=` redirect from /authorize aborts the flow with no /token request issued.
The SDK's callback contract is `() -> (code, state)` with no error form, so the failure is
observed as an empty code reaching the SDK and `OAuthFlowError("No authorization code
received")` being raised. The actual `error` value from the redirect is not surfaced to the
caller; that gap is noted in the manifest.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(deny_authorize=True)
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth()
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^No authorization code received$"), flatten_subgroups=True
):
await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__()
assert headless.error == "access_denied"
assert find(recorded, "POST", "/token") == []
+189
View File
@@ -0,0 +1,189 @@
"""Resource-server bearer-token gate: status codes and `WWW-Authenticate` for each token shape.
These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier`
seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since
every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status,
the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP
endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test.
"""
import time
from collections.abc import AsyncIterator
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import JSONRPCResponse
from mcp.server import Server
from mcp.server.auth.provider import AccessToken
from tests.interaction._connect import base_headers, initialize_body, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import StaticTokenVerifier, auth_settings
pytestmark = pytest.mark.anyio
REQUIRED_SCOPE = "mcp:read"
RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
_FUTURE = int(time.time()) + 3600
_PAST = int(time.time()) - 3600
TOKENS = {
"tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE),
"tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST),
"tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE),
"tok-wrong-aud": AccessToken(
token="tok-wrong-aud",
client_id="c",
scopes=[REQUIRED_SCOPE],
expires_at=_FUTURE,
resource="https://other.example/mcp",
),
}
@pytest.fixture
async def protected() -> AsyncIterator[httpx.AsyncClient]:
"""A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge."""
server = Server("rs")
settings = auth_settings(required_scopes=[REQUIRED_SCOPE])
async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _):
yield http
async def post_mcp(
http: httpx.AsyncClient, *, bearer: str | None = None, query: dict[str, str] | None = None
) -> httpx.Response:
"""POST an initialize body to `/mcp`, optionally with a bearer token and/or a query string."""
headers = base_headers()
if bearer is not None:
headers["authorization"] = f"Bearer {bearer}"
return await http.post("/mcp", headers=headers, params=query, json=initialize_body())
def parse_www_authenticate(value: str) -> dict[str, str]:
"""Parse a `Bearer k="v", k="v"` challenge into a dict.
The SDK emits each parameter exactly once, comma-space separated, with double-quoted
values that contain no quotes themselves; this helper relies on that and would fail
visibly if the format changed.
"""
scheme, _, params = value.partition(" ")
assert scheme == "Bearer"
return {key: quoted.strip('"') for key, _, quoted in (pair.partition("=") for pair in params.split(", "))}
@requirement("hosting:auth:missing-401")
async def test_a_request_with_no_authorization_header_is_challenged_with_resource_metadata(
protected: httpx.AsyncClient,
) -> None:
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata`.
The snapshot pins current behaviour: the SDK collapses the no-header, unknown-token, and
expired-token cases into one challenge (`error="invalid_token"`, no `scope` parameter). The
spec says the discovery-time challenge SHOULD include `scope` and RFC 6750 says the
no-credentials case SHOULD NOT carry an error code; both gaps are recorded as the divergence
on this requirement. Asserting the dict equals an exact key set also pins that no parameter
appears twice.
"""
response = await post_mcp(protected)
assert response.status_code == 401
assert response.headers["www-authenticate"] == snapshot(
'Bearer error="invalid_token", error_description="Authentication required", '
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
)
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"resource_metadata": RESOURCE_METADATA_URL,
}
assert response.json() == snapshot({"error": "invalid_token", "error_description": "Authentication required"})
@requirement("hosting:auth:invalid-401")
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx.AsyncClient) -> None:
"""A token the verifier does not recognize is answered 401 `invalid_token`.
The challenge is identical to the no-header case (the backend returns `None` for both); the
missing `scope` parameter is the recorded divergence on this requirement.
"""
response = await post_mcp(protected, bearer="tok-unknown")
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"resource_metadata": RESOURCE_METADATA_URL,
}
@requirement("hosting:auth:expired-401")
async def test_an_expired_token_is_answered_401(protected: httpx.AsyncClient) -> None:
"""A token whose `expires_at` is in the past is answered 401 `invalid_token`.
The expiry check is the bearer backend's, against the wall clock; the test seeds a concrete
past timestamp so no time mocking is involved. The missing `scope` parameter is the recorded
divergence on this requirement.
"""
response = await post_mcp(protected, bearer="tok-expired")
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"
@requirement("hosting:auth:scope-403")
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param(
protected: httpx.AsyncClient,
) -> None:
"""A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter.
The spec's runtime-insufficient-scope guidance says the challenge SHOULD include `scope`
naming the required scope; the SDK never emits it, recorded as the divergence on this
requirement. The SDK client reads `scope` from this header to drive step-up, so the gap is
a resource-server/client asymmetry.
"""
response = await post_mcp(protected, bearer="tok-noscope")
assert response.status_code == 403
parsed = parse_www_authenticate(response.headers["www-authenticate"])
assert parsed == {
"error": "insufficient_scope",
"error_description": f"Required scope: {REQUIRED_SCOPE}",
"resource_metadata": RESOURCE_METADATA_URL,
}
assert "scope" not in parsed
@requirement("hosting:auth:aud-validation")
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.AsyncClient) -> None:
"""A token whose `resource` does not match the server's resource identifier is accepted.
The spec mandates the resource server validate the token's audience; the bearer backend
never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint
serves it. This pins current behaviour with the divergence recorded on the requirement.
"""
response = await post_mcp(protected, bearer="tok-wrong-aud")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
# The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response
# out of the buffered text to prove the MCP endpoint actually answered the initialize request.
[data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")]
assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result
@requirement("hosting:auth:query-token-ignored")
async def test_an_access_token_in_the_query_string_is_not_accepted(protected: httpx.AsyncClient) -> None:
"""A valid token presented in the URI query string is treated as no authentication.
The bearer backend reads only the `Authorization` header, so `?access_token=...` is never
consulted; the request is treated as unauthenticated and answered 401. This satisfies, by
absence, the security best-practice that resource servers must not accept query-string
tokens.
"""
response = await post_mcp(protected, query={"access_token": "tok-valid"})
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"
+339
View File
@@ -0,0 +1,339 @@
"""Protected-resource and authorization-server metadata discovery, end to end.
Every client-side test connects a real `Client` via `connect_with_oauth` and asserts on the
recorded request paths the discovery probes produced; the discovery URL ordering is a wire
detail `Client` cannot observe directly but the recording can. Tests that need a metadata
endpoint to 404 or return alternate content wrap the SDK's app in `shimmed_app` while leaving
the real authorize and token endpoints behind it, so the rest of the flow runs unaltered.
The two server-side tests (#5, #6) drive raw httpx against `mounted_app` because their
assertions are the metadata response bodies and headers, which `Client` does not surface.
"""
import json
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import ListToolsResult, Tool
from pydantic import AnyHttpUrl
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
from tests.interaction._connect import BASE_URL, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
RecordedRequest,
auth_settings,
connect_with_oauth,
metadata_body,
record_requests,
shim,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
PRM_PATH_SUFFIXED = "/.well-known/oauth-protected-resource/mcp"
PRM_ROOT = "/.well-known/oauth-protected-resource"
ASM_ROOT = "/.well-known/oauth-authorization-server"
OIDC_ROOT = "/.well-known/openid-configuration"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="probe", input_schema={"type": "object"})])
def discovery_gets(recorded: list[RecordedRequest]) -> list[str]:
"""Return the well-known GET paths in recorded order, ignoring everything else."""
return [r.path for r in recorded if r.method == "GET" and "/.well-known/" in r.path]
def real_asm() -> OAuthMetadata:
"""Build an authorization-server metadata document pointing at the real co-hosted endpoints."""
return OAuthMetadata(
issuer=AnyHttpUrl(BASE_URL),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
scopes_supported=["mcp"],
grant_types_supported=["authorization_code", "refresh_token"],
code_challenge_methods_supported=["S256"],
)
@requirement("client-auth:prm-discovery:fallback-order")
async def test_prm_discovery_uses_the_resource_metadata_url_from_www_authenticate() -> None:
"""The first protected-resource probe is the URL the 401's `WWW-Authenticate` header supplied.
With co-hosted defaults the header carries the path-suffixed well-known URL; the client
fetches that one first and, because it succeeds, never falls back. The single-probe
sequence proves priority 1.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, on_request=on_request) as (client, _):
await client.list_tools()
assert discovery_gets(recorded) == snapshot([PRM_PATH_SUFFIXED, ASM_ROOT])
assert (recorded[0].method, recorded[0].path) == ("POST", "/mcp")
assert (recorded[1].method, recorded[1].path) == ("GET", PRM_PATH_SUFFIXED)
@requirement("client-auth:prm-discovery:fallback-order")
async def test_prm_discovery_falls_back_from_path_well_known_to_root_on_404() -> None:
"""When the path-suffixed PRM well-known 404s, the client falls back to the root well-known.
The exact GET count is not asserted: the WWW-Authenticate URL equals the path well-known
here, so the SDK probes it twice (once as priority 1, once as priority 2) before reaching
root. Asserting "path before root, root reached, then the flow proceeds" pins the spec
invariant; the duplicate probe is an implementation detail. The served PRM body carries an
unrecognized field to prove the client's parser ignores unknown members (RFC 9728 §3.2).
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
prm = ProtectedResourceMetadata(
resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(BASE_URL)]
)
app_shim = shim(
not_found=frozenset({PRM_PATH_SUFFIXED}),
serve={PRM_ROOT: metadata_body(prm, x_unknown_extension="ignored")},
)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request) as (
client,
_,
):
await client.list_tools()
well_known = discovery_gets(recorded)
assert PRM_PATH_SUFFIXED in well_known
assert PRM_ROOT in well_known
assert well_known.index(PRM_PATH_SUFFIXED) < well_known.index(PRM_ROOT)
assert any(r.path == "/authorize" for r in recorded)
@requirement("client-auth:prm-discovery:no-prm-fallback")
async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_the_server_origin() -> None:
"""When every protected-resource metadata probe 404s, the client falls back to the legacy path.
The legacy 2025-03-26 behaviour: with no PRM document available, treat the MCP server's
origin as the authorization server and fetch its `/.well-known/oauth-authorization-server`
directly. The real co-hosted ASM endpoint is at exactly that location, so the flow completes.
The recorded sequence shows both PRM well-known paths probed (and failed) before ASM_ROOT.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
app_shim = shim(not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT}))
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request) as (
client,
_,
):
result = await client.list_tools()
well_known = discovery_gets(recorded)
assert PRM_PATH_SUFFIXED in well_known
assert PRM_ROOT in well_known
assert well_known[-1] == ASM_ROOT
assert all(well_known.index(prm) < well_known.index(ASM_ROOT) for prm in (PRM_PATH_SUFFIXED, PRM_ROOT))
assert result.tools[0].name == "probe"
@requirement("client-auth:dcr:registration-error-surfaces")
async def test_a_400_from_the_registration_endpoint_surfaces_as_a_registration_error() -> None:
"""A 400 from `/register` surfaces as `OAuthRegistrationError` carrying the server's body.
The shim makes `/register` return RFC 7591's `invalid_client_metadata`; the SDK reads the
body and raises with the status and text in the message, before any authorize or token
request is made.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
error_body = json.dumps({"error": "invalid_client_metadata", "error_description": "no"}).encode()
app_shim = shim(serve={"/register": (400, error_body)})
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthRegistrationError, match=r"^Registration failed: 400 .*invalid_client_metadata"),
flatten_subgroups=True,
):
await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
@requirement("client-auth:prm-resource-mismatch")
async def test_prm_with_a_mismatched_resource_aborts_the_flow_before_authorize() -> None:
"""A PRM document whose `resource` does not cover the server URL aborts the flow.
The shim serves PRM at the URL the WWW-Authenticate header supplies, but with a `resource`
on a different path; `check_resource_allowed` rejects it and `OAuthFlowError` is raised
before any authorize or token request is made. The error reaches the test wrapped in nested
single-element exception groups by the streamable-HTTP client's task group.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
prm = ProtectedResourceMetadata(
resource=AnyHttpUrl(f"{BASE_URL}/other"), authorization_servers=[AnyHttpUrl(BASE_URL)]
)
app_shim = shim(serve={PRM_PATH_SUFFIXED: metadata_body(prm)})
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^Protected resource .* does not match expected"),
flatten_subgroups=True,
):
await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
@requirement("client-auth:as-metadata-discovery:priority-order")
@pytest.mark.parametrize(
("authorization_server", "not_found", "serve_at", "expected_order"),
[
pytest.param(
f"{BASE_URL}/",
frozenset({ASM_ROOT}),
OIDC_ROOT,
[ASM_ROOT, OIDC_ROOT],
id="root-issuer",
),
pytest.param(
f"{BASE_URL}/tenant",
frozenset({f"{ASM_ROOT}/tenant", f"{OIDC_ROOT}/tenant"}),
"/tenant/.well-known/openid-configuration",
[f"{ASM_ROOT}/tenant", f"{OIDC_ROOT}/tenant", "/tenant/.well-known/openid-configuration"],
id="path-issuer",
),
],
)
async def test_as_metadata_discovery_falls_back_through_the_spec_endpoint_order(
authorization_server: str, not_found: frozenset[str], serve_at: str, expected_order: list[str]
) -> None:
"""Authorization-server metadata is fetched at the spec's endpoints in the spec's order.
The shim 404s every endpoint before the last so the recording proves each probe and its
position. For an issuer URL with no path the order is OAuth root then OIDC root; for an
issuer URL with a path component it is OAuth path-inserted, OIDC path-inserted, then OIDC
path-appended (the spec's three-endpoint MUST). The path-issuer case is driven by serving
a PRM whose `authorization_servers` carries the path; the SDK's own AS routes stay at root
(the served body points at the real `/authorize` and `/token`). The served bodies carry an
unrecognized field to prove the client's parser ignores unknown members (RFC 8414 §3.2).
"""
recorded, on_request = record_requests()
asm = real_asm()
asm.issuer = AnyHttpUrl(authorization_server)
# The redirect iss must equal the issuer the client records from this metadata.
provider = InMemoryAuthorizationServerProvider(issuer=str(asm.issuer))
server = Server("guarded", on_list_tools=list_tools)
prm = ProtectedResourceMetadata(
resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(authorization_server)]
)
app_shim = shim(
not_found=not_found,
serve={
PRM_PATH_SUFFIXED: metadata_body(prm),
serve_at: metadata_body(asm, x_unknown_extension="ignored"),
},
)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request) as (
client,
_,
):
await client.list_tools()
assert discovery_gets(recorded) == [PRM_PATH_SUFFIXED, *expected_order]
@requirement("hosting:auth:metadata-endpoints")
@requirement("hosting:auth:prm:authorization-servers-field")
async def test_the_prm_endpoint_serves_the_resource_url_and_at_least_one_authorization_server() -> None:
"""The protected-resource metadata document the SDK serves identifies the resource and an authorization server.
Also asserts the response is `application/json` (RFC 9728 §3.2) and that fields the SDK has
no value for are absent rather than null (`PydanticJSONResponse` serializes with
`exclude_none=True`, satisfying RFC 9728 §3.2's omit-zero-value rule).
"""
server = Server("bare")
provider = InMemoryAuthorizationServerProvider()
async with mounted_app(server, auth=auth_settings(), auth_server_provider=provider) as (http, _):
response = await http.get(PRM_PATH_SUFFIXED)
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
document = json.loads(response.content)
assert "resource_documentation" not in document
assert "scopes_supported" in document
metadata = ProtectedResourceMetadata.model_validate(document)
assert str(metadata.resource).rstrip("/") == f"{BASE_URL}/mcp"
assert len(metadata.authorization_servers) >= 1
assert metadata.bearer_methods_supported == ["header"]
@requirement("hosting:auth:as-router")
async def test_as_metadata_advertises_authorize_token_registration_and_s256() -> None:
"""The authorization-server metadata document the SDK serves names the required endpoints and S256."""
server = Server("bare")
provider = InMemoryAuthorizationServerProvider()
async with mounted_app(server, auth=auth_settings(), auth_server_provider=provider) as (http, _):
response = await http.get(ASM_ROOT)
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
metadata = OAuthMetadata.model_validate_json(response.content)
assert str(metadata.issuer).rstrip("/") == BASE_URL
assert str(metadata.authorization_endpoint) == f"{BASE_URL}/authorize"
assert str(metadata.token_endpoint) == f"{BASE_URL}/token"
assert str(metadata.registration_endpoint) == f"{BASE_URL}/register"
assert metadata.response_types_supported == ["code"]
assert metadata.code_challenge_methods_supported is not None
assert "S256" in metadata.code_challenge_methods_supported
@requirement("client-auth:as-metadata-discovery:issuer-validation")
async def test_as_metadata_with_a_mismatched_issuer_aborts_the_flow() -> None:
"""Authorization-server metadata whose `issuer` does not match the discovery URL is rejected.
RFC 8414 §3.3 / SEP-2468 require the client to reject the document; the SDK compares `issuer`
to the URL the metadata was fetched from and raises `OAuthFlowError` before any authorize or
token request is made.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
metadata = real_asm()
metadata.issuer = AnyHttpUrl(f"{BASE_URL}/wrong-issuer")
app_shim = shim(serve={ASM_ROOT: metadata_body(metadata)})
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^Authorization server metadata issuer mismatch"),
flatten_subgroups=True,
):
await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
+240
View File
@@ -0,0 +1,240 @@
"""End-to-end OAuth authorization-code flow against the SDK's own server, fully in process.
Auth is HTTP-only so these tests are not transport-parametrized; each connects via
`connect_with_oauth`, which co-hosts the SDK's authorization server, protected-resource
metadata, and bearer-gated MCP endpoint on one bridge-backed Starlette app and drives the
whole flow through one `httpx.AsyncClient` carrying the SDK's `OAuthClientProvider`. The
authorize redirect completes headlessly through the same bridge, so every request the flow
makes is observable via `on_request`.
"""
import json
from collections import Counter
from urllib.parse import parse_qs, urlsplit
import anyio
import httpx
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ListToolsResult, TextContent, Tool
from pydantic import AnyUrl
from mcp.server import Server, ServerRequestContext
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.shared.auth import OAuthClientInformationFull
from tests.interaction._connect import BASE_URL
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
REDIRECT_URI,
InMemoryTokenStorage,
auth_settings,
connect_with_oauth,
oauth_client_metadata,
shimmed_app,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
from tests.interaction.transports._bridge import StreamingASGITransport
pytestmark = pytest.mark.anyio
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="whoami", input_schema={"type": "object"})])
@requirement("flow:oauth:authorization-code-roundtrip")
@requirement("client-auth:401-triggers-flow")
@requirement("hosting:auth:missing-401")
async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow_connects() -> None:
"""Connecting to a bearer-gated server walks the full authorization-code flow and succeeds.
Three requirements are proven by one connect: the flow runs end to end (authorization-code
roundtrip), it was triggered by a 401 on the first MCP request (401-triggers-flow), and
that 401 carried `resource_metadata` in `WWW-Authenticate` for discovery (missing-401).
The flagship test pins the recorded request sequence so the discovery → registration →
authorize → token → retry order is asserted explicitly.
Steps the SDK is expected to perform:
1. POST /mcp without a token → 401 with `WWW-Authenticate: Bearer resource_metadata=...`.
2. GET the protected-resource metadata.
3. GET the authorization-server metadata.
4. POST /register (dynamic client registration).
5. GET /authorize → 302 with code+state (completed by the headless redirect).
6. POST /token (authorization-code exchange).
7. Retry POST /mcp with `Authorization: Bearer <access_token>` → succeeds.
"""
requests: list[httpx.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=requests.append) as (
client,
headless,
):
result = await client.list_tools()
assert result == snapshot(ListToolsResult(tools=[Tool(name="whoami", input_schema={"type": "object"})]))
assert headless.authorize_url is not None
paths = [(r.method, r.url.path) for r in requests]
assert Counter(paths) == snapshot(
Counter(
{
("POST", "/mcp"): 4,
("GET", "/.well-known/oauth-protected-resource/mcp"): 1,
("GET", "/.well-known/oauth-authorization-server"): 1,
("POST", "/register"): 1,
("GET", "/authorize"): 1,
("POST", "/token"): 1,
("GET", "/mcp"): 1,
("DELETE", "/mcp"): 1,
}
)
)
assert (requests[0].method, requests[0].url.path) == ("POST", "/mcp")
# The recorded Request objects are live references: the auth flow mutates the original
# request's headers in place when it adds the bearer token for the retry, so the first
# entry's headers cannot be used to assert "no Authorization on the first attempt". The
# path multiset above proving discovery happened is the evidence the first attempt was 401.
# The first PRM discovery GET carries the protocol-version header (an SDK behaviour, not a
# spec requirement on discovery requests).
prm_get = next(r for r in requests if r.url.path == "/.well-known/oauth-protected-resource/mcp")
assert prm_get.headers.get("mcp-protocol-version") == snapshot("2026-07-28")
authorize = parse_qs(urlsplit(headless.authorize_url).query)
assert authorize["response_type"] == ["code"]
assert authorize["code_challenge_method"] == ["S256"]
assert authorize["client_id"][0] in provider.clients
assert storage.tokens is not None
bearer = f"Bearer {storage.tokens.access_token}"
authed_mcp = [r for r in requests if r.url.path == "/mcp" and r.headers.get("authorization") == bearer]
assert len(authed_mcp) > 0
assert storage.tokens.access_token in provider.access_tokens
@requirement("hosting:auth:authinfo-propagates")
async def test_the_access_token_reaches_the_tool_handler_via_get_access_token() -> None:
"""A tool handler reads the request's access token through `get_access_token()`."""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "whoami"
token = get_access_token()
assert token is not None
return CallToolResult(content=[TextContent(text=" ".join(token.scopes))])
server = Server("guarded", on_list_tools=list_tools, on_call_tool=call_tool)
provider = InMemoryAuthorizationServerProvider()
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider) as (client, _):
result = await client.call_tool("whoami", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="mcp")]))
@requirement("client-auth:pre-registration")
async def test_a_preregistered_client_skips_registration() -> None:
"""A client whose storage already holds client info uses it instead of registering.
The provider holds the same registration server-side so the authorize and token steps
accept it; the recorded requests prove no `/register` call was made.
"""
requests: list[httpx.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
client_info = OAuthClientInformationFull(
client_id="preregistered",
client_secret="s3cret",
token_endpoint_auth_method="client_secret_post",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
scope="mcp",
)
await provider.register_client(client_info)
storage.client_info = client_info
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=requests.append) as (
client,
_,
):
await client.list_tools()
assert [r.url.path for r in requests].count("/register") == 0
assert list(provider.clients) == ["preregistered"]
@requirement("client-auth:dcr")
async def test_the_dcr_request_carries_the_client_metadata() -> None:
"""Dynamic registration sends the client's metadata and persists what the server issued.
The body of the recorded `/register` POST carries the metadata the test supplied (with the
scope filled in from server discovery), and the server's issued client_id and secret are
persisted to storage and held by the provider.
"""
requests: list[httpx.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
client_metadata = oauth_client_metadata()
client_metadata.software_id = "interaction-test-suite"
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, storage=storage, client_metadata=client_metadata, on_request=requests.append
) as (client, _):
await client.list_tools()
register = next(r for r in requests if r.url.path == "/register")
assert register.headers["content-type"] == "application/json"
body = json.loads(register.content)
assert body == snapshot(
{
"redirect_uris": ["http://127.0.0.1:8000/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "mcp",
"application_type": "native",
"client_name": "interaction-suite",
"software_id": "interaction-test-suite",
}
)
assert storage.client_info is not None
assert storage.client_info.client_id is not None
assert storage.client_info.client_secret is not None
assert list(provider.clients) == [storage.client_info.client_id]
async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_wrapped_app() -> None:
"""Harness self-test: `shimmed_app` serves canned bodies, 404s, and forwards everything else.
Wraps a real auth-hosting Starlette app so the forward path is exercised against the SDK's
own routing; provided here so the discovery tests can rely on the shim without each adding
their own contract test.
"""
server = Server("bare")
provider = InMemoryAuthorizationServerProvider()
real_app = server.streamable_http_app(auth=auth_settings(), auth_server_provider=provider)
app = shimmed_app(real_app, not_found=frozenset({"/missing"}), serve={"/override": b'{"shimmed": true}'})
async with server.session_manager.run():
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
served = await http.get("/override")
assert served.status_code == 200
assert served.headers["content-type"] == "application/json"
assert served.json() == {"shimmed": True}
assert (await http.get("/missing")).status_code == 404
forwarded = await http.get("/.well-known/oauth-authorization-server")
assert forwarded.status_code == 200
assert forwarded.json()["issuer"] == "http://127.0.0.1:8000/"
@@ -0,0 +1,302 @@
"""End-to-end SEP-990 Identity Assertion (RFC 7523 jwt-bearer) flows.
These exercise the FULL stack: the real `IdentityAssertionOAuthProvider` on the client and the real
authorization-server token endpoint on the server, with `InMemoryAuthorizationServerProvider`
implementing `exchange_identity_assertion`. The client supplies the ID-JAG through its
`assertion_provider` callback; the provider validates it and the issued bearer authorizes the MCP
request.
Recording-first: the recorded `/token` request is asserted before the call result, so a surprise in
the exchange path produces a readable diff of what fired.
"""
from urllib.parse import parse_qsl
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import ListToolsResult, Tool
from mcp.client.auth import OAuthFlowError, OAuthTokenError
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from tests.interaction._connect import BASE_URL, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
record_requests,
)
from tests.interaction.auth._provider import VALID_ASSERTION, InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
ASM_ROOT = "/.well-known/oauth-authorization-server"
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
CLIENT_ID = "enterprise-mcp-client"
CLIENT_SECRET = "enterprise-secret"
# The AS metadata issuer carries a trailing slash (built from an AnyHttpUrl object); the client
# pins against exactly that.
EXPECTED_ISSUER = f"{BASE_URL}/"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
return [r for r in recorded if r.method == method and r.path == path]
def form_body(request: RecordedRequest) -> dict[str, str]:
return dict(parse_qsl(request.content.decode()))
def preregister_confidential_client(provider: InMemoryAuthorizationServerProvider) -> None:
"""Seed a pre-registered confidential client allowed to use the identity-assertion grant.
SEP-990 clients are provisioned out of band (DCR refuses the grant), so the server already knows
the client; `IdentityAssertionOAuthProvider` presents the same id + secret without registering.
"""
provider.clients[CLIENT_ID] = OAuthClientInformationFull(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method="client_secret_post",
scope="mcp",
)
def identity_assertion_provider(
storage: InMemoryTokenStorage,
*,
assertion: str = VALID_ASSERTION,
issuer: str = EXPECTED_ISSUER,
record: list[tuple[str, str]] | None = None,
) -> IdentityAssertionOAuthProvider:
async def assertion_provider(audience: str, resource: str) -> str:
if record is not None:
record.append((audience, resource))
return assertion
return IdentityAssertionOAuthProvider(
server_url=f"{BASE_URL}/mcp",
storage=storage,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
issuer=issuer,
assertion_provider=assertion_provider,
scope="mcp",
)
@requirement("client-auth:identity-assertion")
async def test_identity_assertion_obtains_a_token_and_authorizes_the_request() -> None:
"""The identity-assertion provider connects end to end with no authorize/register step.
The full stack runs: the client posts grant_type=jwt-bearer with the ID-JAG as `assertion` to the
real token endpoint, the provider validates it and issues a bearer, and the bearer authorizes
list_tools. The recorded sequence proves no /authorize or /register request was made.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
storage = InMemoryTokenStorage()
auth = identity_assertion_provider(storage)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
on_request=on_request,
) as (client, headless):
result = await client.list_tools()
# Recording-first: assert what fired before the call result.
assert headless.authorize_url is None
assert find(recorded, "GET", "/authorize") == []
assert find(recorded, "POST", "/register") == []
# The AS is configuration: PRM is never fetched, so the resource server has no input into where
# the credentials go.
assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded)
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body == snapshot(
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "valid-id-jag",
"client_id": "enterprise-mcp-client",
"resource": "http://127.0.0.1:8000/mcp",
"scope": "mcp",
"client_secret": "enterprise-secret",
}
)
assert result.tools[0].name == "echo"
assert provider.last_assertion_params is not None
assert provider.last_assertion_params.assertion == VALID_ASSERTION
assert storage.tokens is not None
assert storage.tokens.access_token in provider.access_tokens
@requirement("client-auth:identity-assertion")
async def test_configured_scope_is_sent_regardless_of_server_advertised_scopes() -> None:
"""The caller's configured scope reaches the wire; server-advertised scopes have no effect.
The provider has no scope-selection step, so this is true by construction; the test pins it.
Here the AS advertises `mcp extra` but the client requested only `mcp`, so the recorded `/token`
body must carry `scope=mcp`, not the advertised superset.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
auth = identity_assertion_provider(InMemoryTokenStorage())
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
# AS metadata advertises a broader scopes_supported than the client requests.
settings=auth_settings(identity_assertion_enabled=True, valid_scopes=["mcp", "extra"]),
auth=auth,
on_request=on_request,
) as (client, _):
await client.list_tools()
[token_req] = find(recorded, "POST", "/token")
assert form_body(token_req)["scope"] == "mcp"
assert provider.last_assertion_params is not None
assert provider.last_assertion_params.scopes == ["mcp"]
@requirement("client-auth:identity-assertion:assertion-callback")
async def test_assertion_callback_receives_issuer_audience_and_resource() -> None:
"""The assertion_provider gets the AS issuer as audience and the MCP resource identifier."""
record: list[tuple[str, str]] = []
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
auth = identity_assertion_provider(InMemoryTokenStorage(), record=record)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
) as (client, _):
await client.list_tools()
assert record == [(EXPECTED_ISSUER, f"{BASE_URL}/mcp")]
@requirement("client-auth:identity-assertion:issuer-pinning")
async def test_unexpected_issuer_aborts_before_sending_credentials() -> None:
"""When the configured issuer's metadata fails RFC 8414 validation, no credential is sent.
The AS issuer is configuration; metadata is fetched from that issuer's well-known and validated
per RFC 8414 section 3.3. Here the in-process server's metadata has a different issuer than the
one the client is configured for, so validation fails before the assertion callback is invoked
or any credential is posted. PRM is never fetched and no DCR is attempted.
"""
recorded, on_request = record_requests()
record: list[tuple[str, str]] = []
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
# The served AS metadata has issuer BASE_URL/, but the client is configured for a different one.
auth = identity_assertion_provider(InMemoryTokenStorage(), issuer="https://corp-as.example/", record=record)
with anyio.fail_after(5):
with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError, match="issuer mismatch"), flatten_subgroups=True):
await connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
on_request=on_request,
).__aenter__()
assert record == []
assert provider.last_assertion_params is None
assert find(recorded, "POST", "/token") == []
assert find(recorded, "POST", "/register") == []
assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded)
@requirement("client-auth:identity-assertion:disabled-rejected")
async def test_identity_assertion_is_rejected_when_disabled_on_the_server() -> None:
"""With the grant disabled, the token endpoint returns unsupported_grant_type and the flow fails."""
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
auth = identity_assertion_provider(InMemoryTokenStorage())
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*unsupported_grant_type"),
flatten_subgroups=True,
):
await connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=False),
auth=auth,
).__aenter__()
assert provider.last_assertion_params is None
@requirement("client-auth:identity-assertion:invalid-assertion")
async def test_a_rejected_assertion_aborts_the_flow() -> None:
"""An ID-JAG the provider rejects surfaces as OAuthTokenError; no bearer is issued."""
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
storage = InMemoryTokenStorage()
auth = identity_assertion_provider(storage, assertion="forged-id-jag")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*invalid_grant"),
flatten_subgroups=True,
):
await connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
).__aenter__()
assert provider.last_assertion_params is not None
assert provider.last_assertion_params.assertion == "forged-id-jag"
assert storage.tokens is None
@requirement("client-auth:identity-assertion:metadata-advertised")
async def test_metadata_advertises_jwt_bearer_grant_and_id_jag_profile() -> None:
"""When enabled, AS metadata lists the jwt-bearer grant and the id-jag grant profile."""
server = Server("bare")
provider = InMemoryAuthorizationServerProvider()
async with mounted_app(
server, auth=auth_settings(identity_assertion_enabled=True), auth_server_provider=provider
) as (http, _):
response = await http.get(ASM_ROOT)
assert response.status_code == 200
metadata = OAuthMetadata.model_validate_json(response.content)
assert metadata.grant_types_supported is not None
assert JWT_BEARER_GRANT_TYPE in metadata.grant_types_supported
assert metadata.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
+509
View File
@@ -0,0 +1,509 @@
"""Token lifecycle, step-up, and registration-variant flows of the SDK's OAuth client.
Every test connects end to end via `connect_with_oauth`; the assertions are recording-first
(the recorded request sequence is asserted before, or independently of, the call result), so a
surprise in the refresh or step-up paths produces a readable diff of what fired rather than an
opaque failure. The provider knobs that drive each scenario are documented per test.
"""
import base64
from collections import Counter
from urllib.parse import parse_qsl, urlsplit
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, ListToolsResult, Tool
from pydantic import AnyHttpUrl, AnyUrl
from mcp import MCPError
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from tests.interaction._connect import BASE_URL
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
REDIRECT_URI,
InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
m2m_token_shim,
metadata_body,
record_requests,
shim,
step_up_shim,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
PRM_PATH = "/.well-known/oauth-protected-resource/mcp"
ASM_PATH = "/.well-known/oauth-authorization-server"
CIMD_URL = "https://client.example/.well-known/mcp-client"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
def form_body(request: RecordedRequest) -> dict[str, str]:
"""Parse an `application/x-www-form-urlencoded` request body into a flat dict."""
return dict(parse_qsl(request.content.decode()))
def authorize_params(authorize_url: str) -> dict[str, str]:
"""Parse the authorize URL's query string into a flat dict."""
return dict(parse_qsl(urlsplit(authorize_url).query))
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
return [r for r in recorded if r.method == method and r.path == path]
def path_counts(recorded: list[RecordedRequest]) -> Counter[tuple[str, str]]:
return Counter((r.method, r.path) for r in recorded)
def cimd_supported_metadata() -> bytes:
"""AS metadata advertising `client_id_metadata_document_supported: true` (the SDK server never sets it)."""
metadata = OAuthMetadata(
issuer=AnyHttpUrl(f"{BASE_URL}/"),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
scopes_supported=["mcp"],
response_types_supported=["code"],
grant_types_supported=["authorization_code", "refresh_token"],
code_challenge_methods_supported=["S256"],
client_id_metadata_document_supported=True,
)
return metadata_body(metadata)
def seeded_client(provider: InMemoryAuthorizationServerProvider, **kwargs: object) -> OAuthClientInformationFull:
"""Register a client with the provider and return its info, for pre-registration and CIMD scenarios."""
base: dict[str, object] = {
"client_id": "preregistered",
"token_endpoint_auth_method": "none",
"redirect_uris": [AnyUrl(REDIRECT_URI)],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "mcp",
}
base.update(kwargs)
info = OAuthClientInformationFull.model_validate(base)
assert info.client_id is not None
provider.clients[info.client_id] = info
return info
@requirement("client-auth:refresh:transparent")
async def test_an_expired_access_token_is_transparently_refreshed_before_the_next_request() -> None:
"""An access token the client considers expired is refreshed and the new bearer is used.
The provider tells the client `expires_in=-3600` for the first token while keeping the
server-side `expires_at` in the future, so the connect's retry succeeds and the next
request finds the token expired and refreshes. The recorded requests prove exactly one
`grant_type=refresh_token` exchange carrying the resource indicator, and the bearer used
after the refresh is the second access token, which is the one persisted to storage.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(issue_expired_first=True)
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
result = await client.list_tools()
assert result.tools[0].name == "echo"
token_posts = find(recorded, "POST", "/token")
bodies = [form_body(r) for r in token_posts]
assert [b["grant_type"] for b in bodies] == snapshot(["authorization_code", "refresh_token"])
refresh_body = bodies[1]
assert sorted(refresh_body) == snapshot(["client_id", "client_secret", "grant_type", "refresh_token", "resource"])
assert refresh_body["refresh_token"].startswith("refresh_")
assert refresh_body["resource"].startswith(BASE_URL)
bearers = {r.headers["authorization"] for r in recorded if r.path == "/mcp" and "authorization" in r.headers}
assert len(bearers) == 2
assert storage.tokens is not None
assert f"Bearer {storage.tokens.access_token}" in bearers
assert storage.tokens.expires_in == 3600
@requirement("client-auth:403-scope-upgrade")
async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challenged_scope() -> None:
"""A 403 `insufficient_scope` challenge is answered by one re-authorize with the challenge's scope.
The shim 403s the second authenticated `/mcp` POST (the `notifications/initialized` request,
which reaches the auth flow's step-up handler; the first authenticated POST is the post-401
retry, after which the generator ends without inspecting the response). The challenge names a
wider scope; step-up reuses cached metadata and the existing client registration,
re-authorizes with the new scope, and the connect completes. The client is pre-registered
with both scopes so the server's authorize handler accepts the wider second request. One
re-authorize, one retry; the spec's SHOULD-retry-limit ("a few") is not enforced.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
challenge = 'Bearer error="insufficient_scope", scope="mcp write"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
settings=settings,
app_shim=step_up_shim(challenge),
on_request=on_request,
) as (client, headless):
result = await client.list_tools()
assert result.tools[0].name == "echo"
assert len(headless.authorize_urls) == 2
assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp"
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
counts = path_counts(recorded)
assert counts[("GET", PRM_PATH)] == 1
assert counts[("GET", ASM_PATH)] == 1
assert counts[("POST", "/register")] == 0
assert counts[("GET", "/authorize")] == 2
assert counts[("POST", "/token")] == 2
@requirement("client-auth:403-scope-union")
async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenged_scopes() -> None:
"""The step-up re-authorize requests the union of the previously requested and challenged scopes.
The first authorization requests `mcp`; the 403 challenges a disjoint `write` (not naming
`mcp`). Per SEP-2350 the client must re-authorize with `mcp write`, not drop `mcp`. The client
is pre-registered with both scopes so the server's authorize handler accepts the wider request.
"""
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
challenge = 'Bearer error="insufficient_scope", scope="write"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
settings=settings,
app_shim=step_up_shim(challenge),
) as (client, headless):
await client.list_tools()
assert len(headless.authorize_urls) == 2
assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp"
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
@requirement("client-auth:as-binding")
async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_client_re_registers() -> None:
"""Credentials bound to a stale issuer are dropped and re-registered against the current AS.
The stored client is bound (SEP-2352) to a different issuer than the one the server's PRM
advertises, simulating an authorization-server migration. The client must discard it, perform
Dynamic Client Registration with the current AS, and never present the stale `client_id` at the
authorize or token endpoints.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
stale = seeded_client(provider, client_id="stale-as-client", issuer="https://old-as.example.com")
storage = InMemoryTokenStorage(client_info=stale)
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (
client,
_,
):
await client.list_tools()
# The client re-registered with the current AS...
assert path_counts(recorded)[("POST", "/register")] == 1
# ...and the stale client_id never reached the authorize or token endpoints.
authorize_and_token = find(recorded, "GET", "/authorize") + find(recorded, "POST", "/token")
assert all("stale-as-client" not in r.url.query.decode() for r in authorize_and_token)
assert all("stale-as-client" not in r.content.decode() for r in find(recorded, "POST", "/token"))
# The persisted client is now bound to the current AS.
assert storage.client_info is not None
assert storage.client_info.client_id != "stale-as-client"
assert storage.client_info.issuer == f"{BASE_URL}/"
@requirement("client-auth:401-after-auth-throws")
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.
The provider rejects every token at verification, so the full flow runs once and the retry
is 401'd. The auth-flow generator ends after that retry, so the 401 propagates and the
transport converts it to an INTERNAL_ERROR result, raising during connect. Discovery,
registration, authorize, and token each ran exactly once: no loop.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(reject_all_tokens=True)
server = Server("guarded", on_list_tools=list_tools)
def is_internal_error(error: MCPError) -> bool:
return error.error.code == INTERNAL_ERROR
with anyio.fail_after(5):
with pytest.RaisesGroup(pytest.RaisesExc(MCPError, check=is_internal_error), flatten_subgroups=True):
# Entering the connect raises during the OAuth handshake (inside `Client.__aenter__`),
# so an `async with` body would be unreachable; entering explicitly avoids dead code.
await connect_with_oauth(server, provider=provider, on_request=on_request).__aenter__()
counts = path_counts(recorded)
assert counts[("GET", PRM_PATH)] == 1
assert counts[("GET", ASM_PATH)] == 1
assert counts[("POST", "/register")] == 1
assert counts[("GET", "/authorize")] == 1
assert counts[("POST", "/token")] == 1
assert counts[("POST", "/mcp")] == 2
@requirement("client-auth:cimd")
async def test_cimd_is_selected_when_the_as_advertises_support_and_a_metadata_url_is_supplied() -> None:
"""A client-ID metadata-document URL is used as `client_id` instead of registering.
AS metadata is shimmed to advertise `client_id_metadata_document_supported: true`; the
provider is pre-seeded so the server's authorize and token handlers accept the URL as a
client_id (the SDK server has no CIMD-aware client lookup of its own). The recorded
requests prove no `/register` call, the authorize URL's `client_id` is the CIMD URL, the
token request uses `token_endpoint_auth_method=none`, and storage persists the URL as
`client_id`.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
seeded_client(provider, client_id=CIMD_URL)
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
client_metadata_url=CIMD_URL,
app_shim=shim(serve={ASM_PATH: cimd_supported_metadata()}),
on_request=on_request,
) as (client, headless):
await client.list_tools()
assert find(recorded, "POST", "/register") == []
assert headless.authorize_url is not None
assert authorize_params(headless.authorize_url)["client_id"] == CIMD_URL
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body["client_id"] == CIMD_URL
assert "client_secret" not in body
assert "authorization" not in token_req.headers
assert storage.client_info is not None
assert storage.client_info.client_id == CIMD_URL
assert storage.client_info.token_endpoint_auth_method == "none"
@requirement("client-auth:invalid-grant-clears-tokens")
async def test_a_failed_refresh_clears_stored_tokens_and_restarts_the_full_flow() -> None:
"""A non-200 refresh response clears the in-memory tokens and the flow re-runs from discovery.
The first token is reported expired so the next request refreshes; the provider denies the
refresh once with `invalid_grant`, the auth flow clears its tokens, the unauthenticated
request 401s, and discovery, authorize, and token run again. The original registration is
preserved (`client_info` is not cleared). The SDK clears tokens on any non-200 refresh
response, not specifically `error=invalid_grant`; `source="sdk"` so this is a precision
note rather than a divergence.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(issue_expired_first=True, fail_next_refresh=True)
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
result = await client.list_tools()
assert result.tools[0].name == "echo"
token_posts = find(recorded, "POST", "/token")
assert [form_body(r)["grant_type"] for r in token_posts] == snapshot(
["authorization_code", "refresh_token", "authorization_code"]
)
counts = path_counts(recorded)
assert counts[("POST", "/register")] == 1
assert counts[("GET", "/authorize")] == 2
assert counts[("GET", PRM_PATH)] == 2
assert counts[("GET", ASM_PATH)] == 2
assert storage.client_info is not None
assert storage.tokens is not None
assert storage.tokens.access_token in provider.access_tokens
@requirement("client-auth:client-credentials")
async def test_client_credentials_provider_obtains_a_token_without_an_authorize_step() -> None:
"""The client-credentials provider connects with no authorize step and a `client_credentials` grant.
The SDK server's `TokenHandler` does not route `client_credentials`, so the harness shim
handles it (the shim is harness; the SDK-under-test is the client provider). The recorded
`/token` body proves the grant type, scope, resource indicator, and HTTP-Basic client
authentication; no `/authorize` or `/register` request was made.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
auth = ClientCredentialsOAuthProvider(
server_url=f"{BASE_URL}/mcp",
storage=InMemoryTokenStorage(),
client_id="m2m-client",
client_secret="m2m-secret",
scopes="mcp",
)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
auth=auth,
app_shim=m2m_token_shim(provider, scopes=["mcp"]),
on_request=on_request,
) as (client, headless):
result = await client.list_tools()
assert result.tools[0].name == "echo"
assert headless.authorize_url is None
assert find(recorded, "GET", "/authorize") == []
assert find(recorded, "POST", "/register") == []
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body == snapshot(
{"grant_type": "client_credentials", "resource": "http://127.0.0.1:8000/mcp", "scope": "mcp"}
)
decoded = base64.b64decode(token_req.headers["authorization"].removeprefix("Basic ")).decode()
assert decoded == "m2m-client:m2m-secret"
@requirement("client-auth:private-key-jwt")
async def test_private_key_jwt_provider_authenticates_the_token_request_with_an_assertion() -> None:
"""The private-key-JWT provider sends a `client_assertion` on the token request, with the issuer as audience.
The assertion provider is a closure that records the audience it was called with and returns
a fixed opaque value (the JWT contents are not the SDK's concern here); the test asserts the
`client_assertion`/`client_assertion_type` form fields and that the audience matches the AS
metadata's issuer.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
audiences: list[str] = []
async def assertion_provider(audience: str) -> str:
audiences.append(audience)
return "header.payload.sig"
auth = PrivateKeyJWTOAuthProvider(
server_url=f"{BASE_URL}/mcp",
storage=InMemoryTokenStorage(),
client_id="m2m-jwt-client",
assertion_provider=assertion_provider,
scopes="mcp",
)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
auth=auth,
app_shim=m2m_token_shim(provider, scopes=["mcp"]),
on_request=on_request,
) as (client, _):
result = await client.list_tools()
assert result.tools[0].name == "echo"
assert audiences == [f"{BASE_URL}/"]
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body == snapshot(
{
"grant_type": "client_credentials",
"client_assertion": "header.payload.sig",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"resource": "http://127.0.0.1:8000/mcp",
"scope": "mcp",
}
)
assert "client_secret" not in body
assert "authorization" not in token_req.headers
@pytest.mark.parametrize(
("case", "preseed_storage", "advertise_cimd"),
[("cimd_unsupported_falls_through_to_dcr", False, False), ("preregistered_beats_cimd", True, True)],
ids=["cimd_unsupported_falls_through_to_dcr", "preregistered_beats_cimd"],
)
@requirement("client-auth:cimd")
async def test_registration_priority_prefers_preregistered_then_cimd_then_dcr(
case: str, preseed_storage: bool, advertise_cimd: bool
) -> None:
"""The client picks pre-registration over CIMD over DCR, falling through when each is unavailable.
Two priority edges are exercised: with a CIMD URL configured but no AS support, DCR runs and
the registered `client_id` is used; with a CIMD URL configured and AS support but a
pre-registered client in storage, the stored `client_id` is used and neither CIMD nor DCR
runs. (The positive CIMD case and pre-registration over DCR are covered by their own tests.)
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
storage = InMemoryTokenStorage()
expected_client_id: str
if preseed_storage:
info = seeded_client(provider)
storage.client_info = info
assert info.client_id is not None
expected_client_id = info.client_id
else:
expected_client_id = ""
app_shim = shim(serve={ASM_PATH: cimd_supported_metadata()}) if advertise_cimd else None
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
client_metadata_url=CIMD_URL,
app_shim=app_shim,
on_request=on_request,
) as (client, headless):
await client.list_tools()
assert headless.authorize_url is not None
chosen_client_id = authorize_params(headless.authorize_url)["client_id"]
assert chosen_client_id != CIMD_URL
if case == "cimd_unsupported_falls_through_to_dcr":
assert len(find(recorded, "POST", "/register")) == 1
assert chosen_client_id in provider.clients
else:
assert find(recorded, "POST", "/register") == []
assert chosen_client_id == expected_client_id
+48
View File
@@ -0,0 +1,48 @@
"""Shared fixtures for the interaction suite.
The ``connect`` fixture is parametrized per-test from the ``@requirement`` marks the test
carries: ``pytest_generate_tests`` looks up each cited requirement in the manifest and computes
the (transport, spec_version) cells via :func:`compute_cells`, applying arm exclusions, version
bounds, and known-failure xfails declaratively.
"""
from functools import partial
import pytest
from tests.interaction._connect import (
Connect,
connect_in_memory,
connect_over_sse,
connect_over_streamable_http,
connect_over_streamable_http_stateless,
)
from tests.interaction._requirements import REQUIREMENTS, compute_cells
_FACTORIES: dict[str, Connect] = {
"in-memory": connect_in_memory,
"streamable-http": connect_over_streamable_http,
"streamable-http-stateless": connect_over_streamable_http_stateless,
"sse": connect_over_sse,
}
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
"""Parametrize ``connect`` from the test's stacked ``@requirement`` marks."""
if "connect" not in metafunc.fixturenames:
return
requirements = [REQUIREMENTS[mark.args[0]] for mark in metafunc.definition.iter_markers("requirement")]
metafunc.parametrize("connect", compute_cells(requirements), indirect=True)
@pytest.fixture
def connect(request: pytest.FixtureRequest) -> Connect:
"""The transport-parametrized connection factory: a test using it runs once per matrix cell.
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, spec_version = request.param
assert isinstance(transport, str)
assert isinstance(spec_version, str)
return partial(_FACTORIES[transport], spec_version=spec_version)
@@ -0,0 +1,467 @@
"""Cancellation interactions against the low-level Server, driven through the public Client API.
Client-side, cancelling means abandoning: cancelling the task that awaits a call makes the SDK
carry the signal in the transport's own spelling (a cancelled frame on stream wires, closing the
request's own response stream at 2026-07-28 streamable HTTP). The receiving-side tests instead
script a CancelledNotification by hand, capturing the request id from inside the blocked handler.
Handlers block on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
REQUEST_TIMEOUT,
CallToolResult,
EmptyResult,
ErrorData,
Implementation,
InitializeResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListToolsResult,
PingRequest,
ServerCapabilities,
TextContent,
Tool,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
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")]))
@requirement("protocol:cancel:server-to-client")
async def test_abandoned_server_request_cancels_the_client_callback(connect: Connect) -> None:
"""A server that abandons a sampling request cancels it, interrupting the client's callback mid-await."""
callback_started = anyio.Event()
callback_cancelled = anyio.Event()
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
callback_started.set()
try:
await anyio.Event().wait() # blocks until the cancellation interrupts it
except anyio.get_cancelled_exc_class():
callback_cancelled.set()
raise
raise NotImplementedError # unreachable
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "impatient"
request = types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=8,
)
)
async with anyio.create_task_group() as abandon_scope:
async def sample() -> None:
await ctx.session.send_request(request, types.CreateMessageResult)
raise NotImplementedError # unreachable: the scope is cancelled
abandon_scope.start_soon(sample)
with anyio.fail_after(5):
await callback_started.wait()
abandon_scope.cancel_scope.cancel()
with anyio.fail_after(5):
await callback_cancelled.wait()
return CallToolResult(content=[TextContent(text="abandoned")])
server = Server("abandoner", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("impatient", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="abandoned")]))
assert callback_cancelled.is_set()
@requirement("protocol:cancel:late-response-ignored")
async def test_a_response_for_an_unknown_request_id_is_ignored() -> None:
"""A response whose id matches no in-flight request is ignored, as the spec asks.
The spec says a sender SHOULD ignore a response that arrives after it issued a cancellation;
that is the same client-side code path as any response with an unknown id, and that form is
deterministic to test without a client-side cancellation API.
"Ignored" is proved in two halves: the pong round-trip proves the read loop survived the
fabricated response (the ordered in-memory stream routed it first), and `surfaced` holding
only the control notification proves the fabricated response was never delivered to
`message_handler` (v1 surfaced it there as a RuntimeError).
A real Server cannot be made to answer with a fabricated id, so the test plays the server's
side of the wire by hand. Reserve this pattern for behaviour no real server can produce. The
other tests in this file run over the transport matrix; this one is in-memory only because the
scripted-peer mechanism is the in-memory stream pair, not because the behaviour is
transport-specific.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
def respond(request_id: types.RequestId, result: types.Result) -> SessionMessage:
return 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),
)
)
init = await server_read.receive()
assert isinstance(init, SessionMessage)
assert isinstance(init.message, JSONRPCRequest)
assert init.message.method == "initialize"
await server_write.send(
respond(
init.message.id,
InitializeResult(
protocol_version="2025-11-25",
capabilities=ServerCapabilities(),
server_info=Implementation(name="scripted", version="0.0.1"),
),
)
)
initialized = await server_read.receive()
assert isinstance(initialized, SessionMessage)
assert isinstance(initialized.message, JSONRPCNotification)
assert initialized.message.method == "notifications/initialized"
ping = await server_read.receive()
assert isinstance(ping, SessionMessage)
assert isinstance(ping.message, JSONRPCRequest)
assert ping.message.method == "ping"
# First a fabricated id that matches nothing in flight, then a control notification that
# is surfaced to message_handler (proving the handler is live), then the real id.
await server_write.send(respond(9999, EmptyResult()))
await server_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await server_write.send(respond(ping.message.id, EmptyResult()))
surfaced: list[IncomingMessage] = []
async def message_handler(message: IncomingMessage) -> None:
surfaced.append(message)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as task_group,
ClientSession(client_read, client_write, message_handler=message_handler) as session,
):
task_group.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
await session.initialize()
pong = await session.send_request(PingRequest(), EmptyResult)
assert pong == snapshot(EmptyResult())
# The stream is ordered, so the fabricated response was routed before the control
# notification: only the control surfaced, so the unknown-id response was dropped.
assert surfaced == snapshot([types.ToolListChangedNotification()])
@requirement("protocol:cancel:initialize-not-cancellable")
async def test_timed_out_initialize_sends_no_cancellation() -> None:
"""An abandoned initialize is not followed by notifications/cancelled on the wire (spec-mandated).
A real Server always answers initialize, so the test plays a stalling server by hand.
"""
received_methods: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
# Hold the initialize request unanswered until the client's read timeout fires.
init = await server_read.receive()
assert isinstance(init, SessionMessage)
assert isinstance(init.message, JSONRPCRequest)
received_methods.append(init.message.method)
follow_up = await server_read.receive()
assert isinstance(follow_up, SessionMessage)
assert isinstance(follow_up.message, JSONRPCRequest)
received_methods.append(follow_up.message.method)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=follow_up.message.id,
result=EmptyResult().model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as task_group,
# The session-level read timeout is the only public pathway that abandons initialize.
ClientSession(client_read, client_write, read_timeout_seconds=0.000001) as session,
):
task_group.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info:
await session.initialize()
assert exc_info.value.error.code == REQUEST_TIMEOUT
# Override the session-level timeout: this ping must round-trip normally.
pong = await session.send_request(PingRequest(), EmptyResult, request_read_timeout_seconds=5)
assert pong == snapshot(EmptyResult())
# The stream is ordered, so a courtesy cancel would have arrived ahead of the ping.
assert received_methods == snapshot(["initialize", "ping"])
@requirement("protocol:cancel:abort-signal")
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None:
"""Cancelling the task that awaits a call cancels the request itself, not just the local wait:
the server-side handler is interrupted, and the session serves later requests normally.
Spec-mandated (cancellation flow): the sender cancels requests it abandons; the wire spelling
is per-transport (frame on stream wires, response-stream close at 2026 streamable HTTP).
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "block":
handler_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
assert params.name == "echo"
return CallToolResult(content=[TextContent(text="ok")])
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")])
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
abandon = anyio.CancelScope()
async def call_and_abandon() -> None:
with abandon:
await client.call_tool("block", {})
raise NotImplementedError # unreachable: the call never resolves
assert abandon.cancelled_caught
async with anyio.create_task_group() as tg:
tg.start_soon(call_and_abandon)
with anyio.fail_after(5):
await handler_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await handler_cancelled.wait()
# Let the abandoned call's late error response (sent on the legacy arms) arrive and be
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("protocol:cancel:abort-scoped")
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None:
"""Cancellation is scoped to the request it names: with two calls genuinely in flight,
abandoning the first interrupts only its handler and the second returns its result.
Steps:
1. `doomed` and `survivor` are both mid-flight (each handler has started).
2. The client abandons `doomed`; its handler observes cancellation.
3. `survivor` is released and completes normally.
"""
doomed_started = anyio.Event()
doomed_cancelled = anyio.Event()
survivor_started = anyio.Event()
release_survivor = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "doomed":
doomed_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
doomed_cancelled.set()
raise
assert params.name == "survivor"
survivor_started.set()
with anyio.fail_after(5):
await release_survivor.wait()
return CallToolResult(content=[TextContent(text="survived")])
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("doomed", "survivor")]
)
server = Server("pair", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
abandon = anyio.CancelScope()
results: list[CallToolResult] = []
async def doomed_call() -> None:
with abandon:
await client.call_tool("doomed", {})
raise NotImplementedError # unreachable: the call never resolves
async def survivor_call() -> None:
results.append(await client.call_tool("survivor", {}))
async with anyio.create_task_group() as tg:
tg.start_soon(doomed_call)
with anyio.fail_after(5):
await doomed_started.wait()
tg.start_soon(survivor_call)
with anyio.fail_after(5):
await survivor_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await doomed_cancelled.wait()
release_survivor.set()
# Let the abandoned call's late error response (sent on the legacy arms) arrive and be
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])])
@@ -0,0 +1,370 @@
"""Client connect-time negotiation: mode selection, server/discover, and the per-request envelope.
These tests pin what `Client(..., mode=...)` puts on the wire BEFORE the caller's first call --
the legacy initialize handshake, the modern `server/discover` probe, or nothing at all -- and
that a modern-negotiated session stamps the three-key `io.modelcontextprotocol/*` `_meta`
envelope on every subsequent request. Each test drives the highest public surface (`Client`)
and observes traffic at a recording seam: `RecordingTransport` for the legacy stream pair, and
`mounted_app`'s httpx event hook for the in-process streamable-HTTP transport.
The fallback test alone hand-plays the server's side of the wire, because no real `Server`
answers `server/discover` with -32601.
"""
import json
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import anyio
import httpx
import mcp_types as types
import pytest
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
DiscoverResult,
Implementation,
InitializeResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ServerCapabilities,
ToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS
from mcp import MCPError
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import BASE_URL, Connect, mounted_app
from tests.interaction._helpers import RecordingTransport
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _tools_server(name: str = "negotiator") -> Server:
"""A low-level server with one list-tools handler, so a feature request has something to reach."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="noop", input_schema={"type": "object"})])
return Server(name, on_list_tools=list_tools)
def _request_recorder() -> tuple[list[httpx.Request], Callable[[httpx.Request], Awaitable[None]]]:
"""Return a list and an `on_request` hook that appends each outgoing httpx request to it."""
captured: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
captured.append(request)
return captured, on_request
@requirement("lifecycle:mode:legacy-never-probes")
async def test_legacy_mode_sends_initialize_and_never_probes_discover() -> None:
"""`Client(server, mode='legacy')` opens with `initialize` and never sends `server/discover`.
Requirement `lifecycle:mode:legacy-never-probes` (sdk-defined): ``mode='legacy'`` must remain
byte-identical to the pre-2026 client so a 2025-era server never observes modern vocabulary.
"""
recording = RecordingTransport(InMemoryTransport(_tools_server()))
with anyio.fail_after(5):
async with Client(recording, mode="legacy") as client:
await client.list_tools()
sent = [m.message for m in recording.sent]
methods = [m.method for m in sent if isinstance(m, JSONRPCRequest | JSONRPCNotification)]
assert methods[0] == "initialize"
assert "server/discover" not in methods
assert "notifications/initialized" in methods
@requirement("lifecycle:mode:pin-never-handshakes")
async def test_pinned_mode_sends_no_connect_time_traffic() -> None:
"""`Client(..., mode='2026-07-28')` sends nothing on entry; the caller's first call is the first wire request.
Requirement `lifecycle:mode:pin-never-handshakes` (sdk-defined): a version pin adopts a
synthesized DiscoverResult locally, so no `initialize` and no `server/discover` ever cross
the wire. Asserted at the in-process streamable-HTTP seam via the httpx event hook.
"""
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
):
assert requests == [] # entering the Client produced zero HTTP traffic
result = await client.list_tools()
bodies = [json.loads(r.content) for r in requests]
assert [b["method"] for b in bodies] == ["tools/list"]
assert PROTOCOL_VERSION_META_KEY in bodies[0]["params"]["_meta"]
assert [t.name for t in result.tools] == ["noop"]
@requirement("lifecycle:mode:prior-discover-zero-rtt")
async def test_prior_discover_populates_state_with_zero_connect_time_traffic() -> None:
"""`Client(..., mode=<pin>, prior_discover=...)` sends nothing on entry and exposes the prior server_info.
Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained
DiscoverResult is installed via `adopt()` so server_info and capabilities are available
immediately with zero round trips.
"""
prior = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)),
server_info=Implementation(name="cached-server", version="9.9.9"),
)
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=prior,
) as client,
):
assert requests == []
assert client.server_info == Implementation(name="cached-server", version="9.9.9")
assert client.server_capabilities.tools == ToolsCapability(list_changed=False)
await client.list_tools()
assert [json.loads(r.content)["method"] for r in requests] == ["tools/list"]
@requirement("lifecycle:discover:basic")
async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
"""`Client(..., mode='auto')` sends `server/discover` first and adopts the returned version and server_info.
Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a
single `server/discover` request whose result carries supported versions, capabilities,
server_info and the cache-hint fields, after which the session is modern-negotiated.
"""
requests, on_request = _request_recorder()
server = _tools_server("discoverable")
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.server_info.name == "discoverable"
await client.list_tools()
bodies = [json.loads(r.content) for r in requests]
assert bodies[0]["method"] == "server/discover"
assert "initialize" not in [b["method"] for b in bodies]
@requirement("lifecycle:discover:retry-on-32022")
async def test_auto_mode_retries_discover_once_on_unsupported_protocol_version() -> None:
"""A -32022 from `server/discover` triggers exactly one retry at the highest mutual modern version.
Requirement `lifecycle:discover:retry-on-32022` (spec basic/lifecycle#version-errors): the
client intersects `error.data.supported` with its own modern versions and re-probes once;
the second success is adopted. The server's `server/discover` handler is overridden to fail
the first call and succeed on the second.
"""
calls: list[str | None] = []
async def discover(ctx: ServerRequestContext, params: types.RequestParams | None) -> DiscoverResult:
proposed = ctx.meta.get(PROTOCOL_VERSION_META_KEY) if ctx.meta else None
calls.append(proposed)
if len(calls) == 1:
raise MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="unsupported protocol version",
data={"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": proposed},
)
return DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
server_info=Implementation(name="picky", version="1.0.0"),
)
server = _tools_server("picky")
server.add_request_handler("server/discover", types.RequestParams, discover)
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
assert calls == [LATEST_MODERN_VERSION, LATEST_MODERN_VERSION]
assert [json.loads(r.content)["method"] for r in requests][:2] == ["server/discover", "server/discover"]
@requirement("lifecycle:discover:network-error-raises")
async def test_auto_mode_propagates_a_network_error_from_discover_without_initializing() -> None:
"""A network/connection error during `server/discover` propagates to the caller without falling back.
Requirement `lifecycle:discover:network-error-raises` (sdk-defined): under the denylist policy
every server-sent rpc-error and every transport-layer 4xx falls back to `initialize()`; the
only probe failures that reach the caller are real outages — network errors, anyio resource
errors, and the disjoint-modern -32022 case. Exercised here as an `httpx.ConnectError` from
the underlying transport, which the policy must not classify as an era verdict. The error
reaches the test wrapped in the streamable-http transport's task-group teardown, so
`pytest.RaisesGroup` flattens before matching. The probe POST is recorded before the
transport raises, so the `initialize` fallback observably did not happen.
"""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
raise httpx.ConnectError("connection refused")
with anyio.fail_after(5):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
with pytest.RaisesGroup(httpx.ConnectError, flatten_subgroups=True): # pragma: no branch
async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"):
raise NotImplementedError("entering the Client should have raised") # pragma: no cover
assert [json.loads(r.content)["method"] for r in requests] == ["server/discover"]
@requirement("lifecycle:discover:fallback-method-not-found")
@pytest.mark.parametrize(
("probe_code", "probe_message"),
[
(METHOD_NOT_FOUND, "Method not found"),
(INVALID_REQUEST, "Bad Request: Missing session ID"),
],
ids=["method-not-found", "invalid-request"],
)
async def test_auto_mode_falls_back_to_initialize_on_a_legacy_probe_rejection(
probe_code: int, probe_message: str
) -> None:
"""A legacy server's rejection of `server/discover` makes an auto-negotiating client fall back to `initialize`.
Requirement `lifecycle:discover:fallback-method-not-found` (spec stdio#backward-compatibility):
a legacy-era server that does not implement `server/discover` is connected to via the
handshake, and the session lands at a handshake-era protocol version. The probe rejection
arrives as METHOD_NOT_FOUND from a server that routes the unknown method, or as
INVALID_REQUEST from a deployed v1.x stateful streamable-HTTP server that rejects the
session-id-less probe before dispatch. A real `Server` always implements `server/discover`,
so this test plays the server's side of the wire by hand. Reserve this pattern for behaviour
no real server can be made to produce.
"""
methods_seen: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
assert isinstance(frame, JSONRPCRequest | JSONRPCNotification)
methods_seen.append(frame.method)
if isinstance(frame, JSONRPCRequest) and frame.method == "server/discover":
error = types.ErrorData(code=probe_code, message=probe_message)
await server_write.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
elif isinstance(frame, JSONRPCRequest) and frame.method == "initialize":
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="legacy-only", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=frame.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# notifications/initialized (and anything else) is observed and ignored.
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
@requirement("lifecycle:envelope:stamped-on-every-request")
async def test_every_request_on_a_modern_session_carries_the_three_key_meta_envelope(connect: Connect) -> None:
"""Each modern-session request's `params._meta` carries protocolVersion, clientInfo and clientCapabilities.
Requirement `lifecycle:envelope:stamped-on-every-request` (spec basic#_meta): the per-request
envelope replaces the initialize handshake's once-per-session exchange. Asserted server-side
by capturing `ctx.meta` inside the handler.
"""
observed: list[dict[str, object]] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
assert ctx.meta is not None
observed.append(dict(ctx.meta))
return types.ListToolsResult(tools=[])
server = Server("stamped", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect(server, client_info=Implementation(name="enveloper", version="1.2.3")) as client:
await client.list_tools()
await client.list_tools()
assert len(observed) == 2
for meta in observed:
assert meta[PROTOCOL_VERSION_META_KEY] == LATEST_MODERN_VERSION
assert meta[CLIENT_INFO_META_KEY] == {"name": "enveloper", "version": "1.2.3"}
assert CLIENT_CAPABILITIES_META_KEY in meta
@requirement("lifecycle:envelope:header-matches-meta")
async def test_http_protocol_version_header_matches_meta_protocol_version_on_every_post() -> None:
"""On streamable-HTTP, the `MCP-Protocol-Version` header on each POST equals `_meta.protocolVersion` in its body.
Requirement `lifecycle:envelope:header-matches-meta` (spec streamable-http#headers): the
body-derived header and the envelope's protocol version are kept in lockstep so the server's
header-based routing and body-based validation never disagree.
"""
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
):
await client.list_tools()
await client.list_tools()
assert requests, "no HTTP traffic recorded"
for request in requests:
body = json.loads(request.content)
assert request.headers["mcp-protocol-version"] == body["params"]["_meta"][PROTOCOL_VERSION_META_KEY]
assert request.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION
@@ -0,0 +1,134 @@
"""Completion interactions against the low-level Server, driven through the public Client API."""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
METHOD_NOT_FOUND,
CompleteResult,
Completion,
ErrorData,
PromptReference,
ResourceTemplateReference,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
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:error:invalid-ref")
async def test_completion_against_an_unknown_ref_is_rejected_with_invalid_params(connect: Connect) -> None:
"""completion/complete with a ref naming an unknown prompt is answered with -32602 Invalid params.
The lowlevel server does not validate refs itself (it has no prompt/template registry to check
against); rejecting an unknown ref is the handler's job, and this test pins the spec-recommended
way to do it.
"""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, PromptReference)
raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {params.ref.name!r}")
server = Server("completer", on_completion=completion)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.complete(PromptReference(name="ghost"), argument={"name": "x", "value": ""})
assert exc_info.value.error.code == INVALID_PARAMS
@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.server_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", data="completion/complete")
)
@@ -0,0 +1,665 @@
"""Form- and URL-mode elicitation against the low-level Server, driven through the public Client API.
The final test plays the server's side of the wire by hand to issue an elicitation request with no
mode field, because the typed server API (`elicit_form`/`elicit_url`) always serializes one.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
ElicitCompleteNotification,
ElicitCompleteNotificationParams,
ElicitRequestedSchema,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
ErrorData,
Implementation,
InitializeResult,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ServerCapabilities,
TextContent,
)
from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
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")
@requirement("elicitation:capability:server-respects-mode")
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). The
request reaching the client also shows the server does not check the client's declared
elicitation capability before sending (see the divergence on `server-respects-mode`).
"""
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. The completion notification carries ``related_request_id`` so over
streamable HTTP it rides the tool call's own stream and reaches the client before the call
returns; the same ordering already holds on in-memory and SSE transports.
"""
elicitation_id = "auth-001"
elicited_ids: list[str | None] = []
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, related_request_id=ctx.request_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",
}
]
},
)
)
@requirement("elicitation:form:schema:primitives")
@requirement("elicitation:form:schema:enum-variants")
async def test_elicit_form_schema_with_every_primitive_and_enum_type_reaches_the_callback_as_sent(
connect: Connect,
) -> None:
"""A requested schema covering every spec-listed property kind is delivered to the callback unchanged.
One schema with one property per kind: a formatted string, an integer with bounds, a number,
a boolean, a plain enum, a oneOf-const titled enum, and a multi-select array-of-enum. The
callback observing the same schema as the handler sent proves both the primitive coverage and
the enum-variant coverage in one snapshot.
"""
schema: ElicitRequestedSchema = {
"type": "object",
"properties": {
"email": {"type": "string", "format": "email", "title": "Email", "description": "Contact address."},
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"score": {"type": "number"},
"subscribe": {"type": "boolean", "default": False},
"tier": {"type": "string", "enum": ["free", "pro", "team"]},
"region": {
"type": "string",
"oneOf": [
{"const": "eu", "title": "Europe"},
{"const": "na", "title": "North America"},
],
},
"channels": {"type": "array", "items": {"type": "string", "enum": ["email", "sms", "push"]}},
},
"required": ["email"],
}
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="onboard", description="Onboard the user.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "onboard"
answer = await ctx.session.elicit_form("Tell us about yourself.", schema)
return CallToolResult(content=[TextContent(text=answer.action)])
server = Server("onboarder", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[types.ElicitRequestParams] = []
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"email": "ada@example.com"})
async with connect(server, elicitation_callback=answer_form) as client:
await client.call_tool("onboard", {})
assert len(received) == 1
assert isinstance(received[0], ElicitRequestFormParams)
assert received[0].requested_schema == schema
@requirement("elicitation:form:schema:restricted-subset")
async def test_elicit_form_with_a_nested_schema_is_forwarded_unchanged(connect: Connect) -> None:
"""A requested schema with nested-object and array-of-object properties passes through unchanged.
The spec restricts form-mode requested schemas to flat objects with primitive-typed properties;
this test pins that the SDK does not enforce that restriction on either side (see the
divergence on the requirement). The inbound surface gate is deliberately relaxed here so older
servers that emit `anyOf` for `Optional` form fields still reach the elicitation callback.
"""
schema: ElicitRequestedSchema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
},
"contacts": {
"type": "array",
"items": {"type": "object", "properties": {"name": {"type": "string"}}},
},
},
}
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="profile", description="Collect a profile.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "profile"
answer = await ctx.session.elicit_form("Profile details.", schema)
return CallToolResult(content=[TextContent(text=answer.action)])
server = Server("profiler", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[types.ElicitRequestParams] = []
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="decline")
async with connect(server, elicitation_callback=answer_form) as client:
await client.call_tool("profile", {})
assert len(received) == 1
assert isinstance(received[0], ElicitRequestFormParams)
assert received[0].requested_schema == schema
@requirement("elicitation:form:response-validation")
async def test_accepted_elicitation_content_that_violates_the_schema_reaches_the_handler_unchanged(
connect: Connect,
) -> None:
"""Accepted form content that contradicts the requested schema is delivered to the handler unchanged.
The schema requires a string `name`; the callback answers with a wrong-type value and an extra
field. Nothing on either side validates the response against the schema (see the divergence on
the requirement), so the handler observes exactly what the callback sent.
"""
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 name.",
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
)
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:
return ElicitResult(action="accept", content={"name": 42, "extra": "field"})
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("signup", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="accept")], structured_content={"name": 42, "extra": "field"})
)
@requirement("elicitation:url:complete-unknown-ignored")
async def test_elicitation_complete_for_an_unknown_id_is_received_without_error(connect: Connect) -> None:
"""An elicitation/complete for an id the client never elicited is delivered and does not fail anything.
No URL elicitation precedes the notification; the client neither tracks elicitation ids nor
rejects unknown ones, so the call completes normally and the message handler observes the
notification as-is.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="noop", description="Send a stray complete.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "noop"
await ctx.session.send_elicit_complete("never-elicited", related_request_id=ctx.request_id)
return CallToolResult(content=[TextContent(text="ok")])
server = Server("notifier", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[IncomingMessage] = []
async def collect(message: IncomingMessage) -> None:
received.append(message)
async with connect(server, message_handler=collect) as client:
result = await client.call_tool("noop", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
assert received == snapshot(
[ElicitCompleteNotification(params=ElicitCompleteNotificationParams(elicitation_id="never-elicited"))]
)
@requirement("elicitation:form:mode-omitted-default")
async def test_a_mode_less_elicitation_request_is_treated_as_form_mode() -> None:
"""An elicitation/create request with no mode field reaches the client callback as form-mode.
The typed server API always serializes a mode (`elicit_form` writes 'form', `elicit_url` writes
'url'), so this test plays the server's side of the wire by hand to send a request body without
one. Reserve this pattern for behaviour the typed server API cannot produce.
"""
received: list[types.ElicitRequestParams] = []
answered = anyio.Event()
server_received: list[JSONRPCMessage] = []
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={})
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
initialize = await server_read.receive()
assert isinstance(initialize, SessionMessage)
request = initialize.message
assert isinstance(request, JSONRPCRequest)
assert request.method == "initialize"
result = InitializeResult(
protocol_version="2025-11-25",
capabilities=ServerCapabilities(),
server_info=Implementation(name="legacy", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
initialized = await server_read.receive()
assert isinstance(initialized, SessionMessage)
assert isinstance(initialized.message, JSONRPCNotification)
assert initialized.message.method == "notifications/initialized"
# No mode key: a server speaking a pre-mode revision of the spec sends only message + schema.
await server_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=2,
method="elicitation/create",
params={"message": "Legacy ask.", "requestedSchema": {"type": "object", "properties": {}}},
)
)
)
response = await server_read.receive()
assert isinstance(response, SessionMessage)
server_received.append(response.message)
answered.set()
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write, elicitation_callback=answer_form) as session,
):
tg.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
await session.initialize()
await answered.wait()
assert received == snapshot(
[
ElicitRequestFormParams(
_meta=None,
message="Legacy ask.",
requested_schema={"type": "object", "properties": {}},
)
]
)
assert isinstance(received[0], ElicitRequestFormParams)
assert received[0].mode == "form"
assert len(server_received) == 1
assert isinstance(server_received[0], JSONRPCResponse)
assert server_received[0].id == 2
+204
View File
@@ -0,0 +1,204 @@
"""Composed multi-feature flows against the low-level Server, driven through the public Client API.
Each test reads as the scenario it proves: the steps run top to bottom in the order a real client
would perform them, composing two or more feature areas (a tool call followed by a resource read;
a chain of elicitations inside one tool call; the full URL-elicitation-required retry loop). The
individual features are pinned by their own tests; these prove they compose.
"""
from collections.abc import Awaitable, Callable
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
URL_ELICITATION_REQUIRED,
CallToolResult,
ElicitCompleteNotification,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
EmptyResult,
ListToolsResult,
ReadResourceResult,
ResourceLink,
TextContent,
TextResourceContents,
Tool,
)
from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
ListToolsHandler = Callable[
[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[types.ListToolsResult]
]
def _list_tools(*names: str) -> ListToolsHandler:
"""A list_tools handler advertising the named tools, so call_tool's implicit list succeeds."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in names])
return list_tools
@requirement("flow:tool-result:resource-link-follow")
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None:
"""A tool returns a resource_link; reading that link's URI returns the referenced contents.
Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the
link's URI, (4) the read result carries the linked contents.
"""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "generate"
return CallToolResult(content=[ResourceLink(uri="file:///report.txt", name="report")])
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
assert str(params.uri) == "file:///report.txt"
return ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
server = Server(
"linker", on_list_tools=_list_tools("generate"), on_call_tool=call_tool, on_read_resource=read_resource
)
async with connect(server) as client:
called = await client.call_tool("generate", {})
link = called.content[0]
assert isinstance(link, ResourceLink)
read = await client.read_resource(link.uri)
assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]))
assert read == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
)
@requirement("flow:elicitation:multi-step-form")
async def test_a_tool_handler_chains_form_elicitations_feeding_each_answer_forward(connect: Connect) -> None:
"""Sequential form elicitations inside one tool call: each accepted answer feeds the next step.
Steps: (1) call the tool, (2) the handler issues a step-one form elicitation that the client
accepts with content, (3) the handler issues a step-two elicitation whose message references
the step-one answer, (4) the client accepts step two, (5) the tool result summarises both
answers. The callback is invoked exactly twice with the expected messages and schemas. The
short-circuit on decline is the application's choice (proven separately by the per-action
elicitation tests); what this flow pins is that the chain itself works end to end.
"""
received: list[ElicitRequestFormParams] = []
answers: list[dict[str, str | int | float | bool | list[str] | None]] = [{"name": "ada"}, {"age": 37}]
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "onboard"
first = await ctx.session.elicit_form(
"Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}
)
assert first.action == "accept" and first.content is not None
second = await ctx.session.elicit_form(
f"Step 2: confirm age for {first.content['name']}.",
{"type": "object", "properties": {"age": {"type": "integer"}}},
)
assert second.action == "accept" and second.content is not None
return CallToolResult(content=[TextContent(text=f"{first.content['name']} is {second.content['age']}")])
server = Server("onboarder", on_list_tools=_list_tools("onboard"), on_call_tool=call_tool)
async def answer(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
assert isinstance(params, ElicitRequestFormParams)
received.append(params)
return ElicitResult(action="accept", content=answers[len(received) - 1])
async with connect(server, elicitation_callback=answer) as client:
result = await client.call_tool("onboard", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ada is 37")]))
assert [(p.message, p.requested_schema) for p in received] == snapshot(
[
("Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}),
("Step 2: confirm age for ada.", {"type": "object", "properties": {"age": {"type": "integer"}}}),
]
)
@requirement("flow:elicitation:url-required-then-retry")
async def test_a_tool_rejected_with_url_elicitation_required_succeeds_on_retry_after_completion(
connect: Connect,
) -> None:
"""The full URL-elicitation-required retry loop: -32042, completion announced, retry succeeds.
Steps: (1) the first call is rejected with -32042 carrying the required URL elicitation in
its error data, (2) the client extracts the elicitation id from the error, (3) the server
announces completion via the elicitation/complete notification (driven via the captured
session, the same way a real out-of-band callback would reach a held session reference),
(4) the client observes the matching completion notification and retries, (5) the retry
succeeds. The handler distinguishes the two calls by a closure flag the test flips between
them; the test waits on the completion notification with an event so the retry only happens
after the announcement has arrived.
"""
elicitation_id = "auth-001"
authorised: list[bool] = [False]
captured: list[ServerSession] = []
completed = anyio.Event()
notifications: list[ElicitCompleteNotification] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "read_files"
captured.append(ctx.session)
if not authorised[0]:
# The log line gives the message handler a non-completion notification, so the test's
# filtering branch is exercised in both directions and the wait remains specific.
await ctx.session.send_log_message(level="warning", data="authorisation required", logger="gate") # pyright: ignore[reportDeprecated]
raise UrlElicitationRequiredError(
[
ElicitRequestURLParams(
message="Authorize file access.",
url="https://example.com/oauth/authorize",
elicitation_id=elicitation_id,
)
]
)
return CallToolResult(content=[TextContent(text="contents")])
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"gatekeeper",
on_list_tools=_list_tools("read_files"),
on_call_tool=call_tool,
on_set_logging_level=set_logging_level,
)
async def collect(message: IncomingMessage) -> None:
if isinstance(message, ElicitCompleteNotification):
notifications.append(message)
completed.set()
async with connect(server, message_handler=collect) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("read_files", {})
assert exc_info.value.error.code == URL_ELICITATION_REQUIRED
required = UrlElicitationRequiredError.from_error(exc_info.value.error)
assert [e.elicitation_id for e in required.elicitations] == [elicitation_id]
# The out-of-band interaction completes; the server announces it on the same session.
await captured[0].send_elicit_complete(elicitation_id)
with anyio.fail_after(5):
await completed.wait()
assert notifications[0].params.elicitation_id == elicitation_id
authorised[0] = True
result = await client.call_tool("read_files", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="contents")]))
@@ -0,0 +1,385 @@
"""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 mcp_types as types
import pytest
from inline_snapshot import snapshot
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 mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client._memory import InMemoryTransport
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
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.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.instructions == snapshot("Call the add tool.")
async with connect(Server("unguided")) as client:
assert client.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( # pyright: ignore[reportDeprecated]
"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.server_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.server_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),
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),
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),
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 def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
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 (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write) as session,
):
tg.start_soon(scripted_server, server_streams)
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")
@requirement("lifecycle:version:downgrade")
async def test_an_older_supported_protocol_version_from_the_server_is_accepted() -> None:
"""An initialize response carrying an older supported protocol version completes the handshake at that version.
A real Server answers with the version the client requested (or its own latest), so this test
plays the server's side of the wire by hand to return a fixed older version regardless of what
was requested. Reserve this pattern for behaviour no real server can be made to produce.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
message = await server_read.receive()
assert isinstance(message, SessionMessage)
request = message.message
assert isinstance(request, JSONRPCRequest)
assert request.method == "initialize"
result = InitializeResult(
protocol_version="2025-06-18",
capabilities=ServerCapabilities(),
server_info=Implementation(name="conservative", 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 (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write) as session,
):
tg.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
initialize_result = await session.initialize()
assert initialize_result.protocol_version == snapshot("2025-06-18")
@@ -0,0 +1,136 @@
"""List-changed notifications from the low-level Server, driven through the public Client API.
``send_*_list_changed`` does not take a ``related_request_id``, so over streamable HTTP the
notification routes to the standalone GET stream and is not guaranteed to arrive before the tool
result on its POST stream. Tests therefore wait on an event the collector sets, the same pattern
as ``transports/test_streamable_http.py::test_unrelated_server_messages_arrive_on_the_standalone_stream``.
The collector still records every message it receives, so the snapshot also proves nothing else
was delivered.
The servers register the parent capability (resources/prompts) so that part of the spec's
precondition holds, but the ``listChanged`` sub-capability stays ``False``: ``NotificationOptions``
is not threaded through any of the suite's connection paths. The tests therefore rely on the
recorded ``lifecycle:capability:server-not-advertised`` divergence and will need updating
alongside the fix that introduces capability gating.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
PromptListChangedNotification,
ResourceListChangedNotification,
TextContent,
ToolListChangedNotification,
)
from mcp.server import Server, ServerRequestContext
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] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
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", {})
with anyio.fail_after(5):
await seen.wait()
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] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
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")])
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
"""Registered so the resources capability is advertised; the client never lists resources."""
raise NotImplementedError
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool, on_list_resources=list_resources)
async with connect(server, message_handler=collect) as client:
await client.call_tool("mount", {})
with anyio.fail_after(5):
await seen.wait()
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] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
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")])
async def list_prompts(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
"""Registered so the prompts capability is advertised; the client never lists prompts."""
raise NotImplementedError
server = Server("registry", on_list_tools=list_tools, on_call_tool=call_tool, on_list_prompts=list_prompts)
async with connect(server, message_handler=collect) as client:
await client.call_tool("learn", {})
with anyio.fail_after(5):
await seen.wait()
assert received == snapshot([PromptListChangedNotification()])
+127
View File
@@ -0,0 +1,127 @@
"""Logging interactions against the low-level Server, driven through the public Client API.
Notification ordering: await-free callbacks finish in arrival order, and passing
``related_request_id`` keeps each notification on the originating request's POST stream over
streamable HTTP, so plain-list collection is deterministic on every transport leg.
"""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
from mcp.server import Server, ServerRequestContext
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) # pyright: ignore[reportDeprecated]
async with connect(server) as client:
result = await client.set_logging_level("warning") # pyright: ignore[reportDeprecated]
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( # pyright: ignore[reportDeprecated]
level="info", data="starting up", logger="app.lifecycle", related_request_id=ctx.request_id
)
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
level="error", data={"code": 502, "retryable": True}, related_request_id=ctx.request_id
)
return CallToolResult(content=[TextContent(text="done")])
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
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( # pyright: ignore[reportDeprecated]
level=level, data=f"a {level} message", related_request_id=ctx.request_id
)
return CallToolResult(content=[TextContent(text="logged")])
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
async with connect(server, logging_callback=collect) as client:
await client.call_tool("siren", {})
assert [params.level for params in received] == list(ALL_LEVELS)
+63
View File
@@ -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 mcp_types as types
import pytest
from mcp_types import CallToolResult, RequestParamsMeta, TextContent
from mcp.server import Server, ServerRequestContext
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)
@@ -0,0 +1,243 @@
"""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 mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
Prompt,
Resource,
ResourceTemplate,
Tool,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
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.
"""
cursor = "page-2"
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=cursor,
)
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=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [tool.name for tool in first_page.tools] == ["alpha"]
assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]))
@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("pagination:client:cursor-handling")
async def test_the_client_follows_opaque_cursors_through_pages_of_varying_sizes(connect: Connect) -> None:
"""The client passes a server-issued cursor back byte-for-byte and follows pages of varying sizes.
The cursors are deliberately base64-looking strings (with padding and URL-unsafe characters) to
show the client treats them as opaque tokens; the page sizes [3, 1, 2] show the loop relies only
on next_cursor, not on a fixed page size.
"""
cursor_to_page_2 = "YWxwaGE+YnJhdm8/Y2hhcmxpZQ=="
cursor_to_page_3 = "ZGVsdGE="
pages: dict[str | None, tuple[list[str], str | None]] = {
None: (["alpha", "beta", "gamma"], cursor_to_page_2),
cursor_to_page_2: (["delta"], cursor_to_page_3),
cursor_to_page_3: (["epsilon", "zeta"], None),
}
received_cursors: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None
received_cursors.append(params.cursor)
names, next_cursor = pages[params.cursor]
return ListToolsResult(
tools=[Tool(name=name, input_schema={"type": "object"}) for name in names], next_cursor=next_cursor
)
server = Server("paginated", on_list_tools=list_tools)
page_sizes: list[int] = []
cursor: str | None = None
async with connect(server) as client:
while True:
result = await client.list_tools(cursor=cursor)
page_sizes.append(len(result.tools))
if result.next_cursor is None:
break
cursor = result.next_cursor
# Identity, not a snapshot: what arrived at the handler is exactly what the handler issued.
assert received_cursors == [None, cursor_to_page_2, cursor_to_page_3]
assert page_sizes == [3, 1, 2]
@requirement("pagination:invalid-cursor")
async def test_an_unrecognized_pagination_cursor_is_rejected_with_invalid_params(connect: Connect) -> None:
"""A list request with a cursor the server did not issue is answered with -32602 Invalid params.
The lowlevel server does not validate cursors itself (they are opaque to it); rejecting an
unrecognized cursor is the handler's job, and this test pins the spec-recommended way to do it.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None
assert params.cursor == "never-issued"
raise MCPError(code=INVALID_PARAMS, message=f"Unknown cursor: {params.cursor!r}")
server = Server("paginated", on_list_tools=list_tools)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.list_tools(cursor="never-issued")
assert exc_info.value.error.code == INVALID_PARAMS
@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."""
cursor = "page-2"
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=cursor)
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=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [resource.name for resource in first_page.resources] == ["first"]
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."""
cursor = "page-2"
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=cursor,
)
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=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [template.name for template in first_page.resource_templates] == ["first"]
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."""
cursor = "page-2"
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=cursor)
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=first_page.next_cursor)
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [prompt.name for prompt in first_page.prompts] == ["first"]
assert [prompt.name for prompt in second_page.prompts] == ["second"]
assert second_page.next_cursor is None
+53
View File
@@ -0,0 +1,53 @@
"""Ping interactions against the low-level Server, driven through the public Client API."""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, EmptyResult, TextContent
from mcp.server import Server, ServerRequestContext
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() # pyright: ignore[reportDeprecated]
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")]))
+279
View File
@@ -0,0 +1,279 @@
"""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) -- on the in-memory transport unconditionally, and
over streamable HTTP only when sent with ``related_request_id`` so the notification rides the
originating request's POST stream rather than the standalone GET stream. These tests pass
``related_request_id`` 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 mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ProgressNotification, ProgressNotificationParams, ProgressToken, TextContent
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from mcp.shared.session import ProgressFnT
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
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"
await ctx.session.report_progress(1.0, total=3.0, message="first chunk")
await ctx.session.report_progress(2.0, total=3.0, message="second chunk")
await ctx.session.report_progress(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 1).
assert result == snapshot(CallToolResult(content=[TextContent(text="2")]))
@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) # pyright: ignore[reportDeprecated]
async with connect(server) as client:
await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway") # pyright: ignore[reportDeprecated]
with anyio.fail_after(5):
await delivered.wait()
assert received == snapshot(
[ProgressNotificationParams(progress_token="upload-1", progress=0.5, total=1.0, message="halfway")]
)
@requirement("protocol:progress:token-unique")
async def test_concurrent_requests_carry_distinct_progress_tokens(connect: Connect) -> None:
"""Two concurrent requests carry distinct progress tokens, and each callback sees only its own progress.
Without the barrier the first call could run to completion before the second starts, so only one
token would be live at a time and the demultiplexing would never be exercised. The handlers each
block until both have started and then hand control back and forth so the four progress
notifications are emitted in strict a, b, a, b order on the wire. The two handlers send different
progress values so a stream swap (request A's progress delivered to callback B and vice versa)
would fail: each callback receiving exactly its own values proves notifications are routed
per-request, not by arrival order or by chance.
"""
progress_values = {"a": (1.0, 2.0), "b": (10.0, 20.0)}
entered = {"a": anyio.Event(), "b": anyio.Event()}
# turns[n] is set to release the nth emission; each emission releases the next.
turns = [anyio.Event() for _ in range(4)]
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="report", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "report"
assert params.arguments is not None
label = params.arguments["label"]
entered[label].set()
# The two handlers interleave by waiting on alternating turns: a takes 0 and 2, b takes 1 and 3.
first, second = (0, 2) if label == "a" else (1, 3)
await turns[first].wait()
await ctx.session.report_progress(progress_values[label][0])
turns[first + 1].set()
await turns[second].wait()
await ctx.session.report_progress(progress_values[label][1])
if second + 1 < len(turns):
turns[second + 1].set()
return CallToolResult(content=[TextContent(text="done")])
server = Server("reporter", on_list_tools=list_tools, on_call_tool=call_tool)
received_a: list[float] = []
received_b: list[float] = []
async def collect_a(progress: float, total: float | None, message: str | None) -> None:
received_a.append(progress)
async def collect_b(progress: float, total: float | None, message: str | None) -> None:
received_b.append(progress)
async with connect(server) as client:
async def call(label: str, collect: ProgressFnT) -> None:
await client.call_tool("report", {"label": label}, progress_callback=collect)
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
task_group.start_soon(call, "a", collect_a)
task_group.start_soon(call, "b", collect_b)
await entered["a"].wait()
await entered["b"].wait()
turns[0].set()
assert received_a == [1.0, 2.0]
assert received_b == [10.0, 20.0]
@requirement("protocol:progress:stops-after-completion")
@requirement("protocol:progress:late-dropped-by-client")
async def test_progress_sent_after_the_response_is_not_delivered_to_the_callback(connect: Connect) -> None:
"""A progress notification sent after the response is emitted, and the client drops it from the callback.
This single body proves both halves: the server's `send_progress_notification` happily sends for
a token whose request has already completed (the spec MUST that progress stops is not enforced;
see the divergence on `stops-after-completion`), and the client, having removed the callback when
the call returned, does not deliver the late notification to it. The message handler observes the
late notification arriving so the test knows when to assert without polling.
"""
captured: list[tuple[ServerSession, ProgressToken]] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="report", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "report"
assert ctx.meta is not None
token = ctx.meta.get("progress_token")
assert token is not None
captured.append((ctx.session, token))
await ctx.session.send_progress_notification(token, 0.5, related_request_id=str(ctx.request_id))
return CallToolResult(content=[TextContent(text="done")])
server = Server("reporter", on_list_tools=list_tools, on_call_tool=call_tool)
received: list[float] = []
late_progress_arrived = anyio.Event()
async def collect(progress: float, total: float | None, message: str | None) -> None:
received.append(progress)
async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, ProgressNotification) and message.params.progress == 1.0:
late_progress_arrived.set()
async with connect(server, message_handler=message_handler) as client:
with anyio.fail_after(5):
await client.call_tool("report", {}, progress_callback=collect)
assert received == [0.5]
server_session, token = captured[0]
await server_session.send_progress_notification(token, 1.0)
await late_progress_arrived.wait()
assert received == [0.5]
@requirement("protocol:progress:monotonic")
async def test_non_increasing_progress_values_are_forwarded_unchanged(connect: Connect) -> None:
"""A handler that emits non-increasing progress values has them forwarded to the callback unchanged.
The spec says progress MUST increase with each notification; the SDK does not enforce that on
either side. See the divergence note on the requirement.
"""
received: list[float] = []
async def collect(progress: float, total: float | None, message: str | None) -> None:
received.append(progress)
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="zigzag", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "zigzag"
await ctx.session.report_progress(0.5)
await ctx.session.report_progress(0.3)
await ctx.session.report_progress(0.9)
return CallToolResult(content=[TextContent(text="done")])
server = Server("zigzagger", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.call_tool("zigzag", {}, progress_callback=collect)
assert received == snapshot([0.5, 0.3, 0.9])
+210
View File
@@ -0,0 +1,210 @@
"""Prompt interactions against the low-level Server, driven through the public Client API."""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
AudioContent,
EmbeddedResource,
ErrorData,
GetPromptResult,
Icon,
ImageContent,
ListPromptsResult,
Prompt,
PromptArgument,
PromptMessage,
TextContent,
TextResourceContents,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
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:no-args")
async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect) -> None:
"""A prompt fetched with no arguments delivers None as the handler's arguments and returns its messages."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
assert params.name == "static"
assert params.arguments is None
return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))])
server = Server("prompter", on_get_prompt=get_prompt)
async with connect(server) as client:
result = await client.get_prompt("static")
assert result == snapshot(
GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))])
)
@requirement("prompts:get:content:image")
@requirement("prompts:get:content:audio")
@requirement("prompts:get:content:embedded-resource")
async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) -> None:
"""Prompt messages can carry image, audio, and embedded-resource content; all reach the client.
A single full-result snapshot proves all three content types round-trip: each block in the result
is one of the three behaviours under test. Tiny fixed base64 payloads ("aW1n" is b"img", "YXVk"
is b"aud") so the snapshot pins the exact bytes.
"""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
assert params.name == "media"
return GetPromptResult(
messages=[
PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")),
PromptMessage(role="assistant", content=AudioContent(data="YXVk", mime_type="audio/wav")),
PromptMessage(
role="user",
content=EmbeddedResource(
resource=TextResourceContents(uri="resource://notes/1", mime_type="text/plain", text="attached")
),
),
]
)
server = Server("prompter", on_get_prompt=get_prompt)
async with connect(server) as client:
result = await client.get_prompt("media", {})
assert result == snapshot(
GetPromptResult(
messages=[
PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")),
PromptMessage(role="assistant", content=AudioContent(data="YXVk", mime_type="audio/wav")),
PromptMessage(
role="user",
content=EmbeddedResource(
resource=TextResourceContents(uri="resource://notes/1", mime_type="text/plain", text="attached")
),
),
]
)
)
@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"))
@@ -0,0 +1,317 @@
"""Resource interactions against the low-level Server, driven through the public Client API."""
import base64
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
METHOD_NOT_FOUND,
Annotations,
BlobResourceContents,
CallToolResult,
EmptyResult,
ErrorData,
Icon,
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
Resource,
ResourceTemplate,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
TextContent,
TextResourceContents,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
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")
@requirement("resources:annotations")
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.
The fully-populated entry includes annotations, so the snapshot also proves they round-trip.
The SDK's Annotations model omits the schema's lastModified field (see the divergence on
resources:annotations); the input is built via model_validate with lastModified set so the
snapshot pins the drop and will fail once the SDK adds the field.
"""
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.model_validate(
{"audience": ["user", "assistant"], "priority": 0.8, "lastModified": "2025-01-01T00:00:00Z"}
),
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, last_modified="2025-01-01T00:00:00Z"
),
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"])],
),
]
)
)
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@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") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@requirement("resources:subscribe:capability-required")
async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None:
"""Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND.
The rejection comes from no handler being registered, not from any capability check; see the
divergence on lifecycle:capability:server-not-advertised.
"""
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
"""Registered only so the resources capability is advertised; never called."""
raise NotImplementedError
server = Server("library", on_list_resources=list_resources)
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe")
)
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@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") # pyright: ignore[reportDeprecated]
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.
``send_resource_updated`` does not take a ``related_request_id``, so over streamable HTTP the
notification routes to the standalone GET stream and is not guaranteed to arrive before the
tool result; the test waits on an event the collector sets. The collector records every
message the handler receives, so the assertion also proves nothing else was delivered.
"""
received: list[IncomingMessage] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
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")])
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
"""Registered so the resources capability is advertised; the client never lists resources."""
raise NotImplementedError
async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult:
"""Registered so the resources subscribe sub-capability is advertised; the client never subscribes."""
raise NotImplementedError
server = Server(
"library",
on_list_tools=list_tools,
on_call_tool=call_tool,
on_list_resources=list_resources,
on_subscribe_resource=subscribe_resource,
)
async with connect(server, message_handler=collect) as client:
await client.call_tool("touch", {})
with anyio.fail_after(5):
await seen.wait()
assert received == snapshot(
[ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
)
+167
View File
@@ -0,0 +1,167 @@
"""Roots interactions against the low-level Server, driven through the public Client API."""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, CallToolResult, ErrorData, ListRootsResult, Root, TextContent
from pydantic import FileUrl
from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
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() # pyright: ignore[reportDeprecated]
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() # pyright: ignore[reportDeprecated]
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() # pyright: ignore[reportDeprecated]
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() # pyright: ignore[reportDeprecated]
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) # pyright: ignore[reportDeprecated]
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
"""Registered so the client declares the roots capability; the server never asks for roots."""
raise NotImplementedError
async with connect(server, list_roots_callback=list_roots) as client:
await client.send_roots_list_changed() # pyright: ignore[reportDeprecated]
with anyio.fail_after(5):
await delivered.wait()
assert received == snapshot([types.NotificationParams()])
+688
View File
@@ -0,0 +1,688 @@
"""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 mcp_types as types
import pydantic
import pytest
from inline_snapshot import snapshot
from mcp_types import (
AudioContent,
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
ErrorData,
ImageContent,
ModelHint,
ModelPreferences,
SamplingCapability,
SamplingMessage,
TextContent,
ToolResultContent,
ToolUseContent,
)
from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
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( # pyright: ignore[reportDeprecated]
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")
@requirement("sampling:context:server-gated-by-capability")
async def test_create_message_params_reach_callback(connect: Connect) -> None:
"""Every sampling parameter the handler supplies arrives at the client callback unchanged.
The client has not declared the sampling.context capability (Client cannot declare it), yet
include_context="thisServer" reaches the callback regardless: the spec's SHOULD NOT is not
enforced. See the divergence note on `sampling:context:server-gated-by-capability`.
"""
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( # pyright: ignore[reportDeprecated]
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( # pyright: ignore[reportDeprecated]
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( # pyright: ignore[reportDeprecated]
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( # pyright: ignore[reportDeprecated]
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( # pyright: ignore[reportDeprecated]
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( # pyright: ignore[reportDeprecated]
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_mixed_tool_result_content_is_rejected(connect: Connect) -> None:
"""A sampling request whose user message mixes tool_result 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( # pyright: ignore[reportDeprecated]
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")
]
)
)
@requirement("sampling:capability:declare")
async def test_a_client_with_a_sampling_callback_declares_the_sampling_capability(connect: Connect) -> None:
"""A client connecting with a sampling callback advertises the sampling capability to the server.
Client cannot declare any sub-capabilities (it does not expose ClientSession's
sampling_capabilities parameter), so the snapshot pins an empty SamplingCapability.
"""
captured: list[SamplingCapability | None] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="capabilities", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "capabilities"
assert ctx.session.client_params is not None
captured.append(ctx.session.client_params.capabilities.sampling)
return CallToolResult(content=[TextContent(text="ok")])
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
"""Registered only so the sampling capability is advertised; never called."""
raise NotImplementedError
async with connect(server, sampling_callback=sampling_callback) as client:
await client.call_tool("capabilities", {})
assert captured == snapshot([SamplingCapability()])
@requirement("sampling:create-message:audio-content")
async def test_create_message_request_with_audio_content_reaches_callback(connect: Connect) -> None:
"""A sampling request message carrying audio content arrives at the client callback intact.
This is the server-to-client direction: the server includes audio 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="transcribe", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "transcribe"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=AudioContent(data="c25k", mime_type="audio/wav"))],
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)
audio = params.messages[0].content
assert isinstance(audio, AudioContent)
return CreateMessageResult(
role="assistant",
content=TextContent(text=f"transcribed {audio.mime_type} ({audio.data})"),
model="mock-audio-1",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("transcribe", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="transcribed audio/wav (c25k)")]))
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=AudioContent(data="c25k", mime_type="audio/wav"))],
max_tokens=100,
)
]
)
@requirement("sampling:create-message:audio-content")
async def test_create_message_result_with_audio_content_returns_to_handler(connect: Connect) -> None:
"""A sampling result whose content is audio is returned to the requesting handler intact.
This is the client-to-server direction: the model's response is audio rather than text.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="speak", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "speak"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello, aloud."))],
max_tokens=100,
)
audio = result.content
assert isinstance(audio, AudioContent)
return CallToolResult(content=[TextContent(text=f"{result.model}: {audio.mime_type} {audio.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=AudioContent(data="aGVsbG8=", mime_type="audio/wav"),
model="mock-audio-1",
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("speak", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="mock-audio-1: audio/wav aGVsbG8=")]))
@requirement("sampling:message:content-cardinality")
async def test_create_message_with_list_valued_message_content_reaches_callback(connect: Connect) -> None:
"""A sampling message whose content is a list of blocks arrives at the client callback as a list."""
received: list[CreateMessageRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="caption", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "caption"
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[
SamplingMessage(
role="user",
content=[
TextContent(text="Caption this image."),
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)
content = params.messages[0].content
assert isinstance(content, list)
return CreateMessageResult(
role="assistant", content=TextContent(text=f"{len(content)} blocks"), model="mock-llm-1"
)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("caption", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="2 blocks")]))
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[
SamplingMessage(
role="user",
content=[
TextContent(text="Caption this image."),
ImageContent(data="aW1n", mime_type="image/png"),
],
)
],
max_tokens=100,
)
]
)
@requirement("sampling:tool-use:server-preflight")
async def test_create_message_with_mismatched_tool_use_and_result_ids_is_rejected(connect: Connect) -> None:
"""A sampling request whose tool_result ids do not match the preceding tool_use ids never leaves the server.
The message-structure validation runs inside create_message before the request is sent, so the
client callback is never invoked and the handler observes the ValueError directly. The spec's
client-side -32602 check is tracked separately at sampling:tool-use:result-balance.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="continue_tools", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "continue_tools"
try:
await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[
SamplingMessage(
role="assistant",
content=[ToolUseContent(id="call-1", name="weather", input={})],
),
SamplingMessage(
role="user",
content=[ToolResultContent(tool_use_id="call-WRONG", content=[TextContent(text="42")])],
),
],
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("continue_tools", {})
assert result == snapshot(
CallToolResult(
content=[
TextContent(
text="ValueError: ids of tool_result blocks and tool_use blocks from previous message do not match"
)
]
)
)
@requirement("sampling:result:no-tools-single-content")
async def test_array_content_result_for_a_tool_free_request_surfaces_as_a_validation_error(connect: Connect) -> None:
"""An array-content sampling result for a tool-free request is accepted by the client and fails server-side.
Only the exception type is asserted: the message is pydantic's, which changes across releases.
See the divergence note on the requirement: the intended behaviour is that the client rejects
the result; instead the client accepts it and the server's response parsing raises.
"""
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( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text="Two thoughts, please."))],
max_tokens=100,
)
except pydantic.ValidationError as exc:
return CallToolResult(content=[TextContent(text=type(exc).__name__)])
raise NotImplementedError # the array-content result fails server-side parsing every time
server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool)
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResultWithTools:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(text="First thought."), TextContent(text="Second thought.")],
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="ValidationError")]))
@@ -0,0 +1,142 @@
"""Client.listen stream endings against lowlevel servers over the connect matrix."""
from typing import Any
import anyio
import mcp_types as types
import pytest
from mcp import MCPError
from mcp.client.subscriptions import SubscriptionLost, ToolsListChanged
from mcp.server import Server, ServerRequestContext
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("subscriptions:listen:client:graceful-close")
async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None:
"""`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
server = Server("subs", on_subscriptions_listen=handler)
events: list[object] = []
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
handler.close()
events.extend([event async for event in sub])
assert events == [ToolsListChanged()]
@requirement("subscriptions:listen:client:lost")
async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None:
"""Erroring the listen request after the ack (abrupt, not graceful) raises SubscriptionLost from iteration."""
proceed = anyio.Event()
async def dropping_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(
notifications=params.notifications,
_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id},
)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
raise MCPError(types.INTERNAL_ERROR, "stream torn down")
server = Server("subs", on_subscriptions_listen=dropping_listen)
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost): # pragma: no branch
await anext(sub)
@requirement("protocol:request-id:caller-supplied")
async def test_the_subscription_id_is_the_listen_request_id_the_server_saw(connect: Connect) -> None:
"""The handle's `subscription_id` is the listen request's own JSON-RPC id, known to the caller
while the request is still in flight - the key the server stamps every frame with for demux.
The assertion runs inside the open stream: the ack has arrived but the listen request's
response has not, so the id cannot have come from a response.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
seen: list[types.RequestId] = []
async def recording_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
seen.append(ctx.request_id)
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=recording_listen)
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert seen == [sub.subscription_id]
stock.close()
async for _event in sub:
raise NotImplementedError # unreachable: nothing was published
@requirement("subscriptions:listen:client:concurrent-demux")
@requirement("protocol:request-id:caller-supplied")
async def test_concurrent_listen_streams_each_receive_their_own_ack(connect: Connect) -> None:
"""Two subscriptions opened concurrently each surface the honored filter of their own request:
ack frames route by subscription id, not broadcast to every open route.
The server gates both acks until both listen requests have arrived, so both client routes are
live and unacknowledged when the first ack lands - a client that broadcast subscription frames
would cross-pollute that ack into both handles.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
arrived: list[types.RequestId] = []
both_arrived = anyio.Event()
async def gated_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
arrived.append(ctx.request_id)
if len(arrived) == 2:
both_arrived.set()
with anyio.fail_after(10):
await both_arrived.wait()
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=gated_listen)
honored: dict[str, types.SubscriptionFilter] = {}
async with connect(server) as client:
async def open_tools() -> None:
async with client.listen(tools_list_changed=True) as sub:
honored["tools"] = sub.honored
async def open_prompts() -> None:
async with client.listen(prompts_list_changed=True) as sub:
honored["prompts"] = sub.honored
with anyio.fail_after(10):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(open_tools)
tg.start_soon(open_prompts)
assert honored == {
"tools": types.SubscriptionFilter(tools_list_changed=True),
"prompts": types.SubscriptionFilter(prompts_list_changed=True),
}
assert len(set(arrived)) == 2
+197
View File
@@ -0,0 +1,197 @@
"""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. Per-request timeouts are
set to an effectively-zero duration; the session-level test runs on trio's virtual clock instead
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero would
also time out immediately, but a tiny positive value keeps the duration visible in the
cancellation reason these tests snapshot.)
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import REQUEST_TIMEOUT, CallToolResult, ErrorData, JSONRPCNotification, TextContent
from trio.testing import MockClock
from mcp import MCPError
from mcp.client import ClientRequestContext
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 tests.interaction._helpers import RecordingTransport
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
@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.
The timeout is followed by notifications/cancelled, which interrupts the server's handler.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
try:
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable
server = Server("blocker", on_call_tool=call_tool)
async with Client(server, mode="legacy") 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 started and was then cancelled.
with anyio.fail_after(5):
await handler_started.wait()
await handler_cancelled.wait()
assert exc_info.value.error == snapshot(
ErrorData(
code=REQUEST_TIMEOUT,
message="Request 'tools/call' timed out",
)
)
@requirement("protocol:timeout:basic")
@requirement("protocol:timeout:sends-cancellation")
async def test_server_request_timeout_sends_cancellation_to_the_client() -> None:
"""A server-initiated request that times out fails server-side and cancels the client's work.
The sampling callback answers only after the server gave up; the late response is discarded.
"""
release = anyio.Event()
callback_started = anyio.Event()
errors: list[ErrorData] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "impatient"
request = types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=8,
)
)
with pytest.raises(MCPError) as exc_info:
await ctx.session.send_request(request, types.CreateMessageResult, request_read_timeout_seconds=0.000001)
errors.append(exc_info.value.error)
release.set()
return CallToolResult(content=[TextContent(text="gave up")])
server = Server("impatient", on_list_tools=list_tools, on_call_tool=call_tool)
recording = RecordingTransport(InMemoryTransport(server))
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
callback_started.set()
with anyio.fail_after(5):
await release.wait()
return types.CreateMessageResult(role="assistant", content=TextContent(text="too late"), model="test-model")
async with Client(recording, mode="legacy", sampling_callback=sampling_callback) as client:
result = await client.call_tool("impatient", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="gave up")]))
assert callback_started.is_set()
assert errors == snapshot([ErrorData(code=REQUEST_TIMEOUT, message="Request 'sampling/createMessage' timed out")])
cancellations = [
item.message
for item in recording.received
if isinstance(item, SessionMessage)
and isinstance(item.message, JSONRPCNotification)
and item.message.method == "notifications/cancelled"
]
# requestId 1 is the sampling request, the server's first outbound request.
assert [notification.params for notification in cancellations] == snapshot(
[{"requestId": 1, "reason": "timed out after 1e-06s"}]
)
@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 courtesy cancellation interrupts it
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
async with Client(server, mode="legacy") 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")]))
# A session-level timeout cannot use the effectively-zero pattern above: it also governs the
# initialize handshake, which must complete before the blocked tool call can wait the timeout
# out in full. Any real-clock margin is a bet against CI scheduler stalls (a 50ms value lost
# that bet in CI; the in-process handshake tail reaches ~190ms on a loaded windows runner), so
# this test runs on trio's virtual clock instead. With autojump, time advances only when every
# task is blocked: the handshake always has a runnable task and therefore cannot time out no
# matter how slow the runner, and once the tool call blocks on the never-answered request the
# run goes idle and the clock jumps straight to the deadline — deterministic, with no real wait.
@requirement("protocol:timeout:session-default")
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
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 courtesy cancellation interrupts it
raise NotImplementedError # unreachable
server = Server("blocker", on_call_tool=call_tool)
async with Client(server, mode="legacy", 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="Request 'tools/call' timed out",
)
)
+513
View File
@@ -0,0 +1,513 @@
"""Tool interactions against the low-level Server, driven through the public Client API."""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
AudioContent,
CallToolResult,
EmbeddedResource,
ErrorData,
Icon,
ImageContent,
ListToolsResult,
ResourceLink,
TextContent,
TextResourceContents,
Tool,
ToolAnnotations,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
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:input-schema:json-schema-2020-12")
@requirement("tools:input-schema:preserve-additional-properties")
@requirement("tools:input-schema:preserve-defs")
@requirement("tools:input-schema:preserve-schema-dialect")
async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect) -> None:
"""A rich JSON Schema 2020-12 inputSchema reaches the client unchanged and the tool is callable.
The single identity assertion below proves all four pass-through behaviours at once: the same
dict literal that was registered is the dict that arrives, so $schema, $defs, the nested object
property, and additionalProperties are each preserved by virtue of the whole schema being
preserved. The follow-up call proves the rich-schema tool is callable end to end.
"""
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {"positive": {"type": "integer", "exclusiveMinimum": 0}},
"properties": {
"count": {"$ref": "#/$defs/positive"},
"options": {
"type": "object",
"properties": {"verbose": {"type": "boolean"}},
"additionalProperties": False,
},
},
"required": ["count"],
"additionalProperties": False,
}
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="typed", input_schema=schema)])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "typed"
assert params.arguments == {"count": 3, "options": {"verbose": True}}
return CallToolResult(content=[TextContent(text="ok")])
server = Server("typed", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
listed = await client.list_tools()
called = await client.call_tool("typed", {"count": 3, "options": {"verbose": True}})
assert listed.tools[0].input_schema == schema
assert called == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@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: # pragma: no branch
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")
@requirement("client:output-schema:skip-on-error")
async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect) -> None:
"""A tool result with isError true is returned as-is even when its structured content violates the schema.
The schema is cached up front so the client could validate, proving the bypass is specifically the
isError flag and not an empty cache.
"""
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="boom")], structured_content={"temperature": "warm"}, is_error=True
)
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.list_tools()
result = await client.call_tool("forecast", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True)
)
@requirement("client:output-schema:missing-structured")
async def test_declared_output_schema_with_no_structured_content_is_rejected_by_the_client(connect: Connect) -> None:
"""A tool that declared an output schema but returned no structuredContent fails the client-side check.
The error is the SDK's own message, so the full text is snapshotted.
"""
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"}}},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "forecast"
return CallToolResult(content=[TextContent(text="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", {})
assert str(exc_info.value) == snapshot("Tool forecast has an output schema but did not return structured content")
@requirement("client:output-schema:auto-list")
async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(connect: Connect) -> None:
"""Calling a tool whose schema is not cached issues exactly one implicit tools/list to populate it.
The first call_tool of an uncached tool triggers a tools/list the caller never asked for; the
second call hits the cache and does not. This is the SDK's chosen cache strategy and the cause of
the surprising behaviour where a server with only on_call_tool sees a successful call answered
with METHOD_NOT_FOUND from a request the caller never made; see the divergence on the requirement.
"""
list_calls: list[str] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
list_calls.append("called")
return ListToolsResult(
tools=[
Tool(
name="forecast",
input_schema={"type": "object"},
output_schema={"type": "object", "properties": {"temperature": {"type": "number"}}},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "forecast"
return CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
first = await client.call_tool("forecast", {})
assert list_calls == ["called"]
second = await client.call_tool("forecast", {})
assert list_calls == ["called"]
assert first == snapshot(CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21}))
assert second == first
+365
View File
@@ -0,0 +1,365 @@
"""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.
The later tests drive the wire by hand instead: one closes the server-to-client stream while a
request is in flight to pin the connection-closed teardown, and the last two send deliberately
malformed JSON-RPC requests that the typed client API cannot produce.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CONNECTION_CLOSED,
INVALID_PARAMS,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
EmptyResult,
ErrorData,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListRootsResult,
TextContent,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client._memory import InMemoryTransport
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import create_client_server_memory_streams
from mcp.shared.message import SessionMessage
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 one, in send order.
"""
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy") as client:
await client.list_tools()
await client.call_tool("echo", {})
await client.call_tool("echo", {})
await client.send_ping() # pyright: ignore[reportDeprecated]
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([1, 2, 3, 4, 5])
@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.
"""
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
"""Registered so the client declares the roots capability; the server never asks for roots."""
raise NotImplementedError
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy", list_roots_callback=list_roots) as client:
await client.send_roots_list_changed() # pyright: ignore[reportDeprecated]
await client.send_ping() # pyright: ignore[reportDeprecated]
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, mode="legacy") 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"])
@requirement("protocol:error:connection-closed")
async def test_closing_the_transport_fails_in_flight_requests_with_connection_closed() -> None:
"""When the server-to-client stream closes, every in-flight client request fails with CONNECTION_CLOSED.
Driven over a bare ClientSession against a real Server so the test holds the transport stream
pair directly: once the request is in flight (the server handler signals it has started) the
test closes the server's write stream, which ends the client's receive loop and triggers the
teardown that fails the pending request.
"""
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 cancelled; nothing ever sets this event
raise NotImplementedError # unreachable: the wait above never completes normally
server = Server("blocker", on_call_tool=call_tool)
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
errors: list[ErrorData] = []
async with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
async with ClientSession(client_read, client_write) as session:
with anyio.fail_after(5):
await session.initialize()
async def call_and_capture_error() -> None:
with pytest.raises(MCPError) as exc_info:
await session.send_request(
CallToolRequest(params=CallToolRequestParams(name="block")), CallToolResult
)
errors.append(exc_info.value.error)
async with anyio.create_task_group() as task_group: # pragma: no branch
task_group.start_soon(call_and_capture_error)
await handler_started.wait()
await server_write.aclose()
server_task_group.cancel_scope.cancel()
assert errors == snapshot([ErrorData(code=CONNECTION_CLOSED, message="Connection closed")])
@requirement("protocol:error:invalid-params")
async def test_malformed_request_params_are_answered_with_invalid_params() -> None:
"""A request whose params fail validation is answered with -32602 Invalid params.
The typed client API cannot construct a request with the wrong parameter types, so the test
plays the client's side of the wire by hand against a real Server: it completes the
initialization handshake at the JSON-RPC layer and then sends a tools/call whose `name` is an
integer. Reserve this pattern for behaviour the typed API cannot produce.
"""
server = Server("strict")
errors: list[ErrorData] = []
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 with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
with anyio.fail_after(5):
await client_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=0,
method="initialize",
params={
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "raw", "version": "0.0.1"},
},
)
)
)
init_response = await client_read.receive()
assert isinstance(init_response, SessionMessage)
assert isinstance(init_response.message, JSONRPCResponse)
await client_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))
)
await client_write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": 42}))
)
error_response = await client_read.receive()
assert isinstance(error_response, SessionMessage)
assert isinstance(error_response.message, JSONRPCError)
errors.append(error_response.message.error)
server_task_group.cancel_scope.cancel()
assert errors == snapshot([ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")])
@requirement("logging:set-level:invalid-level")
async def test_set_level_with_an_unrecognized_value_is_answered_with_invalid_params() -> None:
"""logging/setLevel with a value outside the spec's level enum is answered with -32602 Invalid params.
The typed client API cannot construct a setLevel request with an unrecognized level (pyright and
the client-side model both reject it), so the test plays the client's side of the wire by hand
against a real Server. Reserve this pattern for behaviour the typed API cannot produce.
"""
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; never called -- params validation fails first."""
raise NotImplementedError
server = Server("logger", on_set_logging_level=set_logging_level) # pyright: ignore[reportDeprecated]
errors: list[ErrorData] = []
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 with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
with anyio.fail_after(5):
await client_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=0,
method="initialize",
params={
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "raw", "version": "0.0.1"},
},
)
)
)
init_response = await client_read.receive()
assert isinstance(init_response, SessionMessage)
assert isinstance(init_response.message, JSONRPCResponse)
await client_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))
)
await client_write.send(
SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "loud"})
)
)
error_response = await client_read.receive()
assert isinstance(error_response, SessionMessage)
assert isinstance(error_response.message, JSONRPCError)
errors.append(error_response.message.error)
server_task_group.cancel_scope.cancel()
assert len(errors) == 1
assert errors[0].code == INVALID_PARAMS
@requirement("protocol:cancel:stream-frame")
async def test_abandoning_a_call_on_a_modern_stream_wire_sends_one_cancelled_frame() -> None:
"""At 2026-07-28 over a stream (stdio-shaped) wire, abandoning an in-flight call puts exactly
one notifications/cancelled naming that request on the wire, and the frame interrupts the
server-side handler - stream wires keep the frame spelling that 2026 streamable HTTP dropped.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError # registered so tools/call is served; the stream wire never lists
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
recording = RecordingTransport(InMemoryTransport(server))
async with Client(recording, mode="2026-07-28") as client:
abandon = anyio.CancelScope()
async def call_and_abandon() -> None:
with abandon:
await client.call_tool("block", {})
raise NotImplementedError # unreachable: the call never resolves
async with anyio.create_task_group() as tg:
tg.start_soon(call_and_abandon)
with anyio.fail_after(5):
await handler_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await handler_cancelled.wait()
# Let the cancelled call's late error response arrive and be dropped while the client
# is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
call, cancel = [message.message for message in recording.sent]
assert isinstance(call, JSONRPCRequest)
assert call.method == "tools/call"
assert isinstance(cancel, JSONRPCNotification)
assert cancel.method == "notifications/cancelled"
assert cancel.params == {"requestId": call.id, "reason": "caller cancelled"}
@@ -0,0 +1,38 @@
"""Completion behaviour against MCPServer, driven through the public Client API."""
import pytest
from mcp_types import (
Completion,
CompletionArgument,
CompletionContext,
CompletionsCapability,
PromptReference,
ResourceTemplateReference,
)
from mcp.server.mcpserver import MCPServer
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("mcpserver:completion:capability-auto")
async def test_completion_capability_is_advertised_only_when_a_handler_is_registered(connect: Connect) -> None:
"""An MCPServer with a registered completion handler advertises the completions capability; one without does not."""
with_handler = MCPServer("completer")
@with_handler.completion()
async def complete(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
"""Registered only so the completions capability is advertised; never called."""
raise NotImplementedError
async with connect(with_handler) as client:
assert client.server_capabilities.completions == CompletionsCapability()
async with connect(MCPServer("plain")) as client:
assert client.server_capabilities.completions is None
+274
View File
@@ -0,0 +1,274 @@
"""The Context convenience methods MCPServer injects into tool functions, observed from the client."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
METHOD_NOT_FOUND,
CallToolResult,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitResult,
ErrorData,
Implementation,
LoggingMessageNotification,
LoggingMessageNotificationParams,
TextContent,
)
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 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") # pyright: ignore[reportDeprecated]
await ctx.info("i") # pyright: ignore[reportDeprecated]
await ctx.warning("w") # pyright: ignore[reportDeprecated]
await ctx.error("e") # pyright: ignore[reportDeprecated]
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.server_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("mcpserver:tool:extra")
async def test_context_exposes_request_id_and_client_info_to_a_tool(connect: Connect) -> None:
"""A tool can read the per-request id and the connecting client's identity through Context.
The request id is non-empty (its concrete value depends on transport-level sequencing, so the
test asserts the value the tool saw is the one returned, rather than pinning the literal); the
client info reflects what the caller passed to `Client`.
"""
mcp = MCPServer("introspector")
@mcp.tool()
async def whoami(ctx: Context) -> str:
client_params = ctx.session.client_params
assert client_params is not None
return f"request {ctx.request_id} from {client_params.client_info.name} {client_params.client_info.version}"
async with connect(mcp, client_info=Implementation(name="acme-agent", version="9.9.9")) as client:
result = await client.call_tool("whoami", {})
assert isinstance(result.content[0], TextContent)
text = result.content[0].text
assert text.startswith("request ")
assert text.endswith(" from acme-agent 9.9.9")
request_id = text.removeprefix("request ").removesuffix(" from acme-agent 9.9.9")
assert request_id
@requirement("mcpserver:context:logging")
@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") # pyright: ignore[reportDeprecated]
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") # pyright: ignore[reportDeprecated]
await ctx.error("signal") # pyright: ignore[reportDeprecated]
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") # pyright: ignore[reportDeprecated]
await client.call_tool("chatter", {})
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="logging/setLevel")
)
assert received == snapshot(
[
LoggingMessageNotificationParams(level="debug", data="noise"),
LoggingMessageNotificationParams(level="error", data="signal"),
]
)
@@ -0,0 +1,174 @@
"""Client extensions (SEP-2133) over the full client-server loop: a server extension
substitutes a claimed `tools/call` shape and the declaring client's `ClientExtension` resolves it."""
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult, Result, TextContent
from pydantic import ValidationError
from mcp import MCPError
from mcp.client import ClaimContext, ClientExtension, ResultClaim, advertise
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
_RECEIPTS = "com.example/receipts"
_FLAGS = "com.example/flags"
class ReceiptResult(Result):
result_type: Literal["receipt"] = "receipt"
receipt_token: str
settings_echo: dict[str, Any] | None = None
_Resolver = Callable[[ReceiptResult, ClaimContext], Awaitable[CallToolResult]]
class Receipts(ClientExtension):
"""Client half: claims the `receipt` shape with the test's resolver and settings."""
identifier = _RECEIPTS
def __init__(self, resolve: _Resolver, settings: dict[str, Any] | None = None) -> None:
self._resolve = resolve
self._settings = {} if settings is None else settings
def settings(self) -> dict[str, Any]:
return self._settings
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._resolve)]
class _ReceiptIssuer(Extension):
"""Server half: answers `buy` with the claimed shape; every other tool passes through."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.name != "buy":
return await call_next(ctx)
return {"resultType": "receipt", "receiptToken": "r-117"}
def _receipt_shop(issuer: Extension) -> MCPServer:
server = MCPServer("shop", extensions=[issuer])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
@server.tool()
def redeem(token: str) -> str:
"""Exchange a receipt token for the goods."""
return f"goods for {token}"
return server
@requirement("extensions:client:claimed-result-resolved")
async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None:
"""The owning extension's claim resolver redeems the substituted `receipt` through
`ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`."""
received: list[ReceiptResult] = []
async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
async with connect(_receipt_shop(_ReceiptIssuer()), extensions=[Receipts(redeem_receipt)]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert [claimed.receipt_token for claimed in received] == ["r-117"]
assert result == snapshot(
CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
)
@requirement("extensions:client:claimed-result-undeclared-invalid")
async def test_claimed_shape_fails_validation_for_a_client_without_the_extension(connect: Connect) -> None:
"""Spec-mandated: an unrecognized `resultType` is invalid, so a client without the
owning extension fails to parse the claimed shape."""
async with connect(_receipt_shop(_ReceiptIssuer())) as client:
with pytest.raises(ValidationError):
await client.call_tool("buy", {"item": "lamp"})
class _SettingsEchoIssuer(Extension):
"""Server half: requires the declaring client, then echoes its declared settings."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
require_client_extension(ctx, _RECEIPTS)
client_params = ctx.session.client_params
assert client_params is not None
extensions = client_params.capabilities.extensions
assert extensions is not None
return {"resultType": "receipt", "receiptToken": "echo", "settingsEcho": extensions[_RECEIPTS]}
@requirement("extensions:client:capability-ad:gates-server-behaviour")
async def test_per_request_ad_carries_settings_and_gates_the_claimed_substitution(connect: Connect) -> None:
"""The per-request `_meta` capability ad gates the claimed substitution: declared
settings reach the resolver and a non-declaring client is refused with -32021."""
server = MCPServer("shop", extensions=[_SettingsEchoIssuer()])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
received: list[ReceiptResult] = []
async def keep(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="done")])
async with connect(server, extensions=[Receipts(keep, settings={"tier": "gold"})]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert result.content == [TextContent(text="done")]
assert [claimed.settings_echo for claimed in received] == [{"tier": "gold"}]
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("buy", {"item": "lamp"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def _unreachable_resolve(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # no claimed shape can be delivered on a legacy wire
@requirement("extensions:client:capability-ad:legacy-omits-claimed")
async def test_legacy_ad_omits_claim_bearing_identifiers_but_keeps_claim_less_ones(connect: Connect) -> None:
"""On a legacy connection the claim-bearing identifier drops out of the initialize
capability ad while an ad-only identifier still advertises."""
server = MCPServer("introspector")
@server.tool()
def declared(ctx: Context) -> list[str]:
"""Report the extension identifiers the client advertised."""
capabilities = ctx.client_capabilities
assert capabilities is not None
return sorted(capabilities.extensions or {})
client_extensions = [Receipts(_unreachable_resolve), advertise(_FLAGS)]
async with connect(server, extensions=client_extensions) as client:
result = await client.call_tool("declared", {})
assert result.structured_content == {"result": [_FLAGS]}
+195
View File
@@ -0,0 +1,195 @@
"""Prompt interactions against MCPServer, driven through the public Client API."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ErrorData,
GetPromptResult,
ListPromptsResult,
Prompt,
PromptArgument,
PromptMessage,
TextContent,
)
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
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.
The spec reserves -32602 for this case; the SDK reports code 0 (see the divergence note on
the requirement).
"""
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'}"))
@requirement("mcpserver:prompt:args-validation")
async def test_get_prompt_with_a_wrong_type_argument_is_rejected_before_the_function_runs(connect: Connect) -> None:
"""An argument that fails the function signature's type validation is rejected before the function runs.
The decorated function is wrapped in pydantic's validate_call, so a value that cannot be
coerced to the parameter's annotation fails before the body executes. The function body
raises NotImplementedError to prove it never ran. The error is wrapped in the SDK's stable
rendering-error prefix; the body of the message is raw pydantic output and is not asserted.
"""
mcp = MCPServer("prompter")
@mcp.prompt()
def repeat(phrase: str, count: int) -> str:
"""A registered prompt; type 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("repeat", {"phrase": "hi", "count": "many"})
assert exc_info.value.error.code == 0
assert exc_info.value.error.message.startswith("Error rendering prompt repeat: 1 validation error")
@requirement("mcpserver:prompt:optional-args")
async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(connect: Connect) -> None:
"""A prompt rendered without one of its optional arguments uses that parameter's default value."""
mcp = MCPServer("prompter")
@mcp.prompt()
def review(code: str, style: str = "pep8") -> str:
"""Review a snippet of code against a style guide."""
return f"Review {code} per {style}."
async with connect(mcp) as client:
result = await client.get_prompt("review", {"code": "x = 1"})
assert result == snapshot(
GetPromptResult(
description="Review a snippet of code against a style guide.",
messages=[PromptMessage(role="user", content=TextContent(text="Review x = 1 per pep8."))],
)
)
@requirement("mcpserver:prompt:duplicate-name")
async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(connect: Connect) -> None:
"""Registering a second prompt with an already-used name keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
and discards the second registration (see the divergence note on the requirement). The
second function is registered via the decorator with an explicit name so the test does not
redefine the same function name in this scope.
"""
mcp = MCPServer("prompter")
@mcp.prompt()
def greet() -> str:
"""The first registration; this is the one that wins."""
return "first"
@mcp.prompt(name="greet")
def greet_second() -> str:
"""Registered with a duplicate name; the registration is discarded so this never runs."""
raise NotImplementedError
async with connect(mcp) as client:
listed = await client.list_prompts()
result = await client.get_prompt("greet")
assert [prompt.name for prompt in listed.prompts] == ["greet"]
assert result == snapshot(
GetPromptResult(
description="The first registration; this is the one that wins.",
messages=[PromptMessage(role="user", content=TextContent(text="first"))],
)
)
@@ -0,0 +1,183 @@
"""Resource interactions against MCPServer, driven through the public Client API."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ErrorData,
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
Resource,
ResourceTemplate,
TextResourceContents,
)
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
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 -32602 and the URI in data (SEP-2164)."""
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=-32602, message="Unknown resource: config://missing", data={"uri": "config://missing"})
)
@requirement("mcpserver:resource:read-throws-surfaced")
async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(connect: Connect) -> None:
"""An exception raised by a resource function reaches the caller as a JSON-RPC error.
MCPServer wraps the failure in a generic ResourceError that names only the URI, so the original
exception text is not leaked to the client. The wrapped exception surfaces as -32603 Internal error.
"""
mcp = MCPServer("library")
@mcp.resource("res://boom")
def boom() -> str:
raise RuntimeError("nope")
async with connect(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("res://boom")
assert exc_info.value.error == snapshot(
ErrorData(code=-32603, message="Error reading resource res://boom", data={"uri": "res://boom"})
)
@requirement("mcpserver:resource:duplicate-name")
async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(connect: Connect) -> None:
"""Registering a second static resource at an already-used URI keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
and discards the second registration (see the divergence note on the requirement). The two
registrations use different function names so the test does not redefine a name in this scope;
the resource decorator keys on the URI, not the function name.
"""
mcp = MCPServer("library")
@mcp.resource("config://app")
def config_first() -> str:
"""The first registration; this is the one that wins."""
return "first"
@mcp.resource("config://app")
def config_second() -> str:
"""Registered at a duplicate URI; the registration is discarded so this never runs."""
raise NotImplementedError
async with connect(mcp) as client:
listed = await client.list_resources()
result = await client.read_resource("config://app")
assert [resource.uri for resource in listed.resources] == ["config://app"]
assert listed.resources[0].name == "config_first"
assert result == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")])
)
@@ -0,0 +1,62 @@
"""Client.listen against MCPServer over the connect matrix (2026-07-28)."""
import anyio
import pytest
from mcp.client.subscriptions import ListenNotSupportedError, ResourceUpdated, ToolsListChanged
from mcp.server.mcpserver import Context, MCPServer
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _notebook() -> MCPServer:
mcp = MCPServer("notebook")
@mcp.tool()
async def touch_tools(ctx: Context) -> str:
await ctx.notify_tools_changed()
return "ok"
@mcp.tool()
async def edit_note(name: str, ctx: Context) -> str:
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
return mcp
@requirement("subscriptions:listen:client:honored-surfacing")
@requirement("subscriptions:listen:client:iteration")
async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connect) -> None:
"""Entering waits for the ack (honored is set before any event); iteration yields
only the typed event kinds this stream opted in to."""
mcp = _notebook()
async with connect(mcp) as client:
with anyio.fail_after(10):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
assert sub.honored.tools_list_changed is True
assert sub.honored.resource_subscriptions == ["note://todo"]
await client.call_tool("edit_note", {"name": "journal"}) # unsubscribed URI: silent
await client.call_tool("edit_note", {"name": "todo"})
assert await anext(sub) == ResourceUpdated(uri="note://todo")
await client.call_tool("touch_tools", {})
assert await anext(sub) == ToolsListChanged()
@requirement("subscriptions:listen:client:era-guard")
async def test_listen_on_a_pre_2026_connection_raises_the_typed_steer(connect: Connect) -> None:
"""On 2025-era connections the guard fires before anything touches the wire, steering to the legacy verbs."""
mcp = _notebook()
async with connect(mcp) as client:
with anyio.fail_after(10):
# Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body.
with pytest.raises(ListenNotSupportedError) as exc_info:
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.negotiated_version == client.session.protocol_version
assert "subscribe_resource" in str(exc_info.value)
+432
View File
@@ -0,0 +1,432 @@
"""Tool interactions against MCPServer, driven through the public Client API."""
import logging
from typing import Annotated, Literal
import pytest
from inline_snapshot import snapshot
from mcp_types import (
URL_ELICITATION_REQUIRED,
CallToolResult,
ElicitRequestURLParams,
ErrorData,
LoggingMessageNotification,
LoggingMessageNotificationParams,
TextContent,
)
from pydantic import BaseModel, Field
from mcp import MCPError
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.shared.exceptions import UrlElicitationRequiredError
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:schema-variants")
async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(connect: Connect) -> None:
"""Literal, nested-model, and constrained parameters are validated and coerced from the wire arguments.
The string "3" is coerced to `int` and the `point` dict to a `Point` instance before the function
body sees them, proving the generated input schema and validation pipeline cover non-trivial types.
"""
mcp = MCPServer("typed")
class Point(BaseModel):
x: int
y: int
@mcp.tool()
def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Field(ge=1, le=10)]) -> str:
assert isinstance(point, Point)
return f"{mode} at ({point.x}, {point.y}) x{count}"
async with connect(mcp) as client:
result = await client.call_tool("place", {"mode": "fast", "point": {"x": "3", "y": 4}, "count": 5})
assert result == snapshot(
CallToolResult(
content=[TextContent(text="fast at (3, 4) x5")], structured_content={"result": "fast at (3, 4) x5"}
)
)
@requirement("mcpserver:tool:handler-throws")
@requirement("mcpserver:output-schema:skip-on-error")
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.
The function's `-> str` annotation gives the tool a derived output schema, but the error
result is built before any schema validation runs, so no validation failure is layered on
top of the original exception.
"""
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:output-schema:server-validate")
@requirement("mcpserver:output-schema:missing-structured")
async def test_tool_with_output_schema_returning_mismatched_structured_content_is_an_error_result(
connect: Connect,
) -> None:
"""Structured content that fails the tool's own output schema is rejected on the server side.
A tool annotated `Annotated[CallToolResult, Model]` returns a hand-built CallToolResult while
declaring `Model` as its output schema; MCPServer validates the supplied structured_content
against that schema before returning. The two cases -- a content shape that does not match,
and no structured content at all -- both fail that validation and are reported as is_error
results carrying the (raw pydantic) validation error wrapped in the SDK's stable prefix.
"""
mcp = MCPServer("forecaster")
class Weather(BaseModel):
temperature: float
conditions: str
@mcp.tool()
def mismatched() -> Annotated[CallToolResult, Weather]:
return CallToolResult(content=[TextContent(text="oops")], structured_content={"nope": True})
@mcp.tool()
def missing() -> Annotated[CallToolResult, Weather]:
return CallToolResult(content=[TextContent(text="oops")])
async with connect(mcp) as client:
mismatched_result = await client.call_tool("mismatched", {})
missing_result = await client.call_tool("missing", {})
# The body of each message is raw pydantic ValidationError output (model name, field paths,
# an errors.pydantic.dev URL) and changes across pydantic versions, so only the SDK's stable
# prefix is asserted.
assert mismatched_result.is_error is True
assert isinstance(mismatched_result.content[0], TextContent)
assert mismatched_result.content[0].text.startswith("Error executing tool mismatched: 2 validation errors")
assert missing_result.is_error is True
assert isinstance(missing_result.content[0], TextContent)
assert missing_result.content[0].text.startswith("Error executing tool missing: 1 validation error")
@requirement("mcpserver:tool:duplicate-name")
async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(connect: Connect) -> None:
"""Registering a second tool with an already-used name keeps the first registration.
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
and discards the second registration (see the divergence note on the requirement). The
second function is registered via add_tool with an explicit name so the test does not
redefine the same function name in this scope.
"""
mcp = MCPServer("duplicates")
@mcp.tool()
def echo() -> str:
return "first"
def echo_second() -> str:
"""Passed to add_tool with a duplicate name; the registration is discarded so this never runs."""
raise NotImplementedError
mcp.add_tool(echo_second, name="echo")
async with connect(mcp) as client:
listed = await client.list_tools()
result = await client.call_tool("echo", {})
assert [tool.name for tool in listed.tools] == ["echo"]
assert result == snapshot(
CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"})
)
@requirement("mcpserver:tool:naming-validation")
async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_reject(
connect: Connect, caplog: pytest.LogCaptureFixture
) -> None:
"""A tool name that violates the SEP-986 rules logs a warning at registration but is still registered.
The intended behaviour is rejection at registration time; MCPServer instead logs the
naming-rule violation and proceeds (see the divergence note on the requirement). The warning
spans several SDK-authored log records, so only the stable prefix and inclusion of the
offending name are asserted.
"""
mcp = MCPServer("naming")
with caplog.at_level(logging.WARNING, logger="mcp.shared.tool_name_validation"):
@mcp.tool(name="bad name!")
def bad() -> str:
return "ok"
assert any(
rec.levelno == logging.WARNING
and rec.message.startswith("Tool name validation warning")
and "bad name!" in rec.message
for rec in caplog.records
)
async with connect(mcp) as client:
listed = await client.list_tools()
result = await client.call_tool("bad name!", {})
assert [tool.name for tool in listed.tools] == ["bad name!"]
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
@requirement("mcpserver:tool:url-elicitation-error")
async def test_decorated_tool_raising_url_elicitation_required_surfaces_as_error_32042(connect: Connect) -> None:
"""A decorated tool raising the URL-elicitation-required error reaches the client as error -32042.
MCPServer wraps every other tool exception as an is_error result; this error is special-cased
so it propagates as the JSON-RPC error the client needs in order to present the listed URL
interactions and retry the call.
"""
mcp = MCPServer("authorizer")
@mcp.tool()
def read_files() -> str:
raise UrlElicitationRequiredError(
[
ElicitRequestURLParams(
message="Authorization required for your files.",
url="https://example.com/oauth/authorize",
elicitation_id="auth-001",
)
]
)
async with connect(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("read_files", {})
assert exc_info.value.error.code == URL_ELICITATION_REQUIRED
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",
}
]
},
)
)
@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") # pyright: ignore[reportDeprecated]
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"))]
)
+360
View File
@@ -0,0 +1,360 @@
"""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
from typing import cast
import pytest
from mcp_types import LATEST_PROTOCOL_VERSION
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
from tests.interaction._requirements import (
CONNECTABLE_TRANSPORTS,
REQUIREMENTS,
SPEC_2026_BASE_URL,
SPEC_BASE_URL,
SPEC_VERSIONS,
ArmExclusion,
KnownFailure,
Requirement,
SpecVersion,
Transport,
cell_id,
compute_cells,
covered_by,
requirement,
)
from tests.interaction.conftest import _FACTORIES
_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",
"tests.interaction.auth.test_flow.test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_wrapped_app",
}
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_spec_versions_are_known_and_include_latest() -> None:
"""Every active spec version is one the SDK knows about, and the SDK's latest is on the active axis."""
assert set(SPEC_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS)
assert LATEST_PROTOCOL_VERSION in SPEC_VERSIONS
def test_spec_base_urls_are_pinned_to_their_revision() -> None:
"""SPEC_BASE_URL constants are pinned literals, so growing SPEC_VERSIONS cannot repoint existing source links."""
assert SPEC_BASE_URL == "https://modelcontextprotocol.io/specification/2025-11-25"
assert SPEC_2026_BASE_URL == "https://modelcontextprotocol.io/specification/2026-07-28"
def test_connectable_transports_match_connect_factories() -> None:
"""CONNECTABLE_TRANSPORTS and the conftest factory map name exactly the same transports."""
assert set(CONNECTABLE_TRANSPORTS) == set(_FACTORIES)
def test_supersession_links_are_symmetric_and_versioned() -> None:
"""``supersedes``/``superseded_by`` reference real entries, agree in both directions, and carry version bounds."""
broken = [
f"{req_id} -> {target}"
for req_id, req in REQUIREMENTS.items()
for target in req.supersedes
if target not in REQUIREMENTS or REQUIREMENTS[target].superseded_by != req_id or req.added_in is None
] + [
f"{req_id} <- {req.superseded_by}"
for req_id, req in REQUIREMENTS.items()
if req.superseded_by is not None
if req.superseded_by not in REQUIREMENTS
or req_id not in REQUIREMENTS[req.superseded_by].supersedes
or req.removed_in is None
]
assert not broken, f"Broken supersession links (forward '->' or back '<-'): {broken}"
def test_removed_entry_has_disposition() -> None:
"""Every retired requirement carries either a forward link or a prose note explaining the retirement."""
undisposed = [
req_id
for req_id, req in REQUIREMENTS.items()
if req.removed_in is not None and req.superseded_by is None and req.note is None
]
assert not undisposed, f"Requirements with removed_in but no superseded_by or note: {undisposed}"
def test_transport_restriction_has_note() -> None:
"""Every transport-restricted requirement carries a note explaining why it is transport-specific."""
missing = [req_id for req_id, req in REQUIREMENTS.items() if req.transports is not None and req.note is None]
assert not missing, f"Requirements with transports= but no note: {missing}"
def test_every_arm_exclusion_targets_a_reachable_cell() -> None:
"""Every arm exclusion names a connectable transport (or wildcards).
spec_version is type-checked against the SpecVersion Literal and may reference a version not yet
on the active SPEC_VERSIONS axis, so pre-staged exclusions for an upcoming revision are permitted.
"""
unreachable = [
f"{req_id}: {exclusion}"
for req_id, req in REQUIREMENTS.items()
for exclusion in req.arm_exclusions
if exclusion.transport is not None and exclusion.transport not in CONNECTABLE_TRANSPORTS
]
assert not unreachable, f"Arm exclusions targeting unreachable cells: {unreachable}"
def test_every_known_failure_targets_a_reachable_cell() -> None:
"""Every known failure names a connectable transport (or wildcards).
spec_version is type-checked against the SpecVersion Literal and may reference a version not yet
on the active SPEC_VERSIONS axis, so pre-staged exclusions for an upcoming revision are permitted.
"""
unreachable = [
f"{req_id}: {failure}"
for req_id, req in REQUIREMENTS.items()
for failure in req.known_failures
if failure.transport is not None and failure.transport not in CONNECTABLE_TRANSPORTS
]
assert not unreachable, f"Known failures targeting unreachable cells: {unreachable}"
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.")
def test_arm_exclusion_with_unknown_spec_version_is_rejected() -> None:
"""An arm exclusion naming a spec version outside KNOWN_PROTOCOL_VERSIONS fails at construction."""
with pytest.raises(ValueError, match="is not in KNOWN_PROTOCOL_VERSIONS"):
ArmExclusion(reason="requires-session", spec_version=cast("SpecVersion", "2099-01-01"))
def test_known_failure_with_empty_note_is_rejected() -> None:
"""A known failure with a blank note fails at construction."""
with pytest.raises(ValueError, match="note must be non-empty"):
KnownFailure(note=" ")
def test_known_failure_with_unknown_spec_version_is_rejected() -> None:
"""A known failure naming a spec version outside KNOWN_PROTOCOL_VERSIONS fails at construction."""
with pytest.raises(ValueError, match="is not in KNOWN_PROTOCOL_VERSIONS"):
KnownFailure(note="x", spec_version=cast("SpecVersion", "2099-01-01"))
def test_known_failure_with_malformed_issue_is_rejected() -> None:
"""A known failure whose issue reference is neither '#<n>' nor a GitHub URL fails at construction."""
with pytest.raises(ValueError, match="must be '#<n>' or a GitHub URL"):
KnownFailure(note="x", issue="not-a-link")
def test_requirement_with_unknown_added_in_is_rejected() -> None:
"""A requirement whose added_in is outside KNOWN_PROTOCOL_VERSIONS fails at construction."""
with pytest.raises(ValueError, match="added_in .* is not in KNOWN_PROTOCOL_VERSIONS"):
Requirement(source="sdk", behavior="x", added_in=cast("SpecVersion", "2099-01-01"))
def test_requirement_with_unknown_removed_in_is_rejected() -> None:
"""A requirement whose removed_in is outside KNOWN_PROTOCOL_VERSIONS fails at construction."""
with pytest.raises(ValueError, match="removed_in .* is not in KNOWN_PROTOCOL_VERSIONS"):
Requirement(source="sdk", behavior="x", removed_in=cast("SpecVersion", "2099-01-01"))
def test_requirement_with_empty_version_range_is_rejected() -> None:
"""A requirement whose added_in is not strictly earlier than its removed_in fails at construction."""
with pytest.raises(ValueError, match="must be earlier than"):
Requirement(source="sdk", behavior="x", added_in="2025-11-25", removed_in="2025-11-25")
def _req(
*,
added_in: SpecVersion | None = None,
removed_in: SpecVersion | None = None,
transports: tuple[Transport, ...] | None = None,
arm_exclusions: tuple[ArmExclusion, ...] = (),
known_failures: tuple[KnownFailure, ...] = (),
) -> Requirement:
"""Build a synthetic Requirement for compute_cells() unit tests."""
return Requirement(
source="sdk",
behavior="x",
added_in=added_in,
removed_in=removed_in,
transports=transports,
arm_exclusions=arm_exclusions,
known_failures=known_failures,
)
def test_compute_cells_with_no_requirements_yields_full_grid() -> None:
"""With a single-version axis, an empty requirement list yields one cell per connectable transport."""
cells = compute_cells([], spec_versions=("2025-11-25",))
assert [c.id for c in cells] == ["in-memory", "sse", "streamable-http", "streamable-http-stateless"]
assert [c.values for c in cells] == [
(("in-memory", "2025-11-25"),),
(("sse", "2025-11-25"),),
(("streamable-http", "2025-11-25"),),
(("streamable-http-stateless", "2025-11-25"),),
]
def test_compute_cells_intersects_stacked_version_ranges() -> None:
"""Stacked requirements intersect their [added_in, removed_in) windows: a cell survives only if all admit it."""
cells = compute_cells(
[_req(removed_in="2026-07-28"), _req(added_in="2025-11-25")],
spec_versions=("2025-11-25", "2026-07-28"),
)
assert [c.id for c in cells] == [
"in-memory-2025-11-25",
"sse-2025-11-25",
"streamable-http-2025-11-25",
"streamable-http-stateless-2025-11-25",
]
def test_compute_cells_drops_era_locked_transport_outside_its_versions() -> None:
"""A transport listed in TRANSPORT_SPEC_VERSIONS only appears for the spec versions it serves."""
cells = compute_cells([], spec_versions=("2025-11-25", "2026-07-28"))
assert [c.id for c in cells] == [
"in-memory-2025-11-25",
"sse-2025-11-25",
"streamable-http-2025-11-25",
"streamable-http-stateless-2025-11-25",
"in-memory-2026-07-28",
"streamable-http-2026-07-28",
]
def test_compute_cells_honours_arm_exclusion_from_any_stacked_requirement() -> None:
"""An arm exclusion on any stacked requirement drops the matching cell even when other requirements have none."""
cells = compute_cells(
[_req(), _req(arm_exclusions=(ArmExclusion(reason="requires-session", transport="sse"),))],
spec_versions=("2025-11-25",),
)
assert [c.id for c in cells] == ["in-memory", "streamable-http", "streamable-http-stateless"]
def test_compute_cells_wildcard_arm_exclusion_drops_every_cell() -> None:
"""An arm exclusion with both transport and spec_version unset matches every cell, leaving none."""
cells = compute_cells([_req(arm_exclusions=(ArmExclusion(reason="requires-session"),))])
assert cells == []
def test_compute_cells_marks_known_failure_as_strict_xfail() -> None:
"""A known failure attaches a strict xfail mark to exactly the matching cell and leaves others unmarked."""
cells = compute_cells(
[_req(known_failures=(KnownFailure(note="broken on sse", transport="sse"),))],
spec_versions=("2025-11-25",),
)
by_id = {c.id: c for c in cells}
assert set(by_id) == {"in-memory", "sse", "streamable-http", "streamable-http-stateless"}
assert by_id["sse"].marks[0].name == "xfail"
assert by_id["sse"].marks[0].kwargs == {"reason": "broken on sse", "strict": True}
assert by_id["in-memory"].marks == ()
assert by_id["streamable-http"].marks == ()
assert by_id["streamable-http-stateless"].marks == ()
def test_compute_cells_wildcard_known_failure_marks_every_cell() -> None:
"""A known failure with both transport and spec_version unset marks every emitted cell as strict xfail."""
cells = compute_cells([_req(known_failures=(KnownFailure(note="all broken"),))], spec_versions=("2025-11-25",))
assert len(cells) == 4
assert all(c.marks[0].name == "xfail" for c in cells)
assert all(c.marks[0].kwargs == {"reason": "all broken", "strict": True} for c in cells)
def test_compute_cells_ignores_transports_field() -> None:
"""Requirement.transports is descriptive metadata only and does not filter the cell grid."""
cells = compute_cells([_req(transports=("stdio",))], spec_versions=("2025-11-25",))
assert [c.id for c in cells] == list(CONNECTABLE_TRANSPORTS)
def test_cell_id_omits_version_when_single_spec_version() -> None:
"""With a single-version axis the cell id is just the transport name."""
assert cell_id("sse", "2025-11-25", spec_versions=("2025-11-25",)) == "sse"
def test_cell_id_appends_version_when_multiple_spec_versions() -> None:
"""With more than one active spec version the cell id gains a -<version> suffix."""
assert cell_id("sse", "2025-11-25", spec_versions=("2025-11-25", "2026-07-28")) == "sse-2025-11-25"
+9
View File
@@ -0,0 +1,9 @@
"""Transport-specific interaction tests, and the in-process streaming bridge they are built on.
`StreamingASGITransport` is re-exported here as the sanctioned import point for test code
outside this suite (the bridge module itself is suite-private).
"""
from tests.interaction.transports._bridge import StreamingASGITransport
__all__ = ["StreamingASGITransport"]
+172
View File
@@ -0,0 +1,172 @@
"""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
from mcp.shared._compat import resync_tracer
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)
await resync_tracer()
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)
try:
await response_started.wait()
if application_error is not None:
raise application_error
except BaseException:
# No response will be built, so close the reader the response body would have owned
# and tell the application its peer has gone away.
client_disconnected.set()
await chunk_reader.aclose()
raise
return httpx.Response(
status_code=response_status,
headers=response_headers,
stream=_StreamingResponseBody(chunk_reader, client_disconnected),
request=request,
)
@@ -0,0 +1,55 @@
"""A predictable event store for resumability tests.
The SDK's `EventStore` interface lets a streamable-HTTP server stamp every SSE event with an ID
and replay missed events when a client reconnects with `Last-Event-ID`. This implementation
issues sequential integer IDs starting at "1" so tests can assert exact IDs (the example store
uses uuid4, which cannot be snapshotted) and is small enough that every line is exercised by the
resumability tests themselves.
"""
import anyio
from mcp_types import JSONRPCMessage
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
class SequencedEventStore(EventStore):
"""Stores every event in order and replays the same-stream tail after a given ID."""
def __init__(self) -> None:
self._events: list[tuple[StreamId, JSONRPCMessage | None]] = []
self._milestones: dict[int, anyio.Event] = {}
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
self._events.append((stream_id, message))
count = len(self._events)
milestone = self._milestones.pop(count, None)
if milestone is not None:
milestone.set()
return str(count)
async def wait_until_stored(self, count: int) -> None:
"""Block until at least `count` events have been stored.
Tests use this to wait for the server's message router (which runs in another task) to
finish storing a known set of events before issuing a replay, so the replay's content is
deterministic rather than depending on task scheduling order.
"""
if len(self._events) >= count:
return
milestone = self._milestones.setdefault(count, anyio.Event())
await milestone.wait()
async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None:
try:
cursor = int(last_event_id)
except ValueError:
return None
if not 0 < cursor <= len(self._events):
return None
stream_id, _ = self._events[cursor - 1]
for index in range(cursor, len(self._events)):
event_stream_id, message = self._events[index]
if event_stream_id == stream_id and message is not None:
await send_callback(EventMessage(message, str(index + 1)))
return stream_id
@@ -0,0 +1,90 @@
"""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 warnings
import anyio
import coverage
from mcp_types import (
CallToolRequestParams,
CallToolResult,
EmptyResult,
ListToolsResult,
PaginatedRequestParams,
SetLevelRequestParams,
TextContent,
Tool,
)
from mcp.server import Server, ServerRequestContext
from mcp.server.stdio import stdio_server
from mcp.shared.exceptions import MCPDeprecationWarning
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"]
with warnings.catch_warnings():
warnings.simplefilter("ignore", MCPDeprecationWarning)
await ctx.session.send_log_message(level="info", data=f"echoing {text}", logger="echo") # pyright: ignore[reportDeprecated]
return CallToolResult(content=[TextContent(text=text)])
async def set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
with warnings.catch_warnings():
warnings.simplefilter("ignore", MCPDeprecationWarning)
server = Server( # pyright: ignore[reportDeprecated]
"stdio-echo", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
async def main() -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
# Flush this process's coverage data before the clean-exit line below. Without this, the
# data is only written by coverage's atexit hook during interpreter teardown -- and on a
# slow Windows runner that can overrun the transport's termination grace, so the kill
# silently destroys the data file and the 100% gate trips on this module's subprocess-only
# lines. Saving here puts the write before the line the test synchronizes on: once the
# parent has seen "clean exit", the data is durably on disk and the escalation is harmless.
# Nothing measured may execute after the save (it would be unrecordable by construction),
# hence the excluded lines below. The branch is pragma'd because under coverage the
# instance always exists, and without coverage nothing is measured anyway.
cov = getattr(coverage.process_startup, "coverage", None)
if cov is not None: # pragma: no branch
# stop() is load-bearing twice over: it ends tracing, making itself the last
# recordable line, and it leaves nothing new for coverage's atexit re-save to flush --
# so a kill landing during interpreter teardown cannot corrupt the file save() wrote
# (coverage opens it with sqlite journaling off; a torn rewrite would not roll back).
cov.stop()
cov.save() # pragma: lax no cover - untraced: stop() above already ended measurement
# 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. lax no cover: runs after the coverage
# save by design, so it can never appear covered.
print("stdio-echo: clean exit", file=sys.stderr, flush=True) # pragma: lax no cover
if __name__ == "__main__":
anyio.run(main)
@@ -0,0 +1,94 @@
"""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,
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)
with anyio.fail_after(5):
async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http:
async with http.stream("GET", "/linger") as response:
assert response.status_code == 200
assert not cleanup_ran.is_set()
assert cleanup_ran.is_set()
@@ -0,0 +1,352 @@
"""Behaviour of the streamable-HTTP client transport itself, observed at the wire.
These tests connect a real `Client` to a real server over the in-process bridge, recording every
HTTP request the SDK client issues, so the assertions are about what the transport sends (headers,
methods, ordering) rather than what the protocol layer on top of it returns. The recording is the
wire-level instrument; the SDK client never exposes these details.
"""
import json
from collections.abc import AsyncIterator
import anyio
import httpx
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import INVALID_REQUEST, CallToolResult, ErrorData, ListToolsResult, TextContent, Tool
from starlette.types import Receive, Scope, Send
from mcp import MCPError
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION, client_via_http, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.transports._bridge import StreamingASGITransport
from tests.interaction.transports._event_store import SequencedEventStore
pytestmark = pytest.mark.anyio
def _tooled_server() -> Server:
"""A low-level server with one echo tool, used by every test in this file."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", description="Echo text.", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
assert params.arguments is not None
return CallToolResult(content=[TextContent(text=str(params.arguments["text"]))])
return Server("echoer", on_list_tools=list_tools, on_call_tool=call_tool)
@pytest.fixture
async def recorded() -> AsyncIterator[list[httpx.Request]]:
"""Connect a `Client` over a recording HTTP client, list tools, exit, and yield every request sent.
The HTTP client carries one caller-supplied header (`x-trace`) so its propagation can be
asserted; the recording captures the closing DELETE because it is read after the `Client` has
fully exited.
"""
requests: list[httpx.Request] = []
async def record(request: httpx.Request) -> None:
requests.append(request)
async with mounted_app(_tooled_server(), on_request=record, headers={"x-trace": "abc"}) as (http, _):
async with client_via_http(http) as client:
result = await client.list_tools()
assert [tool.name for tool in result.tools] == ["echo"]
yield requests
def _after_initialize(recorded: list[httpx.Request]) -> list[httpx.Request]:
"""Every recorded request after the initialize POST (which carries no session yet)."""
assert recorded[0].method == "POST"
assert "mcp-session-id" not in recorded[0].headers
return recorded[1:]
@requirement("client-transport:http:custom-client")
@requirement("client-transport:http:custom-headers")
async def test_the_client_uses_the_supplied_http_client_and_propagates_its_headers(
recorded: list[httpx.Request],
) -> None:
"""A caller-supplied `httpx.AsyncClient` is used for every request and carries its own headers.
The recording itself proves the supplied client is the one in use; the propagated header
proves the SDK transport does not replace the caller's client configuration.
"""
# Exact ordering past the first request is not guaranteed (the standalone GET stream is
# scheduled concurrently with later POSTs), so methods are asserted as a multiset.
assert sorted(request.method for request in recorded) == snapshot(["DELETE", "GET", "POST", "POST", "POST"])
assert all(request.headers["x-trace"] == "abc" for request in recorded)
@requirement("client-transport:http:session-stored")
async def test_every_request_after_initialize_carries_the_issued_session_id(recorded: list[httpx.Request]) -> None:
"""The session id from the initialize response is sent on every subsequent request."""
session_ids = {request.headers["mcp-session-id"] for request in _after_initialize(recorded)}
assert len(session_ids) == 1
(session_id,) = session_ids
assert session_id
@requirement("client-transport:http:protocol-version-stored")
@requirement("client-transport:http:protocol-version-header")
async def test_every_request_after_initialize_carries_the_negotiated_protocol_version(
recorded: list[httpx.Request],
) -> None:
"""The negotiated protocol version is sent on every subsequent request (and not on initialize)."""
assert "mcp-protocol-version" not in recorded[0].headers
versions = {request.headers["mcp-protocol-version"] for request in _after_initialize(recorded)}
assert versions == snapshot({"2025-11-25"})
@requirement("client-transport:http:accept-header-post")
@requirement("client-transport:http:accept-header-get")
async def test_accept_headers_cover_the_response_representations_the_transport_handles(
recorded: list[httpx.Request],
) -> None:
"""POSTs accept both JSON and SSE; the standalone GET stream accepts SSE."""
for request in recorded:
if request.method == "POST":
assert "application/json" in request.headers["accept"]
assert "text/event-stream" in request.headers["accept"]
if request.method == "GET":
assert "text/event-stream" in request.headers["accept"]
@requirement("client-transport:http:no-reconnect-after-close")
async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded: list[httpx.Request]) -> None:
"""Client teardown sends DELETE and issues no further requests (no resumption GET)."""
assert recorded[-1].method == "DELETE"
assert all("last-event-id" not in request.headers for request in recorded)
@requirement("client-transport:http:concurrent-streams")
async def test_concurrent_tool_calls_each_open_a_post_stream_and_receive_their_own_response() -> None:
"""Three tool calls issued at once each open their own POST stream and get the right answer."""
requests: list[httpx.Request] = []
results: dict[int, CallToolResult] = {}
async def record(request: httpx.Request) -> None:
requests.append(request)
async with mounted_app(_tooled_server(), on_request=record) as (http, _), client_via_http(http) as client:
async def call(n: int) -> None:
results[n] = await client.call_tool("echo", {"text": str(n)})
with anyio.fail_after(5): # pragma: no branch
async with anyio.create_task_group() as tg: # pragma: no branch
for n in (1, 2, 3):
tg.start_soon(call, n)
assert results == snapshot(
{
1: CallToolResult(content=[TextContent(text="1")]),
2: CallToolResult(content=[TextContent(text="2")]),
3: CallToolResult(content=[TextContent(text="3")]),
}
)
tools_call_posts = [r for r in requests if r.method == "POST" and b'"tools/call"' in r.content]
assert len(tools_call_posts) == 3
@requirement("client-transport:http:sse-405-tolerated")
@requirement("client-transport:http:terminate-405-ok")
async def test_client_tolerates_405_on_get_and_delete() -> None:
"""A 405 on the standalone GET stream or the closing DELETE does not fail the connection.
The GET-stream task swallows the failure and schedules a reconnect that the closing cancel
interrupts before it ever sleeps the full default delay; the DELETE 405 is logged and ignored.
Neither surfaces to the caller.
"""
server = _tooled_server()
real_app = server.streamable_http_app(transport_security=NO_DNS_REBINDING_PROTECTION)
async def filter_methods(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["method"] in ("GET", "DELETE"):
await send({"type": "http.response.start", "status": 405, "headers": []})
await send({"type": "http.response.body", "body": b""})
return
await real_app(scope, receive, send)
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(filter_methods), base_url=BASE_URL) as http_client,
):
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
with anyio.fail_after(5): # pragma: no branch
async with Client(transport, mode="legacy") as client: # pragma: no branch
result = await client.list_tools()
assert [tool.name for tool in result.tools] == ["echo"]
@requirement("client-transport:http:no-reconnect-after-response")
async def test_a_completed_post_stream_is_not_reconnected() -> None:
"""A POST stream that delivered its response closes without a resumption GET.
With an event store the server stamps every SSE event with an ID, so the client transport has a
Last-Event-ID it could resume from -- the test proves it does not, because the response arrived
and the stream completed normally.
"""
requests: list[httpx.Request] = []
async def record(request: httpx.Request) -> None:
requests.append(request)
server = _tooled_server()
async with (
mounted_app(server, event_store=SequencedEventStore(), retry_interval=0, on_request=record) as (http, _),
client_via_http(http) as client,
):
with anyio.fail_after(5):
result = await client.list_tools()
assert [tool.name for tool in result.tools] == ["echo"]
resumption_gets = [r for r in requests if r.method == "GET" and "last-event-id" in r.headers]
assert resumption_gets == []
@requirement("client-transport:http:404-surfaces")
async def test_a_404_mid_session_surfaces_as_a_session_terminated_error() -> None:
"""A 404 in response to a request after initialization is reported to the caller as an MCP error.
The spec says the client MUST start a new session in this situation; the SDK instead surfaces a
`Session terminated` error to the caller. The spec's MUST is tracked at
client-transport:http:session-404-reinitialize; this test pins the SDK's current behaviour.
"""
server = _tooled_server()
real_app = server.streamable_http_app(transport_security=NO_DNS_REBINDING_PROTECTION)
initialize_seen = anyio.Event()
async def first_post_then_404(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["method"] == "POST" and initialize_seen.is_set():
await send({"type": "http.response.start", "status": 404, "headers": []})
await send({"type": "http.response.body", "body": b""})
return
if scope["type"] == "http" and scope["method"] == "POST":
initialize_seen.set()
await real_app(scope, receive, send)
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(first_post_then_404), base_url=BASE_URL) as http_client,
):
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
with anyio.fail_after(5): # pragma: no branch
async with Client(transport, mode="legacy") as client: # pragma: no branch
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await client.list_tools()
assert exc_info.value.error == snapshot(ErrorData(code=INVALID_REQUEST, message="Session terminated"))
def _blocking_server(started: anyio.Event, cancelled: anyio.Event) -> Server:
"""A server whose `block` tool parks until cancelled; `echo` answers normally."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "block":
started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
cancelled.set()
raise
assert params.name == "echo"
return CallToolResult(content=[TextContent(text="ok")])
return Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("client-transport:http:cancel-closes-stream")
async def test_at_2026_abandoning_a_call_closes_its_stream_and_posts_nothing() -> None:
"""At 2026-07-28, abandoning an in-flight call aborts that call's own POST - the server sees
the disconnect and cancels exactly that handler - and no notifications/cancelled is POSTed.
The follow-up echo call bounds the negative: POSTs leave the client's writer serially, so a
cancel frame would have to appear before the echo's POST.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
requests: list[tuple[str, bytes]] = []
async def record(request: httpx.Request) -> None:
requests.append((request.method, request.content))
server = _blocking_server(handler_started, handler_cancelled)
async with mounted_app(server, on_request=record) as (http, _):
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http)
async with Client(transport, mode="2026-07-28") as client:
await client.list_tools() # settles the schema cache so the calls below add no refresh POST
abandon = anyio.CancelScope()
async def call_and_abandon() -> None:
with abandon:
await client.call_tool("block", {})
raise NotImplementedError # unreachable: the call never resolves
async with anyio.create_task_group() as tg:
tg.start_soon(call_and_abandon)
with anyio.fail_after(5):
await handler_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await handler_cancelled.wait()
result = await client.call_tool("echo", {})
assert result.content == [TextContent(text="ok")]
wire = [(method, json.loads(body)["method"] if body else None) for method, body in requests]
assert wire == snapshot([("POST", "tools/list"), ("POST", "tools/call"), ("POST", "tools/call")])
@requirement("client-transport:http:cancel-posts-frame")
async def test_at_2025_abandoning_a_call_posts_exactly_one_cancelled_frame() -> None:
"""At 2025-era revisions, abandoning an in-flight call POSTs one notifications/cancelled
naming the abandoned request's id - the frame is the legacy HTTP spelling of cancellation,
and it interrupts the server-side handler.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
requests: list[tuple[str, bytes]] = []
async def record(request: httpx.Request) -> None:
requests.append((request.method, request.content))
server = _blocking_server(handler_started, handler_cancelled)
async with mounted_app(server, on_request=record) as (http, _):
async with client_via_http(http) as client:
abandon = anyio.CancelScope()
async def call_and_abandon() -> None:
with abandon:
await client.call_tool("block", {})
raise NotImplementedError # unreachable: the call never resolves
async with anyio.create_task_group() as tg:
tg.start_soon(call_and_abandon)
with anyio.fail_after(5):
await handler_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await handler_cancelled.wait()
# Let the abandoned call's late error response arrive and be dropped while the
# client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
posts = [json.loads(body) for method, body in requests if method == "POST" and body]
block_calls = [p for p in posts if p.get("method") == "tools/call" and p["params"]["name"] == "block"]
cancels = [p for p in posts if p.get("method") == "notifications/cancelled"]
assert len(block_calls) == 1
assert [c["params"]["requestId"] for c in cancels] == [block_calls[0]["id"]]
+129
View File
@@ -0,0 +1,129 @@
"""Transport-level composed flows: multi-client isolation, reconnection, and dual-transport hosting.
These scenarios are about how the transport layer holds together across more than one connection
or more than one transport, so they connect real `Client`s against one mounted server rather than
running over the matrix.
"""
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, LoggingMessageNotificationParams, TextContent
from mcp.client.session import LoggingFnT
from mcp.server.mcpserver import Context, MCPServer
from tests.interaction._connect import client_via_http, connect_over_sse, mounted_app
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("flow:multi-client:stateful-isolation")
async def test_concurrent_clients_on_one_stateful_server_receive_only_their_own_notifications() -> None:
"""Two clients on one stateful manager each receive only the notifications their own request produced.
Complements `test_terminating_one_session_leaves_others_working` (which proves session
independence under termination) with the notification-isolation dimension: a notification
emitted by one session's handler does not leak to another session's client.
"""
mcp = MCPServer("multi")
@mcp.tool()
async def announce(label: str, ctx: Context) -> str:
"""Emit one info-level log carrying the caller's label, then return it."""
await ctx.info(label) # pyright: ignore[reportDeprecated]
return label
received_a: list[object] = []
received_b: list[object] = []
async def collect_a(params: LoggingMessageNotificationParams) -> None:
received_a.append(params.data)
async def collect_b(params: LoggingMessageNotificationParams) -> None:
received_b.append(params.data)
async with mounted_app(mcp) as (http, _):
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def call(label: str, collect: LoggingFnT) -> None:
async with client_via_http(http, logging_callback=collect) as client:
await client.call_tool("announce", {"label": label})
tg.start_soon(call, "a", collect_a)
tg.start_soon(call, "b", collect_b)
assert received_a == ["a"]
assert received_b == ["b"]
@requirement("flow:session:terminate-then-reconnect")
async def test_a_fresh_connection_after_termination_obtains_a_new_session_and_operates() -> None:
"""After a client terminates, a fresh connection to the same manager gets a distinct session.
Steps: (1) connect a client and call list_tools, (2) the client exits (its DELETE fires),
(3) connect a second client to the same mounted app, (4) the second client's call_tool
succeeds and the recorded session ids show two distinct sessions were issued.
"""
mcp = MCPServer("reconnectable")
@mcp.tool()
def echo(text: str) -> str:
"""Return the input unchanged."""
return text
session_ids: list[str] = []
async def record(request: httpx.Request) -> None:
session_id = request.headers.get("mcp-session-id")
if session_id is not None:
session_ids.append(session_id)
async with mounted_app(mcp, on_request=record) as (http, _):
async with client_via_http(http) as first:
first_result = await first.list_tools()
async with client_via_http(http) as second:
second_result = await second.call_tool("echo", {"text": "again"})
assert {tool.name for tool in first_result.tools} == {"echo"}
assert second_result == snapshot(
CallToolResult(content=[TextContent(text="again")], structured_content={"result": "again"})
)
distinct = set(session_ids)
assert len(distinct) == 2, f"expected two distinct session ids across the two connections, saw {distinct}"
@requirement("flow:compat:dual-transport-server")
async def test_one_server_serves_streamable_http_and_sse_clients_concurrently() -> None:
"""One MCPServer instance serves a streamable-HTTP client and a legacy-SSE client at the same time.
The two transports have independent connection management (the streamable-HTTP session manager
versus a per-connection SSE handler), but both dispatch into the same server's request
handlers. The test connects one client over each transport against the same instance and
proves both reach the same tool. Uses MCPServer because the low-level Server has no SSE
convenience; the entry is about hosting composition, not the low-level API.
"""
mcp = MCPServer("dual")
@mcp.tool()
def echo(text: str) -> str:
"""Return the input unchanged."""
return text
async with (
mounted_app(mcp) as (http, _),
connect_over_sse(mcp) as sse_client,
client_via_http(http) as shttp_client,
):
with anyio.fail_after(5):
shttp_result = await shttp_client.call_tool("echo", {"text": "via http"})
sse_result = await sse_client.call_tool("echo", {"text": "via sse"})
assert shttp_result == snapshot(
CallToolResult(content=[TextContent(text="via http")], structured_content={"result": "via http"})
)
assert sse_result == snapshot(
CallToolResult(content=[TextContent(text="via sse")], structured_content={"result": "via sse"})
)
@@ -0,0 +1,381 @@
"""Streamable HTTP semantics: status codes, header validation, message routing, and security.
These tests speak HTTP directly to the server's mounted ASGI app via the in-process bridge,
asserting the wire contract -- which status code answers which condition, which stream a message
travels on -- that the SDK client never exposes. Transport-agnostic behaviour is covered by the
`connect`-fixture matrix.
"""
import anyio
import pytest
from anyio.lowlevel import checkpoint
from httpx_sse import ServerSentEvent, aconnect_sse
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_PARAMS,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolRequestParams,
CallToolResult,
EmptyResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListResourcesResult,
ListToolsResult,
PaginatedRequestParams,
SetLevelRequestParams,
SubscribeRequestParams,
TextContent,
)
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp.server import Server, ServerRequestContext
from mcp.server.transport_security import TransportSecuritySettings
from tests.interaction._connect import (
base_headers,
initialize_body,
initialize_via_http,
mounted_app,
parse_sse_messages,
)
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _server() -> Server:
"""A low-level server with one tool that emits a related and an unrelated notification."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
"""Registered only so the tools capability is advertised; never called."""
raise NotImplementedError
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "narrate"
await ctx.session.send_log_message(level="info", data="related", logger=None, related_request_id=ctx.request_id) # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///watched.txt")
return CallToolResult(content=[TextContent(text="done")])
async def set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
"""Registered so the resources capability is advertised; the client never lists resources."""
raise NotImplementedError
async def subscribe_resource(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult:
"""Registered so the resources subscribe sub-capability is advertised; the client never subscribes."""
raise NotImplementedError
return Server( # pyright: ignore[reportDeprecated]
"hosted",
on_list_tools=list_tools,
on_call_tool=call_tool,
on_set_logging_level=set_logging_level,
on_list_resources=list_resources,
on_subscribe_resource=subscribe_resource,
)
@requirement("hosting:http:method-405")
async def test_unsupported_http_methods_return_405() -> None:
"""PUT and PATCH on the MCP endpoint return 405 with an Allow header naming the supported methods."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
put = await http.put("/mcp", json={}, headers=base_headers(session_id=session_id))
patch = await http.patch("/mcp", json={}, headers=base_headers(session_id=session_id))
assert (put.status_code, put.headers.get("allow")) == snapshot((405, "GET, POST, DELETE"))
assert (patch.status_code, patch.headers.get("allow")) == snapshot((405, "GET, POST, DELETE"))
@requirement("hosting:http:accept-406")
async def test_missing_accept_media_types_return_406() -> None:
"""A POST whose Accept header lacks both required types, or a GET lacking text/event-stream, returns 406."""
async with mounted_app(_server()) as (http, _):
post = await http.post(
"/mcp", json=initialize_body(), headers={"accept": "text/plain", "mcp-protocol-version": "2025-11-25"}
)
session_id = await initialize_via_http(http)
get = await http.get(
"/mcp",
headers={"accept": "application/json", "mcp-protocol-version": "2025-11-25", "mcp-session-id": session_id},
)
assert (post.status_code, post.json()["error"]["message"]) == snapshot(
(406, "Not Acceptable: Client must accept both application/json and text/event-stream")
)
assert (get.status_code, get.json()["error"]["message"]) == snapshot(
(406, "Not Acceptable: Client must accept text/event-stream")
)
@requirement("hosting:http:content-type-415")
async def test_non_json_content_type_is_rejected() -> None:
"""A POST with a non-JSON Content-Type is rejected before reaching the transport.
See the divergence on the requirement: the security middleware rejects with 400, so the
transport's own 415 path is unreachable through any public entry point.
"""
async with mounted_app(_server()) as (http, _):
response = await http.post(
"/mcp", content=b"<not-json/>", headers=base_headers() | {"content-type": "text/plain"}
)
assert (response.status_code, response.text) == snapshot((400, "Invalid Content-Type header"))
@requirement("hosting:http:parse-error-400")
@requirement("hosting:http:batch")
async def test_malformed_and_batched_bodies_return_400() -> None:
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid params."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
not_json = await http.post(
"/mcp",
content=b"this is not json",
headers=base_headers(session_id=session_id) | {"content-type": "application/json"},
)
batched = await http.post(
"/mcp",
json=[
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
],
headers=base_headers(session_id=session_id),
)
assert not_json.status_code == 400
assert JSONRPCError.model_validate_json(not_json.text).error.code == PARSE_ERROR
assert batched.status_code == 400
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_PARAMS
@requirement("hosting:http:protocol-version-400")
@requirement("hosting:http:protocol-version-default")
async def test_protocol_version_header_is_validated() -> None:
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default.
An unrecognised header value routes to the modern entry (which owns rejection of unknown
versions), and a request without the per-request envelope is rejected at the first ladder
rung. Only known initialize-handshake versions and an absent header reach the legacy path.
"""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
bad = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "1991-01-01"},
)
# Only Accept and the session ID -- no MCP-Protocol-Version header at all.
defaulted = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": 0, "progress": 1}},
headers={"accept": "application/json, text/event-stream", "mcp-session-id": session_id},
)
assert bad.status_code == 400
assert JSONRPCError.model_validate_json(bad.text).error.code == INVALID_PARAMS
# 202 proves the request was accepted under the assumed default version (2025-03-26).
assert defaulted.status_code == 202
@requirement("hosting:http:protocol-version-rejection-literal")
async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_literal() -> None:
"""The 400 body for an unsupported MCP-Protocol-Version contains the substring peer SDKs sniff.
SDK-defined: other SDKs detect this rejection by substring-matching ``Unsupported protocol
version`` in the response body, so the literal must survive any rewording of the surrounding
message. The unsupported value must appear in both the header and the envelope so the
classifier reaches its version-supported rung rather than reporting a header mismatch first.
"""
bad = "1991-01-01"
meta = {
PROTOCOL_VERSION_META_KEY: bad,
CLIENT_INFO_META_KEY: {"name": "t", "version": "0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
async with mounted_app(_server()) as (http, _):
response = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}},
headers=base_headers() | {"mcp-protocol-version": bad, "mcp-method": "tools/list"},
)
assert response.status_code == 400
error = JSONRPCError.model_validate_json(response.text).error
assert error.code == UNSUPPORTED_PROTOCOL_VERSION
assert "Unsupported protocol version" in response.text
assert error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": bad}
@requirement("hosting:http:json-response-mode")
async def test_json_response_mode_answers_with_application_json_not_sse() -> None:
"""With JSON response mode enabled, request POSTs are answered with a single application/json body.
Asserted at the wire level because the SDK client parses either representation, so a
Client-driven round trip cannot distinguish a JSON response from an SSE one.
"""
async with mounted_app(_server(), json_response=True) as (http, _):
initialized = await http.post("/mcp", json=initialize_body(), headers=base_headers())
session_id = initialized.headers["mcp-session-id"]
ping = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
headers=base_headers(session_id=session_id),
)
assert initialized.status_code == 200
assert initialized.headers["content-type"].split(";", 1)[0] == "application/json"
assert JSONRPCResponse.model_validate(initialized.json()).id == 1
assert ping.status_code == 200
assert ping.headers["content-type"].split(";", 1)[0] == "application/json"
assert JSONRPCResponse.model_validate(ping.json()).id == 2
@requirement("hosting:http:notifications-202")
async def test_notification_post_returns_202_with_no_body() -> None:
"""A POST containing only a notification (no request ID) returns 202 Accepted with no body."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
response = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": 0, "progress": 1}},
headers=base_headers(session_id=session_id),
)
assert (response.status_code, response.content) == snapshot((202, b""))
@requirement("hosting:http:second-sse-rejected")
async def test_a_second_standalone_get_stream_on_the_same_session_returns_409() -> None:
"""Opening a second standalone GET SSE stream while one is already established returns 409 Conflict."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as first:
assert first.response.status_code == 200
# The standalone-stream writer registers its key as its first action, then parks
# awaiting messages; one yield to the loop lets that registration complete before the
# second GET is dispatched.
await checkpoint()
second = await http.get("/mcp", headers=base_headers(session_id=session_id))
assert (second.status_code, second.json()["error"]["message"]) == snapshot(
(409, "Conflict: Only one SSE stream is allowed per session")
)
@requirement("hosting:http:standalone-sse")
@requirement("hosting:http:standalone-sse-no-response")
@requirement("hosting:http:response-same-connection")
@requirement("hosting:http:sse-close-after-response")
@requirement("hosting:http:no-broadcast")
async def test_messages_are_routed_to_exactly_one_stream() -> None:
"""Each server message travels on exactly one SSE stream and is never broadcast.
A streamable-HTTP session has two kinds of server-to-client SSE stream: one short-lived stream
per POST request, carrying that request's response and any notifications related to it, and one
long-lived standalone stream (opened by GET) for notifications not tied to any request. The
spec's routing rule is that the POST stream delivers the response (and its related
notifications) and then closes, the standalone stream carries only unrelated notifications and
never a JSON-RPC response, and no message appears on both. The test opens both streams, calls a
tool whose handler emits one related and one unrelated notification, and asserts each message's
routing.
"""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
post_events: list[ServerSentEvent] = []
get_events: list[ServerSentEvent] = []
async def read_standalone_stream() -> None:
async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as get:
assert get.response.status_code == 200
standalone_ready.set()
async for event in get.aiter_sse():
get_events.append(event)
seen_on_standalone.set()
standalone_ready = anyio.Event()
seen_on_standalone = anyio.Event()
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(read_standalone_stream)
await standalone_ready.wait()
params = CallToolRequestParams(name="narrate", arguments={})
body = JSONRPCRequest(jsonrpc="2.0", id=5, method="tools/call", params=params.model_dump())
async with aconnect_sse(
http,
"POST",
"/mcp",
json=body.model_dump(by_alias=True, exclude_none=True),
headers=base_headers(session_id=session_id),
) as post:
assert post.response.status_code == 200
# The POST stream iterator ends when the server closes the stream after the response.
post_events = [event async for event in post.aiter_sse()]
await seen_on_standalone.wait()
tg.cancel_scope.cancel()
post_messages = parse_sse_messages(post_events)
get_messages = parse_sse_messages(get_events)
# POST stream: the related log notification, then the response, then the iterator ends (close).
assert [type(m).__name__ for m in post_messages] == snapshot(["JSONRPCNotification", "JSONRPCResponse"])
assert isinstance(post_messages[0], JSONRPCNotification)
assert (post_messages[0].method, post_messages[0].params) == snapshot(
("notifications/message", {"level": "info", "data": "related"})
)
assert isinstance(post_messages[1], JSONRPCResponse)
assert post_messages[1].id == 5
# Standalone stream: only the unrelated resource-updated notification, never a response.
assert [type(m).__name__ for m in get_messages] == snapshot(["JSONRPCNotification"])
assert isinstance(get_messages[0], JSONRPCNotification)
assert get_messages[0].method == snapshot("notifications/resources/updated")
@requirement("hosting:http:dns-rebinding")
@requirement("transport:streamable-http:origin-validation")
async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> None:
"""A disallowed Origin returns 403 (and Host 421) with protection enabled; disabled lets both through.
See the divergence on hosting:http:dns-rebinding: the spec's Origin validation is an
unconditional MUST, but the SDK enables it only when the host is localhost (or settings are
passed explicitly) and additionally checks the Host header (returning 421), which the spec
does not require.
"""
# transport_security=None triggers the localhost auto-enable behaviour.
async with mounted_app(Server("guarded"), transport_security=None) as (http, _):
bad_origin = await http.post(
"/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"}
)
bad_host = await http.post("/mcp", json=initialize_body(), headers=base_headers() | {"host": "evil.example"})
async with aconnect_sse(
http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://127.0.0.1:8000"}
) as ok:
assert ok.response.status_code == 200
assert [event async for event in ok.aiter_sse()]
assert (bad_origin.status_code, bad_origin.text) == snapshot((403, "Invalid Origin header"))
assert (bad_host.status_code, bad_host.text) == snapshot((421, "Invalid Host header"))
async with mounted_app(
Server("unguarded"), transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
) as (http, _):
async with aconnect_sse(
http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"}
) as unguarded:
status = unguarded.response.status_code
assert [event async for event in unguarded.aiter_sse()]
assert status == 200
@@ -0,0 +1,616 @@
"""Streamable HTTP at protocol version 2026-07-28: the single-exchange stateless serving entry.
These tests speak HTTP directly to the server's mounted ASGI app via the in-process bridge,
asserting the wire contract for a 2026-07-28 POST -- one self-contained request, no initialize
handshake, no ``Mcp-Session-Id``, JSON response body -- and that 2025-era traffic on the same
endpoint is byte-unchanged. The SDK client never exposes the response headers or the raw
result-envelope shape, so every assertion here is necessarily wire-level.
"""
import json
from collections.abc import Callable
from typing import Any, Literal
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolRequestParams,
CallToolResult,
DiscoverResult,
EmptyResult,
Implementation,
JSONRPCError,
JSONRPCResponse,
ListToolsResult,
PaginatedRequestParams,
Request,
RequestParams,
Result,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.version import LATEST_MODERN_VERSION
from mcp import MCPError
from mcp.client.client import Client
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]:
"""Request headers for a 2026-07-28 POST.
The Accept/Content-Type baseline plus the ``MCP-Protocol-Version`` routing header and the
``Mcp-Method`` / ``Mcp-Name`` advisory headers a 2026-era client always sends.
"""
headers = base_headers() | {"mcp-protocol-version": LATEST_MODERN_VERSION, "mcp-method": method}
if name is not None:
headers["mcp-name"] = name
return headers
def _meta_envelope() -> dict[str, object]:
"""The per-request ``_meta`` envelope a 2026-07-28 client stamps on every request.
Replaces the 2025-era initialize handshake: protocol version, client info, and client
capabilities travel on each request instead of once per session.
"""
return {
"io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION,
"io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server:
"""A low-level server with one ``add`` tool for the raw-httpx tests below."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
tool = Tool(name="add", input_schema={"type": "object"})
return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "add"
assert params.arguments is not None
if on_meta is not None:
assert ctx.meta is not None
on_meta(dict(ctx.meta))
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("hosting:http:modern:tools-call-stateless")
async def test_modern_tools_call_returns_result_type_complete_without_initialize() -> None:
"""A 2026-07-28 tools/call is served without an initialize handshake and returns resultType: complete.
Spec-mandated under the draft transport: the per-request ``_meta`` envelope replaces initialize,
and ``resultType`` is the 2026 result-envelope discriminator (``complete`` for the monolith
result). Asserted at the wire because the SDK client never surfaces ``resultType`` and because
the absence of any prior request on the connection is the assertion.
"""
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()},
}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="add"))
assert response.status_code == 200
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
parsed = JSONRPCResponse.model_validate(response.json())
assert parsed.id == 1
assert parsed.result == snapshot(
{"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"}
)
@requirement("hosting:http:modern:no-session-id")
async def test_modern_response_carries_no_session_id_header() -> None:
"""A 2026-07-28 response never sets ``Mcp-Session-Id``.
Spec-mandated under the draft transport: the 2026-07-28 exchange is sessionless by definition,
so the header that the 2025-era transport always sets on responses must be absent. Asserted at
the wire because the SDK client never exposes response headers.
"""
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()},
}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="add"))
assert response.status_code == 200
assert "mcp-session-id" not in response.headers
@requirement("hosting:http:modern:initialize-removed")
async def test_modern_initialize_is_method_not_found() -> None:
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the kernel's
method/version gate rejects it before any handler runs. The body must carry the per-request
``_meta`` envelope so the classifier ladder admits it as far as kernel dispatch -- without the
envelope the request is INVALID_PARAMS at rung 1, never METHOD_NOT_FOUND. Asserted at the wire
because the SDK client at 2026-07-28 never sends initialize, so only a raw POST can drive the
negative.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="initialize"))
assert response.status_code == 404
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
@requirement("hosting:http:modern:legacy-fallthrough")
async def test_legacy_version_header_falls_through_and_unrecognised_header_routes_to_modern() -> None:
"""SDK-defined under the draft versioning rules: only the known initialize-handshake protocol
versions reach the legacy transport, so a 2025-era ``initialize`` on the same endpoint still
completes unchanged. Any other ``MCP-Protocol-Version`` value routes to the modern entry,
where the validation ladder rejects it (a request without the per-request envelope fails the
first rung). The modern entry is therefore the single owner of unknown-version rejection.
"""
async with mounted_app(_server()) as (http, _):
# 2025-era initialize through the same endpoint: the modern branch must not intercept it.
session_id = await initialize_via_http(http)
unrecognised = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "9999-01-01"},
)
assert unrecognised.status_code == 400
assert JSONRPCError.model_validate_json(unrecognised.text).error.code == INVALID_PARAMS
@requirement("hosting:http:modern:handler-exception-internal-error")
async def test_modern_handler_exception_maps_to_internal_error_without_leaking_the_message() -> None:
"""A handler exception on the 2026-07-28 path returns -32603 with a generic message.
Spec-mandated for the code: -32603 is the JSON-RPC Internal error code. SDK-defined for the
message: the 2026-07-28 entry deliberately does not echo ``str(exc)`` (the legacy dispatcher's
code-0 leak is the recorded divergence on ``protocol:error:internal-error``). Asserted at the
wire because the SDK client surfaces only the error object, not the HTTP status it travelled on.
"""
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "boom"
raise RuntimeError("kaboom")
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "boom", "arguments": {}, "_meta": _meta_envelope()},
}
async with mounted_app(Server("modern", on_call_tool=call_tool)) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="boom"))
assert response.status_code == 200
error = JSONRPCError.model_validate(response.json()).error
assert error.code == INTERNAL_ERROR
assert "kaboom" not in error.message
@requirement("hosting:http:modern:discover-response-shape")
async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None:
"""A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions.
Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces
the initialize-response payload, and ``supportedVersions`` is the field a client picks its
per-request envelope version from. Asserted at the wire because the SDK client never exposes
the raw result body.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="server/discover"))
assert response.status_code == 200
result = JSONRPCResponse.model_validate(response.json()).result
assert result["supportedVersions"] == snapshot(["2026-07-28"])
assert result["serverInfo"]["name"] == "modern"
assert "capabilities" in result
@requirement("hosting:http:modern:removed-method-status-404")
async def test_modern_removed_method_is_method_not_found_at_http_404() -> None:
"""A 2026-07-28 ping (removed at 2026) is answered METHOD_NOT_FOUND and the HTTP status is 404.
Spec-mandated for the error code: ping is not a defined method at 2026-07-28 so the kernel's
method/version gate rejects it. SDK-defined for the HTTP status: kernel-origin METHOD_NOT_FOUND
travels through the same error-code-to-status table as classifier-origin errors. Asserted at the
wire because the HTTP status is the assertion.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="ping"))
assert response.status_code == 404
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
@requirement("hosting:http:modern:envelope-missing-key-status-400")
async def test_modern_envelope_missing_required_meta_key_is_invalid_params_at_http_400() -> None:
"""A 2026-07-28 request whose ``_meta`` envelope omits a required key is INVALID_PARAMS at HTTP 400.
Spec-mandated under the draft transport: the per-request envelope must carry every reserved key,
so a missing ``clientCapabilities`` fails the classifier's first rung before any kernel dispatch.
Asserted at the wire because the HTTP status is the assertion.
"""
incomplete = _meta_envelope()
del incomplete[CLIENT_CAPABILITIES_META_KEY]
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": incomplete}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/list"))
assert response.status_code == 400
assert JSONRPCError.model_validate(response.json()).error.code == INVALID_PARAMS
@requirement("hosting:http:modern:handler-error-status-via-table")
async def test_modern_handler_raised_mcperror_maps_to_status_via_error_code_table() -> None:
"""A handler-raised ``MCPError`` reaches the wire as a top-level JSON-RPC error at the table-mapped HTTP status.
SDK-defined for the HTTP status: the modern entry maps every JSON-RPC ``error.code`` -- whether
classifier-origin or handler-origin -- through one error-code-to-status table, so a handler
raising ``MISSING_REQUIRED_CLIENT_CAPABILITY`` produces HTTP 400 with ``error.data`` preserved.
Spec-mandated for the error code: the named code and its ``requiredCapabilities`` data shape are
the spec's capability-gating contract. Registered via the low-level ``add_request_handler`` so
the high-level tool wrapper's error-swallowing is not on the path.
"""
async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyResult:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="sampling required",
data={"requiredCapabilities": ["sampling"]},
)
server = _server()
server.add_request_handler("test/cap-check", RequestParams, cap_check)
body = {"jsonrpc": "2.0", "id": 1, "method": "test/cap-check", "params": {"_meta": _meta_envelope()}}
async with mounted_app(server) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="test/cap-check"))
assert response.status_code == 400
error = JSONRPCError.model_validate(response.json()).error
assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert error.data == {"requiredCapabilities": ["sampling"]}
@requirement("hosting:http:modern:tools-call-stateless")
@requirement("lifecycle:stateless:request-envelope")
@requirement("lifecycle:stateless:caller-meta-preserved")
@requirement("client-transport:http:body-derived-headers")
async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern_entry() -> None:
"""First end-to-end exercise of the 2026-07-28 stateless request style: SDK client to SDK server.
Spec-mandated under the draft stateless transport: the pinned ``ClientSession`` and the
single-exchange serving entry compose so that ``call_tool`` returns ``resultType: complete``
with no ``initialize`` ever sent, no ``Mcp-Session-Id`` on any request or response, and every
POST carrying the body-derived ``MCP-Protocol-Version`` / ``Mcp-Method`` / ``Mcp-Name`` headers
plus the three-key ``io.modelcontextprotocol/*`` ``_meta`` envelope. The caller passes a
``custom-key`` under ``meta=`` and the server handler captures the incoming ``ctx.meta``,
proving the envelope merge is additive: the caller's key sits alongside the three envelope keys
on the wire and inside the handler. Asserted at the wire via the ``mounted_app`` httpx event
hooks because none of the headers, the envelope, or the handshake-absence is observable through
the public client API. The recorded log shows two POSTs: the ``tools/call`` itself and the
client's implicit ``tools/list`` output-schema fetch (see ``client:output-schema:auto-list``),
both of which must satisfy the stateless contract.
"""
observed_metas: list[dict[str, Any]] = []
server = _server(on_meta=observed_metas.append)
requests: list[httpx.Request] = []
responses: list[httpx.Response] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
async def on_response(response: httpx.Response) -> None:
responses.append(response)
client_info = Implementation(name="e2e-client", version="1.0.0")
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request, on_response=on_response) as (http, _),
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write),
ClientSession(read, write, client_info=client_info) as session,
):
session.adopt(
DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
)
result = await session.call_tool(
"add",
{"a": 2, "b": 3},
meta={"custom-key": "x", "io.modelcontextprotocol/protocolVersion": "evil"},
)
assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot(
{"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"}
)
# Exactly the tools/call POST and the implicit tools/list POST -- no initialize, no
# notifications/initialized, no standalone GET stream, no closing DELETE.
bodies = [json.loads(r.content) for r in requests]
assert [(r.method, body["method"]) for r, body in zip(requests, bodies, strict=True)] == snapshot(
[("POST", "tools/call"), ("POST", "tools/list")]
)
assert all("initialize" not in body["method"] for body in bodies)
# The tools/call POST carries the body-derived headers, and its _meta envelope overwrites the
# caller's colliding io.modelcontextprotocol/* key while preserving the non-colliding caller key.
call = requests[0]
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-")} == snapshot(
{"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}
)
assert bodies[0]["params"]["_meta"] == snapshot(
{
"custom-key": "x",
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
)
# The implicit tools/list carries the envelope but no caller meta: proves the envelope is
# stamped on every request, not just on requests where the caller passed meta=.
assert bodies[1]["params"]["_meta"] == snapshot(
{
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
)
# The server handler observed the same merged _meta on ctx.meta.
assert observed_metas == [bodies[0]["params"]["_meta"]]
# No session id on any request or response: the exchange is sessionless end to end.
assert len(responses) == len(requests)
assert all("mcp-session-id" not in r.headers for r in requests)
assert all("mcp-session-id" not in r.headers for r in responses)
_CUSTOM_HEADER_TOOL = Tool(
name="run",
input_schema={
"type": "object",
"properties": {
"region": {"type": "string", "x-mcp-header": "Region"},
"priority": {"type": "integer", "x-mcp-header": "Priority"},
"verbose": {"type": "boolean", "x-mcp-header": "Verbose"},
"note": {"type": "string", "x-mcp-header": "Note"},
"query": {"type": "string"},
},
"required": ["region"],
},
)
def _custom_header_server() -> Server:
"""A server with one tool whose schema annotates four args with `x-mcp-header` and leaves `query` plain."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[_CUSTOM_HEADER_TOOL], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])
return Server("custom-headers", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers() -> None:
"""A tools/call mirrors the tool's `x-mcp-header` arguments into `Mcp-Param-*` headers.
After `list_tools` caches the tool's annotations, the client renders each annotated argument into
its header per the spec's Value Encoding rules: `region` verbatim, `priority` as a decimal, `verbose`
as `false`, and the non-ASCII `note` base64-sentinel-wrapped. The unannotated `query` and the omitted
`verbose`-sibling stay out of the headers, and every mirrored value remains in the request body. Asserted
at the wire because the client never surfaces the outgoing headers.
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
await client.list_tools()
await client.call_tool("run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"})
call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call")
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot(
{
"mcp-param-region": "us-west1",
"mcp-param-priority": "42",
"mcp-param-verbose": "false",
"mcp-param-note": "=?base64?aMOpbGxv?=",
}
)
# Mirroring is additive: the arguments are unchanged in the body.
assert json.loads(call.content)["params"]["arguments"] == snapshot(
{"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"}
)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> None:
"""A `tools/call` for a tool the client never listed carries no `Mcp-Param-*` headers.
The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers.
The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the
implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header.
The server validates `Mcp-Param-*` against its own catalog and rejects as the spec's scenario table
requires for an omitted header (the relist-and-retry recovery is a SHOULD the client does not implement yet).
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
with pytest.raises(MCPError) as excinfo: # pragma: no branch
await client.call_tool("run", {"region": "us-west1"})
assert excinfo.value.error.code == HEADER_MISMATCH
assert len(requests) == 1
assert json.loads(requests[0].content)["method"] == "tools/call"
assert not any(k.startswith("mcp-param-") for k in requests[0].headers)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> None:
"""A re-list that drops a previously valid tool stops mirroring its `x-mcp-header` args.
The tool is first listed with a valid annotation (so a call mirrors `Mcp-Param-Region`), then re-listed
with an invalid annotation -- the modern client drops it and evicts the cached map, so a later `tools/call`
by name carries no `Mcp-Param-*` header. Asserted at the wire, where the eviction is observable.
"""
schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}}
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
valid = Tool(name="run", input_schema=schema)
invalid = Tool(name="run", input_schema=bad_schema)
# First listing valid, every later one invalid; the count is not pinned because the server also
# reads its own catalog on each tools/call.
listings: list[None] = []
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
listings.append(None)
return ListToolsResult(tools=[valid if len(listings) == 1 else invalid], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])
server = Server("evict", on_list_tools=list_tools, on_call_tool=call_tool)
tool_calls: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
if json.loads(request.content)["method"] == "tools/call":
tool_calls.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
assert [t.name for t in (await client.list_tools()).tools] == ["run"]
await client.call_tool("run", {"a": "x"})
assert [t.name for t in (await client.list_tools()).tools] == []
await client.call_tool("run", {"a": "x"})
before, after = tool_calls
assert before.headers.get("mcp-param-region") == "x"
assert not any(k.startswith("mcp-param-") for k in after.headers)
class _JobParams(RequestParams):
job_id: str
class _JobStatusRequest(Request[_JobParams, Literal["com.example/jobs.status"]]):
method: Literal["com.example/jobs.status"] = "com.example/jobs.status"
name_param = "jobId"
class _JobStatusResult(Result):
status: str
@requirement("client-transport:http:vendor-name-param-header")
async def test_vendor_request_with_name_param_carries_mcp_name_on_the_wire() -> None:
"""`send_request` mirrors an unregistered vendor request's `name_param` value into the
`Mcp-Name` header while the body keeps the params key unchanged."""
async def job_status(ctx: ServerRequestContext, params: _JobParams) -> _JobStatusResult:
assert params.job_id == "job-7"
return _JobStatusResult(status="running")
server = _server()
server.add_request_handler("com.example/jobs.status", _JobParams, job_status)
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
request = _JobStatusRequest(params=_JobParams(job_id="job-7"))
result = await client.session.send_request(request, _JobStatusResult)
assert result.status == "running"
[wire_request] = requests
assert wire_request.headers["mcp-name"] == "job-7"
assert json.loads(wire_request.content)["params"]["jobId"] == "job-7"
@@ -0,0 +1,449 @@
"""Resumability over the streamable HTTP transport, exercised entirely in process.
These tests configure the server with an event store, so every SSE event is stamped with an ID
and a client that loses its connection can resume by sending `Last-Event-ID`. The wire-level
tests (`mounted_app` + raw httpx) assert exactly what travels on the wire; the end-to-end test
drives the SDK client through a server-initiated stream close and proves the call still
completes. The bridge's `aclose()` delivers `http.disconnect` to the running application, so
closing a streaming response mid-read is a deterministic in-process disconnect -- no sockets,
no real time. Every server here uses `retry_interval=0` so reconnection waits are no-ops.
"""
import json
import anyio
import httpx
import pytest
from httpx_sse import EventSource, ServerSentEvent
from inline_snapshot import snapshot
from mcp_types import (
CallToolRequest,
CallToolRequestParams,
CallToolResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
LoggingMessageNotificationParams,
TextContent,
jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.message import ClientMessageMetadata
from tests.interaction._connect import (
BASE_URL,
base_headers,
connect_over_streamable_http,
initialize_via_http,
mounted_app,
parse_sse_messages,
)
from tests.interaction._requirements import requirement
from tests.interaction.transports._event_store import SequencedEventStore
pytestmark = pytest.mark.anyio
def _counting_server() -> MCPServer:
"""A server with one tool that emits related notifications and one unrelated notification."""
mcp = MCPServer("resumable")
@mcp.tool()
async def count(ctx: Context, n: int) -> str:
"""Emit n log notifications related to this call, plus one unrelated resource update."""
for i in range(1, n + 1):
await ctx.info(f"tick {i}") # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///elsewhere.txt")
return f"counted to {n}"
return mcp
def _tools_call(request_id: int, name: str, arguments: dict[str, object]) -> str:
"""A serialized tools/call JSON-RPC request body."""
return JSONRPCRequest(
jsonrpc="2.0", id=request_id, method="tools/call", params={"name": name, "arguments": arguments}
).model_dump_json(by_alias=True, exclude_none=True)
async def _read_events(response: httpx.Response, count: int) -> list[ServerSentEvent]:
"""Read exactly `count` SSE events from a streaming response without closing it."""
source = EventSource(response).aiter_sse()
return [await anext(source) for _ in range(count)]
@requirement("hosting:resume:event-ids")
@requirement("hosting:resume:priming")
async def test_a_post_sse_stream_begins_with_a_priming_event_and_stamps_every_event() -> None:
"""A request's SSE stream opens with a priming event (id, empty data, retry) then stamps each message."""
async with mounted_app(_counting_server(), event_store=SequencedEventStore(), retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream( # pragma: no branch
"POST", "/mcp", content=_tools_call(1, "count", {"n": 2}), headers=base_headers(session_id=session_id)
) as response:
assert response.status_code == 200
events = await _read_events(response, 4)
priming, first, second, result = events
# The priming event is the only event a client could have seen before any work happened, so it
# is the resumption anchor: it carries an ID and empty data. The SDK attaches the retry hint
# to this event (see the divergence on hosting:resume:priming).
assert (priming.id, priming.data, priming.retry) == snapshot(("3", "", 0))
assert priming.event == snapshot("message")
# Every subsequent event carries an event-store ID; the related notifications and the response
# all ride this stream and close it after the response.
assert [event.id for event in (first, second, result)] == snapshot(["4", "5", "7"])
assert [json.loads(event.data)["method"] for event in (first, second)] == snapshot(
["notifications/message", "notifications/message"]
)
assert jsonrpc_message_adapter.validate_json(result.data) == snapshot(
JSONRPCResponse(
jsonrpc="2.0",
id=1,
result={
"content": [{"type": "text", "text": "counted to 2"}],
"structuredContent": {"result": "counted to 2"},
"isError": False,
},
)
)
@requirement("hosting:resume:priming")
async def test_the_priming_row_is_stored_before_any_handler_output_for_that_stream() -> None:
"""The priming cursor is the first row the event store records for a request's stream.
The POST handler stores the priming row before dispatching the request, so by construction
it precedes anything `message_router` can store for that stream id.
"""
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def burst(ctx: Context) -> str:
await ctx.info("a") # pyright: ignore[reportDeprecated]
await ctx.info("b") # pyright: ignore[reportDeprecated]
await ctx.info("c") # pyright: ignore[reportDeprecated]
return "done"
async with mounted_app(mcp, event_store=store) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream( # pragma: no branch
"POST", "/mcp", content=_tools_call(2, "burst", {}), headers=base_headers(session_id=session_id)
) as response:
await _read_events(response, 5)
# initialize wrote two rows (its own priming + response); everything after is this call.
call_rows = store._events[2:]
stream_id = call_rows[0][0]
assert [(s, None if m is None else type(m).__name__) for s, m in call_rows] == [
(stream_id, None),
(stream_id, "JSONRPCNotification"),
(stream_id, "JSONRPCNotification"),
(stream_id, "JSONRPCNotification"),
(stream_id, "JSONRPCResponse"),
]
@requirement("hosting:resume:replay")
@requirement("hosting:resume:stream-scoped")
@requirement("hosting:resume:buffered-replay")
async def test_get_with_last_event_id_replays_only_that_streams_missed_events() -> None:
"""Reconnecting with Last-Event-ID returns the missed events from that one stream, in order.
The handler also emits an unrelated notification (which the server stores under the
standalone-stream key); replay must not return it, proving replay is scoped to the stream
the given event ID belongs to.
Steps: (1) initialize; (2) POST a tool call and read events until the first notification is
captured; (3) close the response mid-stream -- the bridge delivers `http.disconnect`, the
handler keeps running; (4) release the handler so it emits the remaining messages, which the
server buffers in the event store; (5) wait on the event store for the handler's response to
be stored, so the replay's content is independent of task scheduling; (6) GET with
`Last-Event-ID` and assert the replay is exactly the missed events from this request's stream.
"""
release = anyio.Event()
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def count(ctx: Context) -> str:
"""Emit one related notification, wait for the test, then emit two more plus an unrelated one."""
await ctx.info("tick 1") # pyright: ignore[reportDeprecated]
await release.wait()
await ctx.info("tick 2") # pyright: ignore[reportDeprecated]
await ctx.info("tick 3") # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///elsewhere.txt")
return "counted"
async with mounted_app(mcp, event_store=store, retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream(
"POST", "/mcp", content=_tools_call(1, "count", {}), headers=base_headers(session_id=session_id)
) as response:
# Read the priming event and the first notification, then drop the connection.
priming, first = await _read_events(response, 2)
assert (priming.id, first.id) == snapshot(("3", "4"))
last_seen = first.id
release.set()
# The handler keeps running after the disconnect; its remaining messages are stored.
# The first wait returns immediately (the priming and first tick are already stored);
# the second blocks until the response itself is stored so the replay content is fixed.
await store.wait_until_stored(4)
await store.wait_until_stored(8)
replay_headers = base_headers(session_id=session_id) | {"last-event-id": last_seen}
async with http.stream("GET", "/mcp", headers=replay_headers) as replay: # pragma: no branch
assert replay.status_code == 200
missed = await _read_events(replay, 3)
decoded = parse_sse_messages(missed)
# Exactly the two remaining related notifications and the response, with their original IDs.
assert [event.id for event in missed] == snapshot(["5", "6", "8"])
assert [type(message).__name__ for message in decoded] == snapshot(
["JSONRPCNotification", "JSONRPCNotification", "JSONRPCResponse"]
)
assert isinstance(decoded[2], JSONRPCResponse)
assert decoded[2].id == 1
# The unrelated resource-updated notification was stored under the standalone-stream key, not
# this request's stream, so it must not appear in the replay.
assert all(
not (isinstance(message, JSONRPCNotification) and message.method == "notifications/resources/updated")
for message in decoded
)
@requirement("hosting:resume:priming")
async def test_a_pre_2025_11_25_reconnect_replays_without_minting_a_priming_event() -> None:
"""A pre-2025-11-25 client reconnecting via Last-Event-ID gets the replay with no priming row.
The store-length assertion is the load-bearing proof that no priming cursor was minted.
"""
release = anyio.Event()
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def count(ctx: Context) -> str:
await ctx.info("tick 1") # pyright: ignore[reportDeprecated]
await release.wait()
await ctx.info("tick 2") # pyright: ignore[reportDeprecated]
return "counted"
async with mounted_app(mcp, event_store=store, retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream(
"POST", "/mcp", content=_tools_call(1, "count", {}), headers=base_headers(session_id=session_id)
) as response:
_, first = await _read_events(response, 2)
release.set()
await store.wait_until_stored(6)
old_client_headers = base_headers(session_id=session_id) | {
"mcp-protocol-version": "2025-06-18",
"last-event-id": first.id,
}
async with http.stream("GET", "/mcp", headers=old_client_headers) as replay: # pragma: no branch
assert replay.status_code == 200
missed = await _read_events(replay, 2)
assert [(event.id, bool(event.data)) for event in missed] == snapshot([("5", True), ("6", True)])
# No priming cursor was minted on reconnect: the store still holds only the six rows
# written before the GET (init priming+response, POST priming, tick 1, tick 2, result).
assert len(store._events) == 6
@requirement("hosting:resume:bad-event-id")
async def test_an_unknown_last_event_id_yields_an_empty_replay_stream() -> None:
"""A Last-Event-ID the event store cannot map produces an empty SSE stream rather than an error.
See the divergence on hosting:resume:bad-event-id: this pins current behaviour.
"""
async with mounted_app(_counting_server(), event_store=SequencedEventStore(), retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
for unknown in ("no-such-event", "0"):
headers = base_headers(session_id=session_id) | {"last-event-id": unknown}
async with http.stream("GET", "/mcp", headers=headers) as replay:
assert replay.status_code == 200
assert replay.headers["content-type"].startswith("text/event-stream")
events = [event async for event in EventSource(replay).aiter_sse()]
assert events == []
@requirement("hosting:http:disconnect-not-cancel")
async def test_dropping_the_connection_mid_request_does_not_cancel_the_handler() -> None:
"""Closing the request's SSE connection while the handler is running leaves the handler running.
The handler signals when it has started and when it has finished; the test drops the
connection in between and then releases the handler. If the disconnect cancelled the handler,
`finished` would never be set and the test would time out.
"""
started = anyio.Event()
release = anyio.Event()
finished = anyio.Event()
mcp = MCPServer("resumable")
@mcp.tool()
async def hold(ctx: Context) -> str:
"""Signal start, wait for the test, signal completion."""
started.set()
await release.wait()
await ctx.info("released") # pyright: ignore[reportDeprecated]
finished.set()
return "held"
async with mounted_app(mcp, event_store=SequencedEventStore(), retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream(
"POST", "/mcp", content=_tools_call(1, "hold", {}), headers=base_headers(session_id=session_id)
) as response:
await _read_events(response, 1)
await started.wait()
assert not finished.is_set()
release.set()
await finished.wait()
# This test intentionally carries every automatic-reconnection requirement: the
# close-then-resume scenario is indivisible, so splitting it would mean five near-identical bodies.
@requirement("hosting:resume:close-stream")
@requirement("transport:streamable-http:resumability")
@requirement("client-transport:http:reconnect-post-priming")
@requirement("client-transport:http:reconnect-retry-value")
@requirement("flow:resume:tool-call-resumption-token")
async def test_a_call_whose_stream_the_server_closes_is_resumed_by_the_client() -> None:
"""A server-closed request stream is reconnected by the client and the call completes.
The handler emits one notification, closes its own SSE stream, then (once released) emits
another and returns. The client observed the priming event (so it has a Last-Event-ID and a
retry hint of 0ms), sees the stream end, reconnects via GET with Last-Event-ID, and receives
the post-close notification and the result over the replay stream. The shared events make the
test deterministic: the handler only proceeds once the test knows the first notification has
arrived (and so the client's reconnection has begun).
"""
received: list[object] = []
before_seen = anyio.Event()
gate = anyio.Event()
done = anyio.Event()
mcp = MCPServer("resumable")
@mcp.tool()
async def interrupt(ctx: Context) -> str:
"""Emit, close this call's SSE stream, then emit again after the test releases the gate."""
await ctx.info("before close") # pyright: ignore[reportDeprecated]
await ctx.close_sse_stream()
await gate.wait()
await ctx.info("after close") # pyright: ignore[reportDeprecated]
done.set()
return "resumed"
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params.data)
if params.data == "before close":
before_seen.set()
result: list[CallToolResult] = []
async with connect_over_streamable_http(
mcp, event_store=SequencedEventStore(), retry_interval=0, logging_callback=collect
) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
result.append(await client.call_tool("interrupt", {}))
tg.start_soon(call)
await before_seen.wait()
gate.set()
await done.wait()
assert result == snapshot(
[CallToolResult(content=[TextContent(text="resumed")], structured_content={"result": "resumed"})]
)
assert received == snapshot(["before close", "after close"])
@requirement("client-transport:http:resume-stream-api")
async def test_a_captured_resumption_token_replays_missed_messages_on_a_new_connection() -> None:
"""A resumption token captured via on_resumption_token_update on one connection lets a fresh
connection retrieve the messages it missed by passing resumption_token to send_request.
This is the explicit ClientMessageMetadata API, distinct from the automatic reconnection the
previous test covers: the transport dispatches a resumption_token request as a GET with
Last-Event-ID instead of POSTing the body, and remaps the replayed response onto the new
request's id. Client.call_tool does not expose ClientMessageMetadata, so the test drives a
bare ClientSession via session.send_request -- the sanctioned drop-down for behaviour Client
cannot express. The second connection carries the original session id but does not initialize
(the server-side session already is), modelling a caller that resumes after a process restart.
"""
captured: list[str] = []
received: list[object] = []
first_seen = anyio.Event()
token_seen = anyio.Event()
release = anyio.Event()
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def hold(ctx: Context) -> str:
"""Emit one notification, wait for the test, emit another, return."""
await ctx.info("first") # pyright: ignore[reportDeprecated]
await release.wait()
await ctx.info("second") # pyright: ignore[reportDeprecated]
return "done"
async def on_token(token: str) -> None:
captured.append(token)
if len(captured) >= 2:
token_seen.set()
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params.data)
first_seen.set()
call = CallToolRequest(params=CallToolRequestParams(name="hold", arguments={}))
capture = ClientMessageMetadata(on_resumption_token_update=on_token)
async with mounted_app(mcp, event_store=store, retry_interval=0) as (http, manager):
with anyio.fail_after(5): # pragma: no branch
async with ( # pragma: no branch
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, terminate_on_close=False) as (r1, w1),
ClientSession(r1, w1, logging_callback=collect) as first,
anyio.create_task_group() as tg,
):
await first.initialize()
tg.start_soon(first.send_request, call, CallToolResult, None, capture)
await first_seen.wait()
await token_seen.wait()
assert captured == snapshot(["3", "4"])
assert received == snapshot(["first"])
# The session id is only observable via the manager (the client transport does not expose it).
(session_id,) = manager._server_instances
http.headers["mcp-session-id"] = session_id
http.headers["mcp-protocol-version"] = LATEST_HANDSHAKE_VERSION
tg.cancel_scope.cancel()
with anyio.fail_after(5): # pragma: no branch
release.set() # pragma: lax no cover — python/cpython#106749: 3.11 drops this line event
# init priming + init response + call priming + "first" + "second" + result = 6 stored events.
await store.wait_until_stored(6)
async with ( # pragma: no branch
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (r2, w2),
ClientSession(r2, w2, logging_callback=collect) as second,
):
result = await second.send_request(
call, CallToolResult, metadata=ClientMessageMetadata(resumption_token=captured[-1])
)
assert result == snapshot(CallToolResult(content=[TextContent(text="done")], structured_content={"result": "done"}))
assert received == snapshot(["first", "second"])
@@ -0,0 +1,202 @@
"""Streamable HTTP session lifecycle: creation, routing, termination, and stateless mode.
A test here speaks raw HTTP only when its assertion is the wire contract -- which header is
issued, which status code answers which condition -- that the SDK `Client` cannot observe.
Everything else is `Client`-driven against the same mounted session manager. Transport-agnostic
behaviour is covered by the `connect`-fixture matrix.
"""
import re
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import (
base_headers,
client_via_http,
initialize_body,
initialize_via_http,
mounted_app,
post_jsonrpc,
)
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _server() -> Server:
"""A minimal low-level server with one tool, so subsequent-request routing can be observed."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="noop", description="Does nothing.", input_schema={"type": "object"})])
return Server("hosted", on_list_tools=list_tools)
@requirement("hosting:session:create")
@requirement("hosting:session:id-charset")
async def test_initialize_issues_a_visible_ascii_session_id() -> None:
"""An initialize POST without a session ID creates a session and returns a visible-ASCII Mcp-Session-Id."""
async with mounted_app(_server()) as (http, _):
response, messages = await post_jsonrpc(http, initialize_body())
assert response.status_code == 200
session_id = response.headers.get("mcp-session-id")
assert session_id is not None
# The spec requires the session ID to consist only of visible ASCII (0x21-0x7E).
assert re.fullmatch(r"[\x21-\x7E]+", session_id)
assert isinstance(messages[0], JSONRPCResponse)
assert messages[0].id == 1
@requirement("hosting:session:reuse")
async def test_subsequent_requests_with_the_session_id_route_to_the_same_session() -> None:
"""Requests carrying the issued Mcp-Session-Id reuse that session's transport rather than creating another."""
async with mounted_app(_server()) as (http, manager):
async with client_via_http(http) as client:
await client.list_tools()
await client.list_tools()
# The session count is the only signal that distinguishes routing-to-existing from
# silently creating a second session: both produce a successful result.
assert len(manager._server_instances) == 1
@requirement("hosting:session:unknown-id")
async def test_requests_with_an_unknown_session_id_return_404() -> None:
"""POST, GET, and DELETE each carrying an unknown Mcp-Session-Id are answered 404 by the manager."""
async with mounted_app(_server()) as (http, _):
post = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
headers=base_headers(session_id="not-a-session"),
)
get = await http.get("/mcp", headers=base_headers(session_id="not-a-session"))
delete = await http.delete("/mcp", headers=base_headers(session_id="not-a-session"))
assert (post.status_code, post.json()) == snapshot(
(404, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}})
)
assert (get.status_code, delete.status_code) == (404, 404)
@requirement("hosting:session:missing-id")
async def test_non_initialize_post_without_a_session_id_returns_400() -> None:
"""A non-initialize POST that omits Mcp-Session-Id in stateful mode is rejected with 400."""
async with mounted_app(_server()) as (http, _):
await initialize_via_http(http)
response = await http.post(
"/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, headers=base_headers()
)
assert (response.status_code, response.json()) == snapshot(
(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Bad Request: Missing session ID"}})
)
@requirement("hosting:session:delete")
@requirement("hosting:session:post-termination-404")
async def test_delete_terminates_the_session_and_subsequent_requests_return_404() -> None:
"""DELETE with a valid Mcp-Session-Id terminates the session; further requests on that ID return 404."""
async with mounted_app(_server()) as (http, manager):
session_id = await initialize_via_http(http)
delete = await http.delete("/mcp", headers=base_headers(session_id=session_id))
assert delete.status_code == 200
# The manager keeps the terminated transport registered, so the next request reaches the
# transport's own _terminated check rather than the manager's unknown-session path.
assert session_id in manager._server_instances
post = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
headers=base_headers(session_id=session_id),
)
assert (post.status_code, post.json()) == snapshot(
(
404,
{
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32600, "message": "Not Found: Session has been terminated"},
},
)
)
@requirement("hosting:session:isolation")
async def test_terminating_one_session_leaves_others_working() -> None:
"""Terminating one session on a manager does not disturb a concurrent session on the same manager."""
async with mounted_app(_server()) as (http, manager):
async with client_via_http(http) as survivor:
async with client_via_http(http) as terminated:
await terminated.list_tools()
assert len(manager._server_instances) == 2
# `terminated` has exited (its DELETE has been sent); `survivor` still answers.
result = await survivor.list_tools()
assert result.tools[0].name == "noop"
@requirement("hosting:session:reinitialize")
async def test_second_initialize_on_an_existing_session_is_accepted() -> None:
"""A second initialize POST carrying an existing session ID is processed rather than rejected.
See the divergence on the requirement: the entry expects a rejection, but the SDK forwards the
second initialize to the running server, which answers it as a fresh handshake.
"""
async with mounted_app(_server()) as (http, manager):
session_id = await initialize_via_http(http)
response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id)
assert len(manager._server_instances) == 1
assert response.status_code == snapshot(200)
assert isinstance(messages[0], JSONRPCResponse)
assert messages[0].id == 2
@requirement("hosting:stateless:no-session-id")
@requirement("hosting:stateless:no-reuse")
async def test_stateless_mode_never_issues_a_session_id() -> None:
"""A stateless server issues no Mcp-Session-Id and creates no persistent transport.
The recording proves no request the SDK client sent carried an Mcp-Session-Id (the server
cannot have issued one, or the client would echo it); the empty instance map proves the
manager kept no transport between requests.
"""
requests: list[httpx.Request] = []
async def record(request: httpx.Request) -> None:
requests.append(request)
async with mounted_app(_server(), stateless_http=True, on_request=record) as (http, manager):
async with client_via_http(http) as client:
result = await client.list_tools()
assert manager._server_instances == {}
assert result.tools[0].name == "noop"
assert all("mcp-session-id" not in request.headers for request in requests)
assert "DELETE" not in {request.method for request in requests}
@requirement("hosting:stateless:concurrent-clients")
async def test_stateless_mode_serves_concurrent_clients_independently() -> None:
"""Two clients connected concurrently to the same stateless app each complete a round trip."""
results: dict[str, ListToolsResult] = {}
async with mounted_app(_server(), stateless_http=True) as (http, _):
async def list_via(label: str) -> None:
async with client_via_http(http) as client:
results[label] = await client.list_tools()
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(list_via, "a")
tg.start_soon(list_via, "b")
assert results["a"].tools[0].name == "noop"
assert results["b"].tools[0].name == "noop"
@@ -0,0 +1,85 @@
"""Legacy-wire protection: a 2025-era streamable-HTTP exchange stays free of 2026 vocabulary.
Records a full SDK client -> SDK server round trip at both seams (HTTP request/response headers
via httpx event hooks; JSON-RPC frames in both directions via the recording transport) and runs
the result through :func:`tests.interaction._modern_vocab.assert_no_modern_vocabulary`. The test
pins today's wire so any future 2026-07-28 work that leaks new fields, `_meta` keys, or headers
onto a connection negotiated at the current protocol version fails here.
"""
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.shared.message import SessionMessage
from tests.interaction._connect import BASE_URL, mounted_app
from tests.interaction._helpers import RecordingTransport
from tests.interaction._modern_vocab import RecordedExchange, assert_no_modern_vocabulary
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _server() -> Server:
"""A low-level server with one echo tool, so the recorded exchange covers tools/list and tools/call."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", description="Echo text.", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
assert params.arguments is not None
return CallToolResult(content=[TextContent(text=str(params.arguments["text"]))])
return Server("legacy", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("hosting:http:legacy-no-modern-vocabulary")
async def test_legacy_streamable_http_exchange_carries_no_modern_protocol_vocabulary() -> None:
"""A 2025-era client/server round trip emits none of the 2026-07-28 wire vocabulary.
SDK-defined under the draft versioning rules: pins the current wire so future 2026 work cannot
leak `resultType` / `ttlMs` / `cacheScope`, `io.modelcontextprotocol/*` `_meta` keys, the
`2026-07-28` literal, or `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers onto a connection
negotiated at the current protocol version. Recorded at the HTTP seam (every request and
response header) and the transport seam (every JSON-RPC frame in either direction); the SDK
client never exposes either, so the assertion is necessarily wire-level.
"""
recorded = RecordedExchange(requests=[], responses=[], frames=[])
async def on_request(request: httpx.Request) -> None:
recorded.requests.append(request)
async def on_response(response: httpx.Response) -> None:
recorded.responses.append(response)
async with mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _):
recording = RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http))
async with Client(recording, mode="legacy") as client:
result = await client.call_tool("echo", {"text": "legacy"})
assert result == snapshot(CallToolResult(content=[TextContent(text="legacy")]))
recorded.frames.extend(m.message for m in recording.sent)
recorded.frames.extend(m.message for m in recording.received if isinstance(m, SessionMessage))
# The handshake, the implicit tools/list (output-schema cache), tools/call, the standalone GET
# stream, and the closing DELETE all crossed the HTTP seam; the transport seam saw a JSON-RPC
# frame for each direction of each. Asserting non-empty so the vocabulary scan cannot pass on
# nothing recorded.
assert {r.method for r in recorded.requests} == snapshot({"POST", "GET", "DELETE"})
assert len(recorded.responses) == len(recorded.requests)
assert len(recorded.frames) >= 6
assert_no_modern_vocabulary(recorded)
+90
View File
@@ -0,0 +1,90 @@
"""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.
"""
from uuid import UUID, uuid4
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import EmptyResult
from mcp.client.client import Client
from mcp.client.sse import sse_client
from mcp.server import Server
from tests.interaction._connect import BASE_URL, build_sse_app
from tests.interaction._requirements import requirement
from tests.interaction.transports._bridge import StreamingASGITransport
pytestmark = pytest.mark.anyio
@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, mode="legacy") 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()) # pyright: ignore[reportDeprecated]
assert sse._read_stream_writers == {}
@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"))
+152
View File
@@ -0,0 +1,152 @@
"""The stdio transport: one subprocess end-to-end test and one in-process framing test.
The subprocess test proves the client-server round trip over the transport's real process
boundary; its server lives in `_stdio_server.py` and is launched via `python -m` so subprocess
coverage measurement applies. The framing test drives `stdio_server` over injected in-process
streams instead.
stdio is deliberately not a leg of the `connect`-fixture matrix: a subprocess per test would be
slow, and the matrix already proves transport-agnosticism in-process. Process-lifecycle edge
cases (terminate/kill escalation, parse errors) stay in `tests/client/test_stdio.py`.
"""
import io
import json
import os
import sys
import tempfile
from pathlib import Path
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
LoggingMessageNotificationParams,
TextContent,
)
from mcp_types.jsonrpc import jsonrpc_message_adapter
from mcp.client import stdio
from mcp.client.client import Client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.server.stdio import stdio_server
from mcp.shared.message import SessionMessage
from tests.interaction._connect import initialize_body
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")
@requirement("transport:stdio:stderr-passthrough")
async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A stdio-subprocess Client round-trips a tool call, a notification, and a clean exit.
The Client initializes, calls a tool with arguments, and receives the server's log
notification before the call returns; the server exits when the transport closes its
stdin.
"""
# After stdin closes, the child must unwind, flush its subprocess coverage data, and write
# the clean-exit line before escalation (the server saves coverage *before* printing, so a
# post-print kill can no longer silently lose the data file -- see _stdio_server.main). The
# production 2s default is too tight for the unwind+save tail on loaded Windows runners
# (measured in-situ p99 of the whole test is ~7s); a kill before the print fails the stderr
# assertion below loudly rather than tripping the coverage gate. The 20s grace covers even a
# badly starved runner (a >10s stall has been seen once in CI) and costs nothing when the
# child exits promptly. Not under test.
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 20.0)
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 filters the inherited environment, dropping the variables
# coverage.py's subprocess support uses; pass them through so the server module is
# measured. PYTHONWARNINGS: the child recompiles anyio (pytest's pyc tag differs),
# and on 3.14 anyio's return-in-finally SyntaxWarning would land on the snapshot stderr.
env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")}
| {"PYTHONWARNINGS": "ignore::SyntaxWarning"},
),
errlog=errlog,
)
# Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (20s).
with anyio.fail_after(30):
async with Client(transport, mode="legacy", logging_callback=collect) as client:
assert client.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-to-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 on stdin close: seeing it proves
# a self-exit, not the terminate escalation. The capture itself proves stderr passthrough.
assert captured_stderr == snapshot("stdio-echo: clean exit\n")
@requirement("transport:stdio:stream-purity")
@requirement("transport:stdio:no-embedded-newlines")
async def test_stdio_server_writes_one_jsonrpc_message_per_line() -> None:
"""Every `stdio_server` write is one valid JSON-RPC message on its own line.
Each line is newline-terminated with payload newlines JSON-escaped. This proves the
transport's own framing; it does not guard `sys.stdout` against handler code (see the
divergence on `transport:stdio:stream-purity`).
"""
captured = io.StringIO()
sent_line = json.dumps(initialize_body(request_id=1)) + "\n"
with anyio.fail_after(5):
async with (
stdio_server(stdin=anyio.wrap_file(io.StringIO(sent_line)), stdout=anyio.wrap_file(captured)) as (
read_stream,
write_stream,
),
read_stream,
write_stream,
):
received = await read_stream.receive()
assert isinstance(received, SessionMessage)
assert isinstance(received.message, JSONRPCRequest)
assert received.message.method == "initialize"
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={"text": "line\nbreak"})
notification = JSONRPCNotification(
jsonrpc="2.0", method="notifications/message", params={"level": "info", "data": "two\nlines"}
)
await write_stream.send(SessionMessage(response))
await write_stream.send(SessionMessage(notification))
output = captured.getvalue()
assert output.endswith("\n")
lines = output.removesuffix("\n").split("\n")
assert len(lines) == 2
messages = [jsonrpc_message_adapter.validate_json(line) for line in lines]
assert [type(message).__name__ for message in messages] == snapshot(["JSONRPCResponse", "JSONRPCNotification"])
# The newline inside the payload is JSON-escaped on the wire, not a literal newline that would
# break the one-message-per-line framing.
assert r"line\nbreak" in lines[0]
assert r"two\nlines" in lines[1]
@@ -0,0 +1,170 @@
"""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 mcp_types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequestParams,
ElicitResult,
LoggingMessageNotification,
LoggingMessageNotificationParams,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
TextContent,
)
from pydantic import BaseModel
from mcp.client import ClientRequestContext
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
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") # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///watched.txt")
return "announced"
return mcp
@requirement("transport:streamable-http:json-response")
@requirement("client-transport:http:json-response-parsed")
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.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
back-channel for server-initiated requests. The resulting ``NoBackChannelError`` is an
``MCPError``, so it surfaces as a top-level JSON-RPC error rather than an
``isError`` result."""
async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("ask", {})
assert exc_info.value.error.code == INVALID_REQUEST
@requirement("transport:streamable-http:notifications")
@requirement("transport:streamable-http:unrelated-messages")
@requirement("hosting:http:standalone-sse")
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?"])