test: interaction-model end-to-end suite with a requirements manifest (#2691)
This commit is contained in:
parent
3eb579948a
commit
24725633f1
54 changed files with 14355 additions and 121 deletions
228
tests/interaction/README.md
Normal file
228
tests/interaction/README.md
Normal 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.
|
||||
0
tests/interaction/__init__.py
Normal file
0
tests/interaction/__init__.py
Normal file
360
tests/interaction/_connect.py
Normal file
360
tests/interaction/_connect.py
Normal 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
|
||||
107
tests/interaction/_helpers.py
Normal file
107
tests/interaction/_helpers.py
Normal 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)
|
||||
2816
tests/interaction/_requirements.py
Normal file
2816
tests/interaction/_requirements.py
Normal file
File diff suppressed because it is too large
Load diff
0
tests/interaction/auth/__init__.py
Normal file
0
tests/interaction/auth/__init__.py
Normal file
465
tests/interaction/auth/_harness.py
Normal file
465
tests/interaction/auth/_harness.py
Normal 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
|
||||
186
tests/interaction/auth/_provider.py
Normal file
186
tests/interaction/auth/_provider.py
Normal 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
|
||||
300
tests/interaction/auth/test_as_handlers.py
Normal file
300
tests/interaction/auth/test_as_handlers.py
Normal 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
|
||||
399
tests/interaction/auth/test_authorize_token.py
Normal file
399
tests/interaction/auth/test_authorize_token.py
Normal 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") == []
|
||||
189
tests/interaction/auth/test_bearer.py
Normal file
189
tests/interaction/auth/test_bearer.py
Normal 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"
|
||||
333
tests/interaction/auth/test_discovery.py
Normal file
333
tests/interaction/auth/test_discovery.py
Normal 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"
|
||||
239
tests/interaction/auth/test_flow.py
Normal file
239
tests/interaction/auth/test_flow.py
Normal 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/"
|
||||
445
tests/interaction/auth/test_lifecycle.py
Normal file
445
tests/interaction/auth/test_lifecycle.py
Normal 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
|
||||
23
tests/interaction/conftest.py
Normal file
23
tests/interaction/conftest.py
Normal 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]
|
||||
0
tests/interaction/lowlevel/__init__.py
Normal file
0
tests/interaction/lowlevel/__init__.py
Normal file
234
tests/interaction/lowlevel/test_cancellation.py
Normal file
234
tests/interaction/lowlevel/test_cancellation.py
Normal 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:")
|
||||
131
tests/interaction/lowlevel/test_completion.py
Normal file
131
tests/interaction/lowlevel/test_completion.py
Normal 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"))
|
||||
662
tests/interaction/lowlevel/test_elicitation.py
Normal file
662
tests/interaction/lowlevel/test_elicitation.py
Normal 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
|
||||
203
tests/interaction/lowlevel/test_flows.py
Normal file
203
tests/interaction/lowlevel/test_flows.py
Normal 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
Loading…
Reference in a new issue