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
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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
@@ -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"
|
||||
@@ -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")] == []
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")]))
|
||||
@@ -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])
|
||||
@@ -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"))]
|
||||
)
|
||||
@@ -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()])
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
@@ -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"))]
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"]]
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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?"])
|
||||
Reference in New Issue
Block a user