test: interaction-model end-to-end suite with a requirements manifest (#2691)

This commit is contained in:
Max 2026-05-28 19:48:30 +01:00 • committed by GitHub
commit 24725633f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 14355 additions and 121 deletions

228
tests/interaction/README.md Normal file
View file

@ -0,0 +1,228 @@
# 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)`. 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, 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. 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.
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).
### When a new spec revision is released
1. Update `SPEC_REVISION` and walk the new revision's changelog.
2. For each changed interaction, find its requirements (the IDs use the wire method strings the
changelog speaks in), re-audit the tests against the new text, and update `source` links and
assertions where behaviour legitimately changed.
3. New interactions get new requirements and new tests; removed interactions get their
requirements deleted along with their tests.
4. A behaviour that is correct under both revisions needs no change beyond the `source` link.
## Writing a test
The shortest complete example of the conventions:
```python
@requirement("tools:call:content:text")
async def test_call_tool_returns_text_content() -> None:
"""Arguments reach the tool handler; its content comes back as the call result."""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "add"
assert params.arguments is not None
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
server = Server("adder", on_call_tool=call_tool)
async with Client(server) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
```
- **The server is defined inside the test** (or in a small fixture at the top of the file when
several tests genuinely share it). The whole observable behaviour fits on one screen.
- **Test names are behaviour sentences** — they state the observable outcome, not the feature
being poked. Docstrings add the one or two sentences of context a reviewer needs, including
whether the assertion is spec-mandated, SDK-defined, or a known divergence.
- **Handlers assert their dispatch identity first** (`assert params.name == "add"`), proving the
request that arrived is the request the test sent.
- **The result proves the round trip.** Server-side observations travel back to the test through
the protocol itself (a tool returns what it saw) or through a closure-captured list; the test
asserts after the call returns.
- **Order within a test**: server handlers → server construction → client callbacks → connect →
act → assert. The test reads in the order the conversation happens.
- A registered handler or tool that a test never invokes gets a `raise NotImplementedError` body
so it cannot silently become load-bearing.
- 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 receive loop dispatches each incoming message to completion before reading the next,
and the in-memory transport delivers everything on one ordered stream. Together these guarantee
that every notification a server handler emits before its response reaches the client callback
before the originating request returns — so tests collect notifications into a plain list and
assert after the call, with no synchronisation. The exceptions:
- a notification not triggered by a request the test is awaiting needs an `anyio.Event` set in
the receiving handler and awaited under `anyio.fail_after(5)`;
- the ordering guarantee does not survive transports that split messages across streams (the
streamable HTTP standalone GET stream) — see `transports/test_streamable_http.py`.
### Coverage
CI requires 100% line and branch coverage, including `tests/`, and `strict-no-cover` fails the
build if a line marked `# pragma: no cover` is ever executed. When a new test starts covering a
pragma'd line in `src/`, delete the pragma in the same change. Do not add new `# 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`, the `_terminated`
check in `message_router`, and the response-stream double-close guard in
`BaseSession._receive_loop`. `strict-no-cover` does not check `lax` lines; do not promote them to
strict `no cover` without first making the teardown ordering deterministic. The suite also relies
on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that closes a stream the
SSE leg would otherwise leak.

View file

View file

@ -0,0 +1,360 @@
"""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
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, Protocol
import httpx
from httpx_sse import ServerSentEvent, aconnect_sse
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route
from mcp.client.client import Client
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.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 mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)
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,
) -> AbstractAsyncContextManager[Client]: ...
@asynccontextmanager
async def connect_in_memory(
server: Server | MCPServer,
*,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server over the in-memory transport."""
async with Client(
server,
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
) as client:
yield client
@asynccontextmanager
async def connect_over_streamable_http(
server: Server | MCPServer,
*,
stateless_http: bool = False,
json_response: bool = False,
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,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's streamable HTTP app, entirely in process.
With the defaults this is the matrix leg (stateful sessions, SSE responses); the
transport-specific tests pass `stateless_http` or `json_response` to select the other
server modes, and the resumability tests pass an `event_store` (with `retry_interval=0` so
the client's reconnection wait is a no-op).
"""
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),
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
) as client,
):
yield client
@asynccontextmanager
async def 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,
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` records every outgoing HTTP request before it leaves the
yielded client.
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 = {"request": [on_request]} if on_request is not None else None
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,
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 latest 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_PROTOCOL_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_PROTOCOL_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,
) -> 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,
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
) as client:
yield client

View file

@ -0,0 +1,107 @@
"""Shared helpers for the interaction suite.
Keep this module small: it exists only for (a) types that every test would otherwise have to
assemble from the SDK's internals to annotate a client callback, and (b) the recording transport
used by the wire-level tests. Server fixtures and assertion helpers belong in the test that uses
them.
"""
from types import TracebackType
import anyio
from typing_extensions import Self
from mcp.client._transport import ReadStream, Transport, TransportStreams, WriteStream
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import ClientResult, ServerNotification, ServerRequest
# TODO: this union is the parameter type of every client message handler (MessageHandlerFnT),
# but the SDK does not export a name for it -- writing a correctly-typed handler requires
# importing RequestResponder from mcp.shared.session and assembling the union by hand. It
# should be a named, exported alias next to MessageHandlerFnT (like ClientRequestContext is
# for the request callbacks), at which point this alias can be deleted.
IncomingMessage = RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
"""Everything a client message handler can receive."""
class _RecordingReadStream:
"""Delegates to a read stream, appending every received message to a log."""
def __init__(self, inner: ReadStream[SessionMessage | Exception], log: list[SessionMessage | Exception]) -> None:
self._inner = inner
self._log = log
async def receive(self) -> SessionMessage | Exception:
item = await self._inner.receive()
self._log.append(item)
return item
async def aclose(self) -> None:
await self._inner.aclose()
def __aiter__(self) -> Self:
return self
async def __anext__(self) -> SessionMessage | Exception:
try:
return await self.receive()
except anyio.EndOfStream:
raise StopAsyncIteration from None
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> bool | None:
await self.aclose()
return None
class _RecordingWriteStream:
"""Delegates to a write stream, appending every sent message to a log."""
def __init__(self, inner: WriteStream[SessionMessage], log: list[SessionMessage]) -> None:
self._inner = inner
self._log = log
async def send(self, item: SessionMessage, /) -> None:
self._log.append(item)
await self._inner.send(item)
async def aclose(self) -> None:
await self._inner.aclose()
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> bool | None:
await self.aclose()
return None
class RecordingTransport:
"""Wraps a Transport and records every message crossing the client's transport boundary.
`sent` holds everything the client wrote towards the server; `received` holds everything the
server delivered to the client. The recording sits at the transport seam -- the exact payloads
a real transport would serialise -- and never touches the session, so wire-level assertions
written against it survive changes to the receive path.
"""
def __init__(self, inner: Transport) -> None:
self.inner = inner
self.sent: list[SessionMessage] = []
self.received: list[SessionMessage | Exception] = []
async def __aenter__(self) -> TransportStreams:
read_stream, write_stream = await self.inner.__aenter__()
return _RecordingReadStream(read_stream, self.received), _RecordingWriteStream(write_stream, self.sent)
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> bool | None:
return await self.inner.__aexit__(exc_type, exc_val, exc_tb)

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,465 @@
"""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 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.
"""
def __init__(self, *, state_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._http: httpx.AsyncClient | None = None
self._code: str = ""
self._state: 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.error = params.get("error", [None])[0]
async def callback_handler(self) -> tuple[str, str | None]:
return self._code, self._state_override if self._state_override is not None else self._state
def auth_settings(
*, required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None
) -> 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`.
"""
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),
)
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(
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client))
)
yield client, headless

View file

@ -0,0 +1,186 @@
"""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,
OAuthAuthorizationServerProvider,
RefreshToken,
TokenError,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
_TOKEN_LIFETIME_SECONDS = 3600
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,
) -> None:
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
self._issuer = "http://127.0.0.1:8000"
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] = {}
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 revoke_token(self, token: AccessToken | RefreshToken) -> None:
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
raise NotImplementedError

View file

@ -0,0 +1,300 @@
"""Error-plane behaviour of the SDK's bundled OAuth authorization-server handlers.
The end-to-end OAuth tests prove the handlers' happy paths; these tests drive the same
mounted authorization server directly with raw httpx so the assertions are the HTTP
semantics (status, redirect target, error body, headers) the OAuth RFCs mandate. Almost
every behaviour here is enforced by the SDK's own handlers; where the pinned output
deviates from the RFC, the manifest entry carries the divergence.
"""
import base64
import hashlib
import secrets
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from inline_snapshot import snapshot
from mcp.server import Server
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.shared.auth import OAuthClientInformationFull
from tests.interaction._connect import mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import REDIRECT_URI, auth_settings, oauth_client_metadata
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
@pytest.fixture
async def as_app() -> AsyncIterator[tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider]]:
"""Co-host the SDK's authorization-server routes and yield a raw httpx client against them."""
provider = InMemoryAuthorizationServerProvider()
settings = auth_settings()
async with mounted_app(
Server("guarded"),
auth=settings,
token_verifier=ProviderTokenVerifier(provider),
auth_server_provider=provider,
) as (http, _):
yield http, provider
def _pkce_pair() -> tuple[str, str]:
"""Generate a (code_verifier, code_challenge) pair the same way the SDK client does."""
verifier = secrets.token_urlsafe(48)[:64]
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
return verifier, challenge
async def _register_client(http: httpx.AsyncClient) -> OAuthClientInformationFull:
"""Dynamically register a client and return its full credentials."""
response = await http.post("/register", content=oauth_client_metadata().model_dump_json())
assert response.status_code == 201
return OAuthClientInformationFull.model_validate_json(response.content)
async def _mint_code(http: httpx.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
"""Register a client, complete a valid authorize step, and return (client_info, code, verifier)."""
client_info = await _register_client(http)
assert client_info.client_id is not None
verifier, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": "s",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
code = parse_qs(redirect.query)["code"][0]
return client_info, code, verifier
def _token_form(client_info: OAuthClientInformationFull, **overrides: str) -> dict[str, str]:
"""Build the form body for an authorization-code token request, with the defaults a real client would send."""
assert client_info.client_id is not None
assert client_info.client_secret is not None
form = {
"grant_type": "authorization_code",
"client_id": client_info.client_id,
"client_secret": client_info.client_secret,
"redirect_uri": REDIRECT_URI,
}
form.update(overrides)
return form
@requirement("hosting:auth:as:authorize-requires-pkce")
async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_request(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request omitting `code_challenge` is redirected back with `error=invalid_request`.
PKCE is mandatory: the bundled authorize handler models `code_challenge` as a required field, so
a code without a stored challenge can never be issued. That makes the PKCE-downgrade attack (a
token request carrying a verifier for a code minted without a challenge) structurally impossible
through these handlers, so no separate downgrade-guard test is needed.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"state": "abc",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
params = parse_qs(redirect.query)
assert params["error"] == ["invalid_request"]
assert params["state"] == ["abc"]
assert "code_challenge" in params["error_description"][0]
@requirement("hosting:auth:as:verifier-mismatch")
async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `code_verifier` does not hash to the stored challenge is rejected."""
http, _ = as_app
client_info, code, _ = await _mint_code(http)
response = await http.post("/token", data=_token_form(client_info, code=code, code_verifier="0" * 64))
assert response.status_code == 400
assert response.json() == snapshot({"error": "invalid_grant", "error_description": "incorrect code_verifier"})
@requirement("hosting:auth:as:code-single-use")
async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorization code can be exchanged exactly once; a second exchange is `invalid_grant`.
The handler does not track used codes itself: it returns `invalid_grant` whenever the provider's
`load_authorization_code` returns None, and the in-memory provider deletes the code on first
exchange. The test proves the combination enforces single-use; a provider that did not consume
codes would not get this guarantee from the handler.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
first = await http.post("/token", data=form)
assert first.status_code == 200
assert first.json()["token_type"] == "Bearer"
second = await http.post("/token", data=form)
assert second.status_code == 400
assert second.json() == snapshot(
{"error": "invalid_grant", "error_description": "authorization code does not exist"}
)
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_endpoint(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected.
This is the security-critical half of redirect-URI binding: a code intercepted via redirect
substitution cannot be redeemed because the attacker cannot reproduce the original authorize
redirect URI at the token endpoint. RFC 6749 §5.2 specifies `invalid_grant` for this case;
the SDK returns `invalid_request` (see the divergence on the requirement). The rejection
itself is the security property and is correct.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
response = await http.post(
"/token",
data=_token_form(client_info, code=code, code_verifier=verifier, redirect_uri=f"{REDIRECT_URI}/different"),
)
assert response.status_code == 400
assert response.json() == snapshot(
{
"error": "invalid_request",
"error_description": "redirect_uri did not match the one used when creating auth code",
}
)
@requirement("hosting:auth:as:token-cache-headers")
async def test_token_responses_carry_cache_control_no_store(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Every token-endpoint response (success and error) carries `Cache-Control: no-store`."""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
success = await http.post("/token", data=form)
assert success.status_code == 200
assert success.headers["cache-control"] == "no-store"
assert success.headers["pragma"] == "no-cache"
failure = await http.post("/token", data=form)
assert failure.status_code == 400
assert failure.headers["cache-control"] == "no-store"
assert failure.headers["pragma"] == "no-cache"
@requirement("hosting:auth:as:register-error-response")
async def test_registration_with_invalid_metadata_is_rejected_with_400(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Invalid client metadata at the registration endpoint returns 400 with an RFC 7591 error body."""
http, _ = as_app
malformed = await http.post("/register", json={"redirect_uris": ["not-a-url"]})
assert malformed.status_code == 400
assert malformed.json()["error"] == "invalid_client_metadata"
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
no_auth_code = await http.post("/register", json=body | {"grant_types": ["refresh_token"]})
assert no_auth_code.status_code == 400
assert no_auth_code.json() == snapshot(
{"error": "invalid_client_metadata", "error_description": "grant_types must include 'authorization_code'"}
)
bad_scope = await http.post("/register", json=body | {"scope": "forbidden"})
assert bad_scope.status_code == 400
body = bad_scope.json()
assert body["error"] == "invalid_client_metadata"
# The description embeds a set difference whose ordering is not stable, so assert the prefix.
assert body["error_description"].startswith("Requested scopes are not valid: ")
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request naming an unregistered `redirect_uri` returns 400 without redirecting to it.
The security property is that the authorization server never redirects to an unvalidated URI:
the response is a direct JSON error to the user agent, not a 302 to the attacker's host.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
_, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": "http://127.0.0.1:8000/evil",
"code_challenge": challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
assert response.status_code == 400
assert "location" not in response.headers
body = response.json()
assert body["error"] == "invalid_request"
assert "not registered" in body["error_description"]
@requirement("hosting:auth:as:redirect-uri-scheme")
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
The spec requires every redirect URI to be either HTTPS or a loopback host; the bundled
registration handler does not enforce this and registers `http://evil.example/callback`
successfully. See the divergence on the requirement.
"""
http, provider = as_app
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
body["redirect_uris"] = ["http://evil.example/callback"]
response = await http.post("/register", json=body)
assert response.status_code == 201
info = OAuthClientInformationFull.model_validate_json(response.content)
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
assert info.client_id in provider.clients

View file

@ -0,0 +1,399 @@
"""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 pytest
from inline_snapshot import snapshot
from pydantic import AnyHttpUrl, AnyUrl
from mcp import types
from mcp.client.auth import OAuthFlowError
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from mcp.types import ListToolsResult, Tool
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: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") == []

View file

@ -0,0 +1,189 @@
"""Resource-server bearer-token gate: status codes and `WWW-Authenticate` for each token shape.
These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier`
seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since
every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status,
the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP
endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test.
"""
import time
from collections.abc import AsyncIterator
import httpx
import pytest
from inline_snapshot import snapshot
from mcp.server import Server
from mcp.server.auth.provider import AccessToken
from mcp.types import JSONRPCResponse
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"

View file

@ -0,0 +1,333 @@
"""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 pytest
from inline_snapshot import snapshot
from pydantic import AnyHttpUrl
from mcp import types
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
from mcp.types import ListToolsResult, Tool
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()
provider = InMemoryAuthorizationServerProvider()
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(real_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_is_accepted_and_the_flow_proceeds() -> None:
"""Authorization-server metadata whose `issuer` does not match the discovery URL is accepted.
RFC 8414 §3.3 requires the client to reject the document; the SDK parses and uses it
without comparing `issuer` to the URL it was fetched from. See the divergence on the
requirement. The served body carries an unrecognized field as a fold-in proof of
unknown-field tolerance.
"""
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, x_unknown_extension="ignored")})
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, app_shim=app_shim) as (client, _):
result = await client.list_tools()
assert result.tools[0].name == "probe"

View file

@ -0,0 +1,239 @@
"""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 pytest
from inline_snapshot import snapshot
from pydantic import AnyUrl
from mcp import types
from mcp.server import Server, ServerRequestContext
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.shared.auth import OAuthClientInformationFull
from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool
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("2025-11-25")
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",
"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/"

View file

@ -0,0 +1,445 @@
"""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 pytest
from inline_snapshot import snapshot
from pydantic import AnyHttpUrl, AnyUrl
from mcp import MCPError, types
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from mcp.types import INTERNAL_ERROR, ListToolsResult, Tool
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: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

View file

@ -0,0 +1,23 @@
"""Shared fixtures for the interaction suite."""
import pytest
from tests.interaction._connect import Connect, connect_in_memory, connect_over_sse, connect_over_streamable_http
_FACTORIES: dict[str, Connect] = {
"in-memory": connect_in_memory,
"streamable-http": connect_over_streamable_http,
"sse": connect_over_sse,
}
@pytest.fixture(params=sorted(_FACTORIES))
def connect(request: pytest.FixtureRequest) -> Connect:
"""The transport-parametrized connection factory: a test using it runs once per transport.
Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests,
the transport-specific tests under transports/) do not use this fixture and connect directly.
"""
transport_name = request.param
assert isinstance(transport_name, str)
return _FACTORIES[transport_name]

View file

View file

@ -0,0 +1,234 @@
"""Cancellation interactions against the low-level Server, driven through the public Client API.
There is no client-side cancellation API: cancelling means sending a CancelledNotification
carrying the request id, which only the server-side handler can observe (`ctx.request_id`), so
these tests capture the id from inside the blocked handler before cancelling. The handler blocks
on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
"""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp import MCPError, types
from mcp.client import 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 mcp.types import (
CallToolResult,
EmptyResult,
ErrorData,
Implementation,
InitializeResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
PingRequest,
ServerCapabilities,
TextContent,
)
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:late-response-ignored")
async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_handler() -> None:
"""A response whose id matches no in-flight request is surfaced to the message handler as a RuntimeError.
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 depending on the cancellation API the SDK does not yet provide.
See the divergence note on the requirement.
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 answer with a fabricated id that matches nothing in flight, then the real id.
await server_write.send(respond(9999, EmptyResult()))
await server_write.send(respond(ping.message.id, EmptyResult()))
incoming: list[IncomingMessage] = []
async def message_handler(message: IncomingMessage) -> None:
incoming.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())
assert len(incoming) == 1
assert isinstance(incoming[0], RuntimeError)
# The full message embeds the response object's repr; only the prefix is stable.
assert str(incoming[0]).startswith("Received response with an unknown request ID:")

View file

@ -0,0 +1,131 @@
"""Completion interactions against the low-level Server, driven through the public Client API."""
import pytest
from inline_snapshot import snapshot
from mcp import MCPError, types
from mcp.server import Server, ServerRequestContext
from mcp.types import (
INVALID_PARAMS,
METHOD_NOT_FOUND,
CompleteResult,
Completion,
ErrorData,
PromptReference,
ResourceTemplateReference,
)
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("completion:prompt-arg")
@requirement("completion:result-shape")
async def test_complete_prompt_argument(connect: Connect) -> None:
"""Completing a prompt argument delivers the ref, argument name, and current value to the handler.
The returned values are filtered by the argument's value, proving the value reached the handler.
"""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, PromptReference)
assert params.ref.name == "code_review"
assert params.argument.name == "language"
candidates = ["python", "pytorch", "ruby"]
matches = [candidate for candidate in candidates if candidate.startswith(params.argument.value)]
return CompleteResult(completion=Completion(values=matches, total=len(matches), has_more=False))
server = Server("completer", on_completion=completion)
async with connect(server) as client:
result = await client.complete(
PromptReference(name="code_review"), argument={"name": "language", "value": "py"}
)
assert result == snapshot(
CompleteResult(completion=Completion(values=["python", "pytorch"], total=2, has_more=False))
)
@requirement("completion:resource-template-arg")
async def test_complete_resource_template_variable(connect: Connect) -> None:
"""Completing a URI template variable delivers the template URI and variable name to the handler."""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, ResourceTemplateReference)
assert params.ref.uri == "github://repos/{owner}/{repo}"
assert params.argument.name == "owner"
return CompleteResult(completion=Completion(values=[f"{params.argument.value}contextprotocol"]))
server = Server("completer", on_completion=completion)
async with connect(server) as client:
result = await client.complete(
ResourceTemplateReference(uri="github://repos/{owner}/{repo}"),
argument={"name": "owner", "value": "model"},
)
assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"])))
@requirement("completion:context-arguments")
async def test_complete_receives_context_arguments(connect: Connect) -> None:
"""Previously-resolved arguments passed as completion context reach the handler.
The returned value is derived from the context, proving it arrived.
"""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
assert params.argument.name == "repo"
assert params.context is not None
assert params.context.arguments is not None
return CompleteResult(completion=Completion(values=[f"{params.context.arguments['owner']}/python-sdk"]))
server = Server("completer", on_completion=completion)
async with connect(server) as client:
result = await client.complete(
ResourceTemplateReference(uri="github://repos/{owner}/{repo}"),
argument={"name": "repo", "value": ""},
context_arguments={"owner": "modelcontextprotocol"},
)
assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"])))
@requirement("completion: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.initialize_result.capabilities.completions is None
with pytest.raises(MCPError) as exc_info:
await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""})
assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))

View file

@ -0,0 +1,662 @@
"""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 pytest
from inline_snapshot import snapshot
from mcp import MCPError, UrlElicitationRequiredError, types
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 mcp.types import (
CallToolResult,
ElicitCompleteNotification,
ElicitCompleteNotificationParams,
ElicitRequestedSchema,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
ErrorData,
Implementation,
InitializeResult,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ServerCapabilities,
TextContent,
)
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
REQUESTED_SCHEMA: dict[str, object] = {
"type": "object",
"properties": {
"username": {"type": "string"},
"newsletter": {"type": "boolean"},
},
"required": ["username"],
}
@requirement("elicitation:form:action:accept")
@requirement("elicitation:form:basic")
@requirement("tools:call:elicitation-roundtrip")
async def test_elicit_form_accepted_content_returns_to_handler(connect: Connect) -> None:
"""An accepted form elicitation returns the user's content to the requesting handler.
The tool reports the action as text and the received content as structured content, proving
the client's answer made it back into the tool's own result.
"""
received: list[types.ElicitRequestParams] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="signup", description="Register the user.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "signup"
answer = await ctx.session.elicit_form("Choose a username.", REQUESTED_SCHEMA)
return CallToolResult(content=[TextContent(text=answer.action)], structured_content=answer.content)
server = Server("registrar", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"username": "ada", "newsletter": True})
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("signup", {})
assert received == snapshot(
[
ElicitRequestFormParams(
_meta={},
message="Choose a username.",
requested_schema={
"type": "object",
"properties": {
"username": {"type": "string"},
"newsletter": {"type": "boolean"},
},
"required": ["username"],
},
)
]
)
assert result == snapshot(
CallToolResult(
content=[TextContent(text="accept")],
structured_content={"username": "ada", "newsletter": True},
)
)
@requirement("elicitation:form:action:decline")
async def test_elicit_form_decline_returns_no_content(connect: Connect) -> None:
"""A declined form elicitation returns the decline action to the handler with no content."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "confirm"
answer = await ctx.session.elicit_form("Proceed?", {"type": "object", "properties": {}})
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("confirm", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="decline content=None")]))
@requirement("elicitation:form:action:cancel")
async def test_elicit_form_cancel_returns_no_content(connect: Connect) -> None:
"""A cancelled form elicitation returns the cancel action to the handler with no content."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "confirm"
answer = await ctx.session.elicit_form("Proceed?", {"type": "object", "properties": {}})
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool)
async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="cancel")
async with connect(server, elicitation_callback=answer_form) as client:
result = await client.call_tool("confirm", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="cancel content=None")]))
@requirement("elicitation:form:not-supported")
@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] = []
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": {
"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).
"""
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

View file

@ -0,0 +1,203 @@
"""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 pytest
from inline_snapshot import snapshot
from mcp import MCPError, UrlElicitationRequiredError, types
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from mcp.types import (
URL_ELICITATION_REQUIRED,
CallToolResult,
ElicitCompleteNotification,
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
EmptyResult,
ListToolsResult,
ReadResourceResult,
ResourceLink,
TextContent,
TextResourceContents,
Tool,
)
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")
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(
"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")]))

Some files were not shown because too many files have changed in this diff Show more