fastmcp/tests/server/test_mrtr_guards.py
Jeremiah Lowin 7699deb99c
Pass the MCP conformance suite's draft and pending scenarios
Pin the suite, build out the fixture, and fix the protocol gaps it found.
2026-07-26 15:00:08 -04:00

1275 lines
53 KiB
Python

"""Server-side guard-mode multi-round-trip (MRTR, SEP-2322).
A FastMCP tool may return an ``InputRequiredResult`` as the full result of a
call: the client fulfils the embedded requests (elicitation / sampling /
roots) and calls again — a new, complete request-response cycle — with the
answers on ``ctx.input_responses`` and the echoed opaque ``ctx.request_state``.
This is the SDK's own base "guard" model — the tool runs per round and checks
whether the client's answers are present — with FastMCP mirroring its
semantics exactly.
These tests exercise the *emission* side (a FastMCP server producing the
``InputRequiredResult`` and being driven to completion) over both in-memory and
HTTP transports, plus the request-state boundary (framework-owned sealing) and
the ≤2025-11-25 era gate. The client-side *answering* path is covered by
``tests/client/client/test_input_required_driver.py``.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
import mcp_types
import pytest
from docket import Docket
from mcp.client._input_required import InputRequiredRoundsExceededError
from mcp.server.request_state import RequestStateSecurity
from mcp.shared.exceptions import MCPError
from mcp_types import ElicitRequest, InputRequiredResult
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import Field
from typing_extensions import TypeAliasType
from fastmcp import Client, Context, FastMCP
from fastmcp.client.elicitation import ElicitResult
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fastmcp.server.middleware.middleware import Middleware
from fastmcp.tools.base import InputRequiredToolResult, ToolResult
from fastmcp.utilities.tests import run_server_async
from fastmcp_tasks import TasksExtension
from tests.tasks.task_helpers import running_task_server, submit_task, wait_for_task
def _elicit(key: str, message: str, field: str) -> ElicitRequest:
"""A single-field form elicitation request keyed by ``key``."""
params = mcp_types.ElicitRequestFormParams(
message=message,
requested_schema={
"type": "object",
"properties": {field: {"type": "string"}},
"required": [field],
},
)
return ElicitRequest(method="elicitation/create", params=params)
def _ask(
request: ElicitRequest, key: str, request_state: str | None
) -> InputRequiredResult:
return InputRequiredResult(
result_type="input_required",
input_requests={key: request},
request_state=request_state,
)
def _accepted(responses: mcp_types.InputResponses, key: str) -> dict[str, object]:
"""The accepted form content for one answered elicitation.
``ctx.input_responses`` values are the raw SDK union
(``ElicitResult | CreateMessageResult | ListRootsResult``); a guard tool
narrows to the response type it asked for.
"""
answer = responses[key]
assert isinstance(answer, mcp_types.ElicitResult)
assert answer.content is not None
return dict(answer.content)
def two_question_server(**server_kwargs) -> FastMCP:
"""A guard tool that asks two dependent questions across three rounds.
Round 1 (no responses): ask for a destination.
Round 2 (destination answered): ask for a date, carrying the destination
forward through ``request_state`` (a computed value, not re-derived).
Round 3 (date answered): read the carried destination out of
``request_state`` and finish.
"""
mcp = FastMCP("guard", **server_kwargs)
@mcp.tool
async def book_flight(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _ask(
_elicit("destination", "Where would you like to fly?", "destination"),
"destination",
request_state=None,
)
if "destination" in responses:
destination = _accepted(responses, "destination")["destination"]
return _ask(
_elicit("date", f"When to {destination}?", "date"),
"date",
request_state=f"dest={destination}",
)
assert ctx.request_state is not None
destination = ctx.request_state.split("=", 1)[1]
date = _accepted(responses, "date")["date"]
return f"Booked {destination} on {date}"
return mcp
def _two_answer_handler(asked: list[str]):
async def handler(message, response_type, params, ctx):
asked.append(message)
if "Where" in message:
return ElicitResult(
action="accept", content=response_type(destination="Paris")
)
return ElicitResult(action="accept", content=response_type(date="2026-08-01"))
return handler
class TestContextProperties:
"""`ctx.input_responses` / `ctx.request_state` thin passthroughs."""
async def test_none_outside_mrtr_round(self):
"""On a plain call (no prior InputRequiredResult), both are None."""
seen: dict[str, object] = {}
mcp = FastMCP("x")
@mcp.tool
async def probe(ctx: Context) -> str:
seen["input_responses"] = ctx.input_responses
seen["request_state"] = ctx.request_state
return "ok"
async with Client(mcp, mode="auto") as client:
await client.call_tool("probe", {})
assert seen["input_responses"] is None
assert seen["request_state"] is None
async def test_none_without_request_context(self):
"""Off-request (no bound wire request) the properties are None, not a crash."""
mcp = FastMCP("x")
async with Context(fastmcp=mcp) as ctx:
assert ctx.input_responses is None
assert ctx.request_state is None
# PEP 695 `type X = ...` aliases, built portably (the `type` statement is 3.12+).
# One factors out the whole guard union; one is a lone aliased ask arm; one is a
# composed alias whose union arm hides the guard (`str | _ComposedGuardArm`).
_AliasedGuardUnion = TypeAliasType("_AliasedGuardUnion", str | InputRequiredResult)
_AliasedAskArm = TypeAliasType("_AliasedAskArm", InputRequiredResult)
_ComposedGuardArm = TypeAliasType("_ComposedGuardArm", int | InputRequiredResult)
class _InputRequiredSubclass(InputRequiredResult):
"""A user subclass of the guard result — still a control signal, not data."""
class TestOutputSchema:
"""An `InputRequiredResult` return arm is control flow, not output data, so
it is stripped from output-schema derivation."""
def test_union_arm_stripped_keeps_data_schema(self):
from fastmcp.tools.function_tool import FunctionTool
def book(x: int) -> str | InputRequiredResult:
return "ok"
tool = FunctionTool.from_function(book)
assert tool.output_schema is not None
# Schema is derived from the residual `str` arm (wrapped as {"result": ...}).
assert tool.output_schema.get("x-fastmcp-wrap-result") is True
def test_aliased_guard_union_stripped(self):
"""A `type Result = str | InputRequiredResult` alias is unwrapped before
stripping, so the ask arm never leaks into the output schema."""
from fastmcp.tools.function_tool import FunctionTool
def book(x: int) -> _AliasedGuardUnion:
return "ok"
tool = FunctionTool.from_function(book)
assert tool.output_schema is not None
assert tool.output_schema.get("x-fastmcp-wrap-result") is True
def test_aliased_ask_arm_stripped(self):
"""A lone aliased arm (`str | AskAlias`) is recognized as a guard signal
and stripped, leaving the data arm's schema."""
from fastmcp.tools.function_tool import FunctionTool
def book(x: int) -> str | _AliasedAskArm:
return "ok"
tool = FunctionTool.from_function(book)
assert tool.output_schema is not None
assert tool.output_schema.get("x-fastmcp-wrap-result") is True
def test_composed_alias_arm_stripped(self):
"""A composed alias arm — `str | Value` where
`Value = int | InputRequiredResult` — is recursively unwrapped so the
hidden guard is stripped, leaving the flattened data arms (`str | int`)."""
from typing import get_args as _get_args
from fastmcp.tools.function_parsing import _strip_input_required
stripped = _strip_input_required(str | _ComposedGuardArm)
assert set(_get_args(stripped)) == {str, int}
def test_bare_input_required_subclass_suppresses_schema(self):
"""A bare `InputRequiredResult` *subclass* is subclass-aware suppressed,
matching `run()`'s isinstance handling, so no output schema is emitted
for data the client can never receive."""
from fastmcp.tools.function_tool import FunctionTool
def suspend_only(x: int) -> _InputRequiredSubclass:
raise NotImplementedError
tool = FunctionTool.from_function(suspend_only)
assert tool.output_schema is None
def test_bare_aliased_input_required_suppresses_schema(self):
"""A bare aliased guard return (`-> _AliasedAskArm` where the alias is
`InputRequiredResult`) is de-aliased so downstream suppression applies,
emitting no output schema."""
from fastmcp.tools.function_tool import FunctionTool
def suspend_only(x: int) -> _AliasedAskArm:
raise NotImplementedError
tool = FunctionTool.from_function(suspend_only)
assert tool.output_schema is None
def test_annotated_bare_guard_returns_suppress_schema(self):
"""The wholesale suppression also covers Annotated wrappings of a bare
guard return — including an aliased one — which exact-match replacement
would otherwise miss."""
from fastmcp.tools.function_tool import FunctionTool
def annotated_plain(x: int) -> Annotated[InputRequiredResult, Field()]:
raise NotImplementedError
def annotated_aliased(x: int) -> Annotated[_AliasedAskArm, Field()]:
raise NotImplementedError
assert FunctionTool.from_function(annotated_plain).output_schema is None
assert FunctionTool.from_function(annotated_aliased).output_schema is None
def test_bare_input_required_return_suppresses_schema(self):
from fastmcp.tools.function_tool import FunctionTool
def suspend_only(x: int) -> InputRequiredResult:
raise NotImplementedError
tool = FunctionTool.from_function(suspend_only)
assert tool.output_schema is None
class TestTransformedGuard:
"""A guard tool wrapped by TransformedTool must still emit its ask: a
non-object output_schema on the transform reshapes ordinary ToolResults,
but an InputRequiredToolResult (which carries no output data) must pass
through so the wire handler still returns the InputRequiredResult."""
async def test_transformed_guard_still_asks(self):
from fastmcp.tools.tool_transform import TransformedTool
base = two_question_server()
book = await base.get_tool("book_flight")
assert book is not None
mcp = FastMCP("transformed")
# A non-object output_schema is exactly the transform config that
# rebuilds ordinary ToolResults and would strip the ask.
transformed = TransformedTool.from_tool(
book, name="book", output_schema={"type": "string"}
)
mcp.add_tool(transformed)
# The asking (first) round must reach the wire as an InputRequiredResult
# — without the guard it would arrive as an empty terminal result.
async with Client(mcp, mode="auto") as client:
first = await client.session.call_tool(
"book", {}, allow_input_required=True
)
assert isinstance(first, InputRequiredResult)
assert "destination" in first.input_requests
async def test_transform_fn_returning_raw_ask_is_wrapped(self):
"""A custom transform_fn that returns a raw InputRequiredResult (not a
pre-wrapped InputRequiredToolResult) must still emit the ask — the
transform path wraps it like any tool body."""
from fastmcp.tools.tool_transform import TransformedTool
base = two_question_server()
book = await base.get_tool("book_flight")
assert book is not None
async def transform_fn(**kwargs) -> InputRequiredResult:
return _ask(_elicit("q", "raw ask?", "q"), "q", request_state=None)
mcp = FastMCP("raw-transform")
transformed = TransformedTool.from_tool(
book, name="raw", transform_fn=transform_fn
)
mcp.add_tool(transformed)
async with Client(mcp, mode="auto") as client:
first = await client.session.call_tool("raw", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert "q" in first.input_requests
class TestInMemoryLoop:
async def test_two_question_loop_completes(self):
"""Two dependent asks complete over the in-memory transport; the
client's elicitation handler answers both rounds."""
asked: list[str] = []
async with Client(
two_question_server(),
mode="auto",
elicitation_handler=_two_answer_handler(asked),
) as client:
assert client.protocol_version == "2026-07-28"
result = await client.call_tool("book_flight", {})
assert asked == ["Where would you like to fly?", "When to Paris?"]
assert result.data == "Booked Paris on 2026-08-01"
async def test_input_responses_visible_to_tool(self):
"""A guard tool reads the client's answer out of ctx.input_responses."""
seen: list[object] = []
mcp = FastMCP("echo")
@mcp.tool
async def ask_once(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _ask(_elicit("name", "Your name?", "name"), "name", None)
content = _accepted(responses, "name")
seen.append(content)
return f"Hi {content['name']}"
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content=response_type(name="Ada"))
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("ask_once", {})
assert seen == [{"name": "Ada"}]
assert result.data == "Hi Ada"
async def test_declined_answer_shape(self):
"""A client that declines delivers an ElicitResult with action='decline'
and no content into ctx.input_responses (not an error)."""
mcp = FastMCP("decline")
@mcp.tool
async def ask(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _ask(_elicit("x", "q", "x"), "x", None)
answer = responses["x"]
assert isinstance(answer, mcp_types.ElicitResult)
return f"action={answer.action} content={answer.content}"
async def decline_handler(message, response_type, params, ctx):
return ElicitResult(action="decline", content=None)
async with Client(
mcp, mode="auto", elicitation_handler=decline_handler
) as client:
result = await client.call_tool("ask", {})
assert result.data == "action=decline content=None"
async def test_max_rounds_exceeded_raises(self):
"""A two-round guard under max_rounds=1 raises on the client driver."""
asked: list[str] = []
async with Client(
two_question_server(),
mode="auto",
elicitation_handler=_two_answer_handler(asked),
input_required_max_rounds=1,
) as client:
with pytest.raises(InputRequiredRoundsExceededError):
await client.call_tool("book_flight", {})
class TestMountedServer:
async def test_guard_tool_through_parent(self):
"""A guard tool on a mounted child completes when called through the
parent's namespaced name — the InputRequiredToolResult forwards through
the provider delegation and is sealed/returned at the parent's wire seam."""
parent = FastMCP("parent")
parent.mount(two_question_server(), namespace="sub")
asked: list[str] = []
async with Client(
parent, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("sub_book_flight", {})
assert asked == ["Where would you like to fly?", "When to Paris?"]
assert result.data == "Booked Paris on 2026-08-01"
def _modern_proxy(backend: FastMCP) -> FastMCP:
"""A FastMCPProxy whose backend client negotiates the modern era, so the
backend can emit an `InputRequiredResult` (SEP-2322) for the proxy to
round-trip rather than drive to completion internally."""
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
return FastMCPProxy(client_factory=lambda: ProxyClient(backend, mode="auto"))
class TestProxyServer:
async def test_guard_tool_round_trips_through_proxy(self):
"""A guard tool behind a proxy completes end-to-end: the backend's ask
round-trips into an `InputRequiredToolResult` on the parent (rather than
being driven to completion inside the proxy), so the parent's wire
handler returns it to the real client, and continuation answers forward
back down to the backend."""
proxy = _modern_proxy(two_question_server())
asked: list[str] = []
async with Client(
proxy, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert asked == ["Where would you like to fly?", "When to Paris?"]
assert result.data == "Booked Paris on 2026-08-01"
async def test_proxy_parent_middleware_observes_ask(self):
"""The proxy round-trip is what lets the parent's own middleware see the
ask as a result — it is not swallowed by driving inside the proxy."""
observed: list[object] = []
class RecordingMiddleware(Middleware):
async def on_call_tool(self, context, call_next):
result = await call_next(context)
observed.append(result)
return result
proxy = _modern_proxy(two_question_server())
proxy.add_middleware(RecordingMiddleware())
asked: list[str] = []
async with Client(
proxy, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert result.data == "Booked Paris on 2026-08-01"
assert len(observed) == 3
assert isinstance(observed[0], InputRequiredToolResult)
assert isinstance(observed[1], InputRequiredToolResult)
assert not isinstance(observed[2], InputRequiredToolResult)
async def test_guard_round_trips_through_create_proxy_mode_auto(self):
"""The standard create_proxy(target, mode="auto") path round-trips a
guard without a hand-built ProxyClient factory. The default stays
handshake-era (which preserves server-initiated push forwarding); modern
proxying is a per-call opt-in because the two eras are mutually
exclusive on a single proxy session."""
from fastmcp.server import create_proxy
proxy = create_proxy(two_question_server(), mode="auto")
asked: list[str] = []
async with Client(
proxy, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert asked == ["Where would you like to fly?", "When to Paris?"]
assert result.data == "Booked Paris on 2026-08-01"
async def test_progress_forwards_through_modern_proxy(self):
"""A backend tool's `ctx.report_progress()` reaches the caller's progress
handler even when the proxy backend negotiates the modern era — the
modern branch forwards the proxy client's progress handler just like the
legacy `call_tool_mcp` path does."""
backend = FastMCP("progress-backend")
@backend.tool
async def report(ctx: Context) -> str:
await ctx.report_progress(progress=1, total=2, message="halfway")
await ctx.report_progress(progress=2, total=2, message="done")
return "ok"
proxy = _modern_proxy(backend)
received: list[tuple[float, float | None, str | None]] = []
async def progress_handler(progress, total, message):
received.append((progress, total, message))
async with Client(
proxy, mode="auto", progress_handler=progress_handler
) as client:
result = await client.call_tool("report", {})
assert result.data == "ok"
assert received == [(1, 2, "halfway"), (2, 2, "done")]
@dataclass
class _Person:
name: str
def _era_reporting_backend() -> FastMCP:
"""A dual-era backend for the mirroring tests.
Hosts the three-round guard tool (``book_flight``), a tool that reports the
protocol version its own backend session negotiated (``backend_era``), and a
server-initiated elicitation tool (``ask_name``) that only works when the
session is handshake-era, so a single backend proves which era the proxy
mirrored onto it.
"""
mcp = two_question_server()
@mcp.tool
async def backend_era(ctx: Context) -> str:
rc = ctx.request_context
assert rc is not None
return rc.protocol_version
@mcp.tool
async def ask_name(ctx: Context) -> str:
result = await ctx.elicit("What is your name?", response_type=_Person)
if result.action == "accept":
assert isinstance(result.data, _Person)
return f"Hello, {result.data.name}!"
return "no name"
return mcp
class TestProxyEraMirroring:
"""A proxy created from a non-Client target with no explicit mode mirrors the
front connection's negotiated era onto its backend session, so the whole
chain speaks one era end-to-end."""
async def test_modern_front_mirrors_modern_backend(self):
"""A modern front through a proxy with NO explicit mode gets a modern
backend session, so a guard tool round-trips end-to-end."""
from fastmcp.server import create_proxy
proxy = create_proxy(_era_reporting_backend())
asked: list[str] = []
async with Client(
proxy, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
era = await client.call_tool("backend_era", {})
result = await client.call_tool("book_flight", {})
assert era.data == "2026-07-28"
assert result.data == "Booked Paris on 2026-08-01"
async def test_legacy_front_mirrors_handshake_backend(self):
"""A legacy front through a proxy with NO explicit mode gets a handshake
backend session, so server-initiated elicitation push-forwards through
the proxy to the front client's handler."""
from fastmcp.server import create_proxy
proxy = create_proxy(_era_reporting_backend())
async def name_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content=response_type(name="Ada"))
async with Client(
proxy, mode="legacy", elicitation_handler=name_handler
) as client:
era = await client.call_tool("backend_era", {})
greeting = await client.call_tool("ask_name", {})
assert era.data not in MODERN_PROTOCOL_VERSIONS
assert greeting.data == "Hello, Ada!"
async def test_same_proxy_serves_both_eras_without_bleed(self):
"""The SAME proxy instance serves a legacy front and a modern front (and
a legacy front again); each gets its matching backend era. This is the
session-cache trap: a backend session pinned to one era must never be
reused across front connections of a different era."""
from fastmcp.server import create_proxy
proxy = create_proxy(_era_reporting_backend())
async with Client(proxy, mode="legacy") as client:
legacy_era = await client.call_tool("backend_era", {})
async with Client(proxy, mode="auto") as client:
modern_era = await client.call_tool("backend_era", {})
async with Client(proxy, mode="legacy") as client:
legacy_again = await client.call_tool("backend_era", {})
assert legacy_era.data not in MODERN_PROTOCOL_VERSIONS
assert modern_era.data == "2026-07-28"
assert legacy_again.data not in MODERN_PROTOCOL_VERSIONS
async def test_explicit_mode_overrides_mirroring(self):
"""An explicit ``create_proxy(mode=...)`` pins the backend era regardless
of the front connection's era, overriding mirroring."""
from fastmcp.server import create_proxy
proxy = create_proxy(_era_reporting_backend(), mode="auto")
# Legacy front, but the backend is pinned modern by the explicit mode.
async with Client(proxy, mode="legacy") as client:
era = await client.call_tool("backend_era", {})
assert era.data == "2026-07-28"
class TestMultiServerConfigEraMirroring:
"""A multi-server `MCPConfig` target puts an extra hop between the proxy and
the real backends: `MCPConfigTransport` mounts one proxy per configured
server on a composite router. Setting the era on the outer client alone
would stop at that router, leaving every real backend on its own default
era, so the mirrored era has to reach the mounted legs too.
"""
@staticmethod
def _config(url: str) -> dict[str, object]:
"""Two entries so the transport takes its multi-server composite path."""
return {"mcpServers": {"a": {"url": url}, "b": {"url": url}}}
async def test_modern_front_reaches_modern_backends(self):
"""A modern front reaches each real backend on a modern session, and a
backend guard tool round-trips end to end across both proxy hops."""
from fastmcp.server import create_proxy
async with run_server_async(_era_reporting_backend()) as url:
proxy = create_proxy(self._config(url))
asked: list[str] = []
async with Client(
proxy, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
era = await client.call_tool("a_backend_era", {})
result = await client.call_tool("a_book_flight", {})
assert era.data == "2026-07-28"
assert result.data == "Booked Paris on 2026-08-01"
assert len(asked) == 2
async def test_legacy_front_reaches_handshake_backends(self):
"""A legacy front reaches each real backend on a handshake session, so
server-initiated elicitation still push-forwards up the whole chain."""
from fastmcp.server import create_proxy
async def name_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content=response_type(name="Ada"))
async with run_server_async(_era_reporting_backend()) as url:
proxy = create_proxy(self._config(url))
async with Client(
proxy, mode="legacy", elicitation_handler=name_handler
) as client:
era = await client.call_tool("a_backend_era", {})
greeting = await client.call_tool("a_ask_name", {})
assert era.data not in MODERN_PROTOCOL_VERSIONS
assert greeting.data == "Hello, Ada!"
async def test_explicit_mode_overrides_mirroring(self):
"""An explicit ``create_proxy(mode=...)`` pins the era all the way down,
overriding what the front negotiated."""
from fastmcp.server import create_proxy
async with run_server_async(_era_reporting_backend()) as url:
proxy = create_proxy(self._config(url), mode="auto")
async with Client(proxy, mode="legacy") as client:
era = await client.call_tool("a_backend_era", {})
assert era.data == "2026-07-28"
class TestEraGate:
async def test_legacy_connection_rejects_with_era_error(self):
"""Returning an InputRequiredResult on a ≤2025-11-25 connection produces
a clear era error naming 2026-07-28, not a generic 'invalid result'."""
async with Client(two_question_server(), mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
with pytest.raises(MCPError) as excinfo:
await client.call_tool("book_flight", {})
message = str(excinfo.value)
assert "2026-07-28" in message
assert "2025-11-25" in message
assert "InputRequiredResult" in message
class TestRequestStateSealing:
"""The framework (SDK RequestStateBoundary middleware) owns sealing: a tool
only ever mints/reads plaintext, and the wire value is authenticated."""
def _one_shot_server(self) -> FastMCP:
mcp = FastMCP("seal")
@mcp.tool
async def guard(ctx: Context) -> str | InputRequiredResult:
if ctx.input_responses is None:
return _ask(
_elicit("x", "q", "x"), "x", request_state="PLAINTEXT-STATE"
)
return f"state={ctx.request_state}"
return mcp
async def test_wire_request_state_is_sealed(self):
"""The requestState the client receives is the sealed token, never the
plaintext the tool minted."""
async with Client(self._one_shot_server(), mode="auto") as client:
first = await client.session.call_tool(
"guard", {}, allow_input_required=True
)
assert isinstance(first, InputRequiredResult)
assert first.request_state is not None
assert first.request_state != "PLAINTEXT-STATE"
# The SDK's AES-256-GCM codec stamps a versioned "v1." prefix.
assert first.request_state.startswith("v1.")
async def test_tampered_request_state_rejected(self):
"""A modified requestState echo fails the boundary's integrity check with
the frozen wire error, before the tool runs."""
async with Client(self._one_shot_server(), mode="auto") as client:
first = await client.session.call_tool(
"guard", {}, allow_input_required=True
)
assert isinstance(first, InputRequiredResult)
assert first.request_state is not None
tampered = first.request_state[:-2] + (
"AA" if not first.request_state.endswith("AA") else "BB"
)
with pytest.raises(MCPError) as excinfo:
await client.session.call_tool(
"guard",
{},
input_responses={"x": {"action": "accept", "content": {"x": "v"}}},
request_state=tampered,
allow_input_required=True,
)
assert "requestState" in str(excinfo.value)
async def test_plaintext_round_trips_to_tool(self):
"""A valid echo delivers the original plaintext back to the tool."""
async with Client(self._one_shot_server(), mode="auto") as client:
first = await client.session.call_tool(
"guard", {}, allow_input_required=True
)
assert isinstance(first, InputRequiredResult)
second = await client.session.call_tool(
"guard",
{},
input_responses={"x": {"action": "accept", "content": {"x": "v"}}},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, mcp_types.CallToolResult)
assert second.structured_content == {"result": "state=PLAINTEXT-STATE"}
class TestMiddlewareInteraction:
"""Each MRTR leg is a complete request→response cycle: an
`InputRequiredResult` is the full, legitimate result of that leg, so it
flows back through the middleware chain as an ordinary `ToolResult`
(specifically an `InputRequiredToolResult`). Middleware observes it as a
normal result, not an error and not control flow."""
async def test_completes_with_error_and_broad_except_middleware(self):
"""An ask is a result, not an error, so error-handling middleware and a
broad ``except Exception`` are simply irrelevant — `call_next` returns
the ask, nothing is raised, and the loop completes normally.
"""
class BroadCatchMiddleware(Middleware):
async def on_call_tool(self, context, call_next):
# The ask is RETURNED here, never raised, so this except never
# fires on a guard round.
try:
return await call_next(context)
except Exception as e:
raise RuntimeError(f"swallowed: {e}") from e
server = two_question_server()
server.add_middleware(ErrorHandlingMiddleware())
server.add_middleware(BroadCatchMiddleware())
asked: list[str] = []
async with Client(
server, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert result.data == "Booked Paris on 2026-08-01"
assert len(asked) == 2
async def test_middleware_observes_ask_then_final_result_per_leg(self):
"""A logging-style recording middleware sees the full chain run on every
leg: the ask (`InputRequiredToolResult`) is the observed result on the
two guard legs, and the terminal string is the result on the last leg.
Three client-visible rounds ⇒ three `on_call_tool` fires."""
observed: list[object] = []
class RecordingMiddleware(Middleware):
async def on_call_tool(self, context, call_next):
result = await call_next(context)
observed.append(result)
return result
server = two_question_server()
server.add_middleware(RecordingMiddleware())
asked: list[str] = []
async with Client(
server, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert result.data == "Booked Paris on 2026-08-01"
# One on_call_tool fire per leg — three legs, three observations.
assert len(observed) == 3
# The first two legs each return the ask as their result; middleware can
# identify it by isinstance on the ToolResult subclass.
assert isinstance(observed[0], InputRequiredToolResult)
assert isinstance(observed[1], InputRequiredToolResult)
# Each ask carries the underlying InputRequiredResult unmodified.
assert isinstance(observed[0].input_required, InputRequiredResult)
# The final leg returns the ordinary terminal result, not an ask.
assert isinstance(observed[2], ToolResult)
assert not isinstance(observed[2], InputRequiredToolResult)
assert observed[2].structured_content == {
"result": "Booked Paris on 2026-08-01"
}
async def test_continuation_leg_is_detectable_from_context(self):
"""Middleware can distinguish an initial leg from a continuation leg via
``context.fastmcp_context.input_responses`` — ``None`` on the first
round, present once the client has answered."""
input_responses_seen: list[bool] = []
class DetectMiddleware(Middleware):
async def on_call_tool(self, context, call_next):
fctx = context.fastmcp_context
assert fctx is not None
input_responses_seen.append(fctx.input_responses is not None)
return await call_next(context)
server = two_question_server()
server.add_middleware(DetectMiddleware())
asked: list[str] = []
async with Client(
server, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert result.data == "Booked Paris on 2026-08-01"
# Leg 1 has no answers yet; legs 2 and 3 are continuations.
assert input_responses_seen == [False, True, True]
async def test_continuation_fields_populate_message(self):
"""The continuation fields (SEP-2322) appear on ``context.message``
itself, not only on ``fastmcp_context`` — so middleware branching on the
standard `input_responses` / `request_state` params sees a continuation
round as such rather than as an initial call."""
responses_seen: list[bool] = []
state_seen: list[bool] = []
class MessageMiddleware(Middleware):
async def on_call_tool(self, context, call_next):
responses_seen.append(context.message.input_responses is not None)
state_seen.append(context.message.request_state is not None)
return await call_next(context)
server = two_question_server()
server.add_middleware(MessageMiddleware())
asked: list[str] = []
async with Client(
server, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert result.data == "Booked Paris on 2026-08-01"
# Answers arrive on legs 2 and 3; request_state is carried only on leg 3
# (round 2's ask minted it), so it is absent on legs 1 and 2.
assert responses_seen == [False, True, True]
assert state_seen == [False, False, True]
class TestCachingMiddlewareInteraction:
async def test_ask_is_not_cached(self):
"""The response cache must never store an ask: two identical first-leg
calls both reach the tool (the second is not served a stale cached
question). A cached ask would replay a stale prompt and skip the tool's
own per-round logic."""
from fastmcp.server.middleware.caching import ResponseCachingMiddleware
call_count = {"n": 0}
mcp = FastMCP("cache-guard")
@mcp.tool
async def ask(ctx: Context) -> str | InputRequiredResult:
if ctx.input_responses is None:
call_count["n"] += 1
return _ask(_elicit("x", "q", "x"), "x", None)
return "done"
mcp.add_middleware(ResponseCachingMiddleware())
# Two independent first legs (no elicitation handler ⇒ each raises on the
# client driver rather than completing), but both must have reached the
# tool body — proving the ask was not served from cache.
async with Client(mcp, mode="auto") as client:
for _ in range(2):
first = await client.session.call_tool(
"ask", {}, allow_input_required=True
)
assert isinstance(first, InputRequiredResult)
assert call_count["n"] == 2
async def test_state_only_continuation_final_not_cached(self):
"""A state-only round (request_state, no questions) retries with
input_responses=None — request_state alone must mark the continuation,
or its terminal result would be cached under the fresh-call key."""
from fastmcp.server.middleware.caching import ResponseCachingMiddleware
body_runs = {"n": 0}
mcp = FastMCP("cache-state-only")
@mcp.tool
async def staged(ctx: Context) -> str | InputRequiredResult:
body_runs["n"] += 1
if ctx.request_state is None:
return InputRequiredResult(
result_type="input_required",
input_requests={},
request_state="stage=1",
)
return f"done after {ctx.request_state}"
mcp.add_middleware(ResponseCachingMiddleware())
async with Client(mcp, mode="auto") as client:
first = await client.session.call_tool(
"staged", {}, allow_input_required=True
)
assert isinstance(first, InputRequiredResult)
# The client echoes the (sealed) state with no responses — the
# state-only continuation the guard must recognize.
final = await client.session.call_tool(
"staged",
{},
request_state=first.request_state,
allow_input_required=True,
)
assert not isinstance(final, InputRequiredResult)
runs_after_flow = body_runs["n"]
# A fresh identical call must run the tool again, not be served
# the continuation's cached final.
fresh = await client.session.call_tool(
"staged", {}, allow_input_required=True
)
assert isinstance(fresh, InputRequiredResult)
assert body_runs["n"] == runs_after_flow + 1
async def test_completed_flow_final_result_not_served_to_fresh_call(self):
"""A continuation leg's final result must not enter the cache: its key
is built from name+arguments only, identical to a fresh call's — so a
cached final would be served to the next fresh call, which would then
never be asked the tool's questions."""
from fastmcp.server.middleware.caching import ResponseCachingMiddleware
body_runs = {"n": 0}
mcp = FastMCP("cache-flow")
@mcp.tool
async def ask(ctx: Context) -> str | InputRequiredResult:
body_runs["n"] += 1
if ctx.input_responses is None:
return _ask(_elicit("x", "q", "x"), "x", None)
return "done"
mcp.add_middleware(ResponseCachingMiddleware())
async def answer(message, response_type, params, ctx):
return ElicitResult(action="accept", content=response_type(x="a"))
async with Client(mcp, mode="auto", elicitation_handler=answer) as client:
first = await client.call_tool("ask", {})
assert first.data == "done"
runs_after_first_flow = body_runs["n"]
# A fresh identical call must run the tool again (ask + answer),
# not be served the previous flow's cached final answer.
second = await client.call_tool("ask", {})
assert second.data == "done"
assert body_runs["n"] == runs_after_first_flow + 2
class TestAnnotatedReturns:
async def test_annotated_union_return_strips_guard_arm(self):
"""``Annotated[str | InputRequiredResult, ...]`` derives its output
schema from the data arm; the guard arm is stripped inside Annotated."""
mcp = FastMCP("AnnotatedGuard")
@mcp.tool
async def greet(
ctx: Context,
) -> Annotated[str | InputRequiredResult, Field(description="greeting")]:
if ctx.input_responses is None:
return InputRequiredResult(
input_requests={"name": _elicit("name", "Your name?", "name")},
request_state="",
)
return "hello"
tool = await mcp.get_tool("greet")
assert tool is not None
schema = tool.output_schema
assert schema is not None
# Derived from the str arm — not poisoned by the guard arm.
assert "InputRequired" not in str(schema)
async def test_metadata_on_guard_arm_is_stripped(self):
"""When only the guard arm carries metadata
(``str | Annotated[InputRequiredResult, Field(...)]``), it is still
recognized and stripped so the str arm's schema survives."""
mcp = FastMCP("AnnotatedGuardArm")
@mcp.tool
async def greet(
ctx: Context,
) -> str | Annotated[InputRequiredResult, Field(description="suspend")]:
if ctx.input_responses is None:
return InputRequiredResult(
input_requests={"name": _elicit("name", "Your name?", "name")},
request_state="",
)
return "hello"
tool = await mcp.get_tool("greet")
assert tool is not None
schema = tool.output_schema
assert schema is not None
assert "InputRequired" not in str(schema)
class TestRequestStateSecurityConfig:
def test_custom_security_without_stable_audience_warns(self, caplog):
"""A supplied policy with neither an explicit audience nor a stable
server name warns (shared keys across replicas would stamp per-replica
random audiences) — but constructs, since single-process customization
(e.g. an ephemeral policy with a custom ttl) is legitimate and a policy
object cannot reveal whether its keys are shared."""
import logging
with caplog.at_level(logging.WARNING):
FastMCP(request_state_security=RequestStateSecurity(keys=[b"0" * 32]))
assert any("stable audience" in r.message for r in caplog.records)
# An empty name is falsy → still a random per-replica name, so it must
# warn like an omitted name (not slip through a `name is None` check).
caplog.clear()
with caplog.at_level(logging.WARNING):
FastMCP(
name="", request_state_security=RequestStateSecurity(keys=[b"0" * 32])
)
assert any("stable audience" in r.message for r in caplog.records)
# Single-process customization is allowed (warns, does not raise):
FastMCP(request_state_security=RequestStateSecurity.ephemeral(ttl=30))
# Either remedy avoids the warning:
caplog.clear()
with caplog.at_level(logging.WARNING):
FastMCP(
name="Stable",
request_state_security=RequestStateSecurity(keys=[b"0" * 32]),
)
FastMCP(
request_state_security=RequestStateSecurity(
keys=[b"0" * 32], audience="my-service"
)
)
assert not any("stable audience" in r.message for r in caplog.records)
async def test_explicit_shared_keys_seal_and_complete(self):
"""A server configured with explicit shared keys drives a full loop —
the multi-replica configuration (RequestStateSecurity(keys=[...]))."""
key = b"0" * 32
server = two_question_server(
request_state_security=RequestStateSecurity(keys=[key])
)
asked: list[str] = []
async with Client(
server, mode="auto", elicitation_handler=_two_answer_handler(asked)
) as client:
result = await client.call_tool("book_flight", {})
assert result.data == "Booked Paris on 2026-08-01"
def _sealing_server(self, key: bytes) -> FastMCP:
"""A one-round guard that seals a request_state on its first ask, under
an explicit key so state can be replayed across instances."""
mcp = FastMCP(
"seal-keyed",
request_state_security=RequestStateSecurity(keys=[key]),
)
@mcp.tool
async def guard(ctx: Context) -> str | InputRequiredResult:
if ctx.input_responses is None:
return _ask(_elicit("x", "q", "x"), "x", request_state="carried")
return f"state={ctx.request_state}"
return mcp
async def test_state_from_a_different_key_is_rejected(self):
"""State minted under one key is rejected by a server holding a
different key — the cross-instance isolation shared keys prevent."""
async with Client(self._sealing_server(b"a" * 32), mode="auto") as client:
first = await client.session.call_tool(
"guard", {}, allow_input_required=True
)
assert isinstance(first, InputRequiredResult)
state_from_a = first.request_state
assert state_from_a is not None
async with Client(self._sealing_server(b"b" * 32), mode="auto") as client:
with pytest.raises(MCPError):
await client.session.call_tool(
"guard",
{},
input_responses={"x": {"action": "accept", "content": {"x": "v"}}},
request_state=state_from_a,
allow_input_required=True,
)
class TestTaskExecution:
"""A guard tool returns an `InputRequiredResult` as its result, which only
makes sense against a live request that can answer the prompt. A detached
background task has no such request, so returning a guard result from a task
is rejected with a clear error rather than silently yielding empty content."""
@pytest.fixture
def reset_docket_memory_server(self):
"""Force a fresh memory:// Docket server bound to this test's loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
async def test_guard_result_from_task_parks_for_input(
self, reset_docket_memory_server
):
mcp = FastMCP("guard-task")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def book_flight(ctx: Context) -> str | InputRequiredResult:
return _ask(
_elicit("date", "When?", "date"),
key="date",
request_state=None,
)
# A function-tool guard is driven as a task by the in-task reentrant
# loop: submitting `book_flight` parks its input request on the poll
# surface (`input_required`), where a client answers it via
# `tasks/update`. The full round-trip lives in
# tests/tasks/server/test_guard_reentrant.py.
async with running_task_server(mcp):
created = await submit_task(mcp, "book_flight", {})
parked = await wait_for_task(
mcp,
created.task_id,
target_states=frozenset({"input_required"}),
)
assert parked.status == "input_required"
assert parked.input_requests
class TestHttpTransport:
async def test_two_question_loop_over_http(self):
"""The full guard loop completes over Streamable HTTP with mode='auto'."""
asked: list[str] = []
async with run_server_async(two_question_server()) as url:
async with Client(
StreamableHttpTransport(url),
mode="auto",
elicitation_handler=_two_answer_handler(asked),
) as client:
assert client.protocol_version == "2026-07-28"
result = await client.call_tool("book_flight", {})
assert asked == ["Where would you like to fly?", "When to Paris?"]
assert result.data == "Booked Paris on 2026-08-01"
class TestPromptGuard:
"""`InputRequiredResult` is a result type, not a tools/call feature, so a
prompt can ask for input the same way a tool does (SEP-2322)."""
@staticmethod
def _context_prompt_server() -> FastMCP:
mcp = FastMCP("prompt-guard")
@mcp.prompt
async def summarize(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _ask(
_elicit("context", "What context?", "context"),
key="context",
request_state=None,
)
return f"Summarizing with {_accepted(responses, 'context')['context']}"
return mcp
async def test_prompt_emits_input_required(self):
"""The asking round reaches the wire as an InputRequiredResult."""
async with Client(self._context_prompt_server(), mode="auto") as client:
result = await client.session.get_prompt(
"summarize", allow_input_required=True
)
assert isinstance(result, InputRequiredResult)
assert "context" in result.input_requests
async def test_prompt_completes_with_responses(self):
"""Answering the ask renders the prompt on the next round."""
mcp = self._context_prompt_server()
async with Client(mcp, mode="auto") as client:
ask = await client.session.get_prompt(
"summarize", allow_input_required=True
)
assert isinstance(ask, InputRequiredResult)
done = await client.session.get_prompt(
"summarize",
input_responses={
"context": mcp_types.ElicitResult(
action="accept", content={"context": "quarterly report"}
)
},
)
assert done.messages[0].content.text == ("Summarizing with quarterly report")
async def test_prompt_guard_rejected_on_handshake_era(self):
"""The result type only exists at 2026-07-28, so an older connection
gets the era named rather than a generic invalid-result failure."""
async with Client(self._context_prompt_server(), mode="legacy") as client:
with pytest.raises(MCPError, match="2026-07-28"):
await client.session.get_prompt("summarize")