Merge pull request #4650 from PrefectHQ/claude/mcp-conformance-tests-7ec13f

Pass the MCP conformance suite's draft and pending scenarios
This commit is contained in:
Jeremiah Lowin 2026-07-26 17:38:13 -04:00 committed by GitHub
commit fecced2b5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1727 additions and 84 deletions

View file

@ -1,6 +1,23 @@
# Scenarios the conformance suite runs that FastMCP does not pass.
#
# This is a baseline, not a to-do list: every entry needs a reason, and anything
# that is merely unimplemented in the *fixture* belongs in server.py instead.
# The suite is run with `--suite all`, so draft and pending scenarios count too.
server:
- completion-complete
- server-sse-polling
# Resource subscriptions (resources/subscribe, resources/unsubscribe) are not
# implemented. The server correctly advertises `resources.subscribe: false`,
# but the suite calls the methods regardless of the declared capability. Both
# scenarios were removed in MCP 2026-07-28, the version FastMCP targets, so
# this affects handshake-era clients only.
- resources-subscribe
- resources-unsubscribe
- dns-rebinding-protection
# SEP-2663 MRTR-to-tasks composition: a task-supporting guard tool is
# expected to gather its input over foreground multi-round-trip rounds and
# only mint the task on the final round. FastMCP instead creates the task up
# front and parks it at `input_required`, answered through `tasks/update` —
# the model the `tasks-mrtr-input` scenario exercises. Supporting both would
# need the tool to declare which one it wants, which is an unmade API
# decision rather than a bug.
- tasks-mrtr-composition

View file

@ -9,17 +9,33 @@ import base64
import json
import sys
from enum import Enum as PyEnum
from typing import Annotated
import mcp_types
from mcp_types import EmbeddedResource, ImageContent, TextContent
import uvicorn
from mcp.shared.exceptions import MCPError
from mcp_types import (
ClientCapabilities,
Completion,
EmbeddedResource,
ImageContent,
MissingRequiredClientCapabilityErrorData,
PromptReference,
TextContent,
)
from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import BaseModel, Field
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.prompts import Message
from fastmcp.server.completions import CompletionValues
from fastmcp.server.context import Context
from fastmcp.server.event_store import EventStore
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import Audio, Image
from fastmcp_tasks import TasksExtension
# Minimal 1x1 red PNG for image tests (89 bytes)
_1X1_PNG = base64.b64decode(
@ -47,6 +63,29 @@ _SILENT_WAV = (
server = FastMCP("conformance-test-server", dereference_schemas=False)
def require_client_capability(ctx: Context, capability: str) -> None:
"""Raise `-32021` unless the client declared *capability* on this request.
SEP-2575 makes capability negotiation per-request: the client repeats its
capabilities in each request's `_meta`, and a server that needs one the
client did not declare must answer with a
`MissingRequiredClientCapabilityError` whose `data.requiredCapabilities` is
a `ClientCapabilities` object keyed by the missing capability.
"""
client_params = ctx.session.client_params
declared = client_params.capabilities if client_params else None
if declared is not None and getattr(declared, capability, None) is not None:
return
data = MissingRequiredClientCapabilityErrorData(
required_capabilities=ClientCapabilities.model_validate({capability: {}})
)
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message=f"Client did not declare the required {capability!r} capability",
data=data.model_dump(by_alias=True, mode="json", exclude_none=True),
)
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@ -261,6 +300,7 @@ server.add_tool(
"type": "object",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"properties": {
"street": {"type": "string"},
@ -272,12 +312,423 @@ server.add_tool(
"name": {"type": "string"},
"address": {"$ref": "#/$defs/address"},
},
# SEP-2106 requires servers to pass composition and conditional
# keywords through to the client untouched.
"allOf": [
{
"anyOf": [
{"required": ["name"]},
{"required": ["address"]},
]
}
],
"if": {"required": ["address"]},
"then": {"properties": {"name": {"minLength": 1}}},
"else": {},
"additionalProperties": False,
},
)
)
@server.tool(name="test_reconnection")
async def test_reconnection(ctx: Context) -> str:
"""Closes the POST stream mid-call so the client must resume (SEP-1699).
The result is written after the stream is gone, so it can only reach the
client through the event store on reconnect.
"""
await ctx.report_progress(0, 100)
await ctx.close_sse_stream()
await asyncio.sleep(0.1)
return "Reconnection test complete."
@server.tool(name="test_custom_headers")
async def test_custom_headers(
message: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Message"})],
) -> str:
"""Mirrors an argument into an `Mcp-Param-Message` header (SEP-2243).
The annotation is what makes the header recognized; the transport compares
the header against this argument before the tool ever runs.
"""
return f"Received message: {message}"
@server.tool(name="test_missing_capability")
async def test_missing_capability(ctx: Context) -> str:
"""Requires the client to have declared the sampling capability (SEP-2575).
A stateless server may not rely on a capability the client did not declare
in this request's `io.modelcontextprotocol/clientCapabilities` `_meta`
block, so an undeclared caller gets `-32021` rather than a tool result.
"""
require_client_capability(ctx, "sampling")
return "Client declared the sampling capability."
# ---------------------------------------------------------------------------
# Multi-round-trip input requests (SEP-2322)
#
# A guard component returns an `InputRequiredResult` naming what it needs; the
# client fulfils those requests and calls again, and the answers arrive on
# `ctx.input_responses` with any `ctx.request_state` echoed back. The framework
# seals and verifies `request_state`, so a tampered echo is rejected before a
# handler sees it.
# ---------------------------------------------------------------------------
def _elicit_request(message: str, field: str) -> mcp_types.ElicitRequest:
"""A single-field form elicitation for *field*."""
return mcp_types.ElicitRequest(
method="elicitation/create",
params=mcp_types.ElicitRequestFormParams(
message=message,
requested_schema={
"type": "object",
"properties": {field: {"type": "string"}},
"required": [field],
},
),
)
def _sampling_request(text: str, max_tokens: int) -> mcp_types.CreateMessageRequest:
"""A one-message sampling request."""
return mcp_types.CreateMessageRequest(
method="sampling/createMessage",
params=mcp_types.CreateMessageRequestParams(
messages=[
mcp_types.SamplingMessage(
role="user",
content=TextContent(type="text", text=text),
)
],
max_tokens=max_tokens,
),
)
def _elicited_field(responses: mcp_types.InputResponses, key: str, field: str) -> str:
"""The accepted value of *field* from the elicitation answered under *key*."""
answer = responses[key]
if not isinstance(answer, mcp_types.ElicitResult) or answer.content is None:
return ""
return str(answer.content.get(field, ""))
@server.tool(name="test_input_required_result_elicitation")
async def test_input_required_result_elicitation(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Asks the client one elicitation question, then greets the answer.
A retry whose `inputResponses` omit the key is re-asked rather than
errored: the answer is still missing, so the honest result is the same
request again.
"""
responses = ctx.input_responses
if responses is None or "user_name" not in responses:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={"user_name": _elicit_request("What is your name?", "name")},
)
return f"Hello, {_elicited_field(responses, 'user_name', 'name')}!"
@server.tool(name="test_input_required_result_sampling")
async def test_input_required_result_sampling(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Asks the client to sample an answer, then echoes the sampled text."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"capital_question": _sampling_request(
"What is the capital of France?", 100
)
},
)
answer = responses["capital_question"]
text = ""
if isinstance(answer, mcp_types.CreateMessageResult) and isinstance(
answer.content, TextContent
):
text = answer.content.text
return f"Sampling result: {text}"
@server.tool(name="test_input_required_result_list_roots")
async def test_input_required_result_list_roots(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Asks the client for its roots, then reports them back."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"client_roots": mcp_types.ListRootsRequest(method="roots/list")
},
)
answer = responses["client_roots"]
roots = (
[str(root.uri) for root in answer.roots]
if isinstance(answer, mcp_types.ListRootsResult)
else []
)
return f"Client roots: {', '.join(roots)}"
@server.tool(name="test_input_required_result_request_state")
async def test_input_required_result_request_state(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Carries opaque state across the round trip and confirms it came back."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"confirm": mcp_types.ElicitRequest(
method="elicitation/create",
params=mcp_types.ElicitRequestFormParams(
message="Please confirm",
requested_schema={
"type": "object",
"properties": {"ok": {"type": "boolean"}},
"required": ["ok"],
},
),
)
},
request_state="conformance-state-v1",
)
if ctx.request_state != "conformance-state-v1":
raise ToolError("requestState was not echoed back intact")
return "state-ok: requestState round-tripped"
@server.tool(name="test_input_required_result_multiple_inputs")
async def test_input_required_result_multiple_inputs(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Asks for elicitation, sampling, and roots in a single round."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"user_name": _elicit_request("What is your name?", "name"),
"greeting": _sampling_request("Generate a greeting", 50),
"client_roots": mcp_types.ListRootsRequest(method="roots/list"),
},
request_state="conformance-multi-v1",
)
name = _elicited_field(responses, "user_name", "name")
return f"Collected {len(responses)} responses for {name}"
@server.tool(name="test_input_required_result_multi_round")
async def test_input_required_result_multi_round(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Asks two dependent questions across three rounds."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"step1": _elicit_request("Step 1: What is your name?", "name")
},
request_state="round-1",
)
if "step1" in responses:
name = _elicited_field(responses, "step1", "name")
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"step2": _elicit_request(
"Step 2: What is your favorite color?", "color"
)
},
request_state=f"round-2:{name}",
)
color = _elicited_field(responses, "step2", "color")
name = (ctx.request_state or "round-2:").split(":", 1)[1]
return f"{name} likes {color}"
@server.tool(name="test_input_required_result_tampered_state")
async def test_input_required_result_tampered_state(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Round-trips sealed state so a tampered echo is rejected by the framework."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"confirm": _elicit_request("Please confirm", "confirmation")
},
request_state="sealed-state-v1",
)
return f"Accepted state: {ctx.request_state}"
@server.tool(name="test_input_required_result_capabilities")
async def test_input_required_result_capabilities(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""Asks only for the input methods this client declared it can answer."""
responses = ctx.input_responses
if responses is not None:
return f"Collected {len(responses)} responses"
client_params = ctx.session.client_params
declared = client_params.capabilities if client_params else None
requests: dict[str, mcp_types.InputRequest] = {}
if declared is not None and declared.sampling is not None:
requests["capital_question"] = _sampling_request(
"What is the capital of France?", 100
)
if declared is not None and declared.elicitation is not None:
requests["user_name"] = _elicit_request("What is your name?", "name")
if declared is not None and declared.roots is not None:
requests["client_roots"] = mcp_types.ListRootsRequest(method="roots/list")
if not requests:
return "Client declared no input capabilities"
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests=requests,
)
# ---------------------------------------------------------------------------
# Background tasks (SEP-2663)
#
# The tasks extension is what turns `task=`-declared tools into background
# work; registering it also advertises `io.modelcontextprotocol/tasks` under
# `capabilities.extensions` and gates the `tasks/*` methods on negotiation.
# The in-memory Docket backend keeps the fixture to a single process.
# ---------------------------------------------------------------------------
server.add_extension(TasksExtension(url="memory://"))
@server.tool(name="greet")
async def greet(name: str) -> str:
"""A sync-only tool: never runs as a task."""
return f"Hello, {name}!"
@server.tool(name="slow_compute", task=True)
async def slow_compute(seconds: float = 1.0, label: str = "") -> str:
"""Sleeps for *seconds*, so a cancel can land while it is still running."""
await asyncio.sleep(seconds)
return f"Computed {label} after {seconds} seconds"
@server.tool(name="failing_job", task=TaskConfig(mode="required"))
async def failing_job() -> str:
"""Reports a tool execution error: `completed` with `result.isError`.
Registered `required` so a client that never negotiated the extension gets
`-32021` rather than a synchronous run.
"""
await asyncio.sleep(1)
raise ToolError("This job intentionally fails for testing")
@server.tool(name="protocol_error_job", task=True)
async def protocol_error_job() -> str:
"""Raises a protocol-level error: `failed` with an inlined `error`."""
raise MCPError(
code=mcp_types.INTERNAL_ERROR,
message="Protocol-level failure for testing",
)
@server.tool(name="confirm_delete", task=True)
async def confirm_delete(
filename: str, ctx: Context
) -> str | mcp_types.InputRequiredResult:
"""Parks the task on one elicitation before doing the (pretend) deletion."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"confirm": _elicit_request(
f"Confirm deletion of {filename}?", "confirmation"
)
},
)
answer = _elicited_field(responses, "confirm", "confirmation")
return f"Deleted {filename}: {answer}"
@server.tool(name="multi_input", task=True)
async def multi_input(ctx: Context) -> str | mcp_types.InputRequiredResult:
"""Parks the task on two elicitations at once, so they can be answered separately."""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"first": _elicit_request("First question?", "first"),
"second": _elicit_request("Second question?", "second"),
},
)
first = _elicited_field(responses, "first", "first")
second = _elicited_field(responses, "second", "second")
return f"Answers: {first}, {second}"
@server.tool(name="test_tool_with_task", task=TaskConfig(mode="required"))
async def test_tool_with_task(ctx: Context) -> str | mcp_types.InputRequiredResult:
"""Gathers input over MRTR, then escalates the final round to a task.
The composition is the point: round 1 is a plain `InputRequiredResult`
with no `taskId`, and the round that actually does the work becomes a
`CreateTaskResult` because the tool requires task execution.
"""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={"user_name": _elicit_request("What is your name?", "name")},
)
return f"Task completed for {_elicited_field(responses, 'user_name', 'name')}"
# ---------------------------------------------------------------------------
# Completions
# ---------------------------------------------------------------------------
_PROMPT_ARG_COMPLETIONS = ["paris", "park", "party"]
@server.completion
async def complete(
ref: mcp_types.PromptReference | mcp_types.ResourceTemplateReference,
argument: mcp_types.CompletionArgument,
context: mcp_types.CompletionContext | None,
) -> CompletionValues:
"""Suggests values for `test_prompt_with_arguments` arguments."""
if isinstance(ref, PromptReference) and ref.name == "test_prompt_with_arguments":
matches = [
value
for value in _PROMPT_ARG_COMPLETIONS
if value.startswith(argument.value)
]
return Completion(values=matches, total=len(matches), has_more=False)
return None
# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------
@ -372,6 +823,49 @@ async def test_prompt_with_image() -> list:
]
@server.prompt(name="test_input_required_result_prompt")
async def test_input_required_result_prompt(
ctx: Context,
) -> str | mcp_types.InputRequiredResult:
"""A prompt that gathers its context by elicitation before rendering.
`InputRequiredResult` is universal it is a result type, not a tools/call
feature so `prompts/get` can ask for input the same way a tool does.
"""
responses = ctx.input_responses
if responses is None:
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={
"user_context": _elicit_request(
"What context should the prompt use?", "context"
)
},
)
context_value = _elicited_field(responses, "user_context", "context")
return f"Prompt rendered with context: {context_value}"
MCP_PATH = "/mcp"
def build_app():
"""The ASGI app the conformance suite is run against.
Shared by the pytest fixture and the `__main__` entry point so both exercise
the same configuration. The event store is what makes SSE resumption work,
which `test_reconnection` depends on; host/origin protection is a spec MUST
for a localhost server without TLS or auth.
"""
return server.http_app(
transport="streamable-http",
path=MCP_PATH,
host_origin_protection=True,
event_store=EventStore(),
retry_interval=100,
)
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
server.run(transport="streamable-http", host="127.0.0.1", port=port)
uvicorn.run(build_app(), host="127.0.0.1", port=port, log_level="warning")

View file

@ -1,5 +1,14 @@
"""Run the MCP conformance test suite against a FastMCP server.
The suite is pinned rather than tracking `@latest`: upstream adds scenarios for
draft SEPs, so an unpinned run turns CI red on somebody else's release rather
than on a change of ours. Bumping `CONFORMANCE_VERSION` is how new scenarios
arrive, and the diff shows what they cost.
`--suite all` includes draft and pending scenarios, which is deliberate most
of what FastMCP implements ahead of a spec release lives there. Anything that
does not pass is listed in `expected-failures.yml` with a reason.
Requires Node.js and npx to be available on PATH.
Mark: pytest -m conformance
"""
@ -17,7 +26,9 @@ import uvicorn
CONFORMANCE_DIR = Path(__file__).parent
EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml"
HOST = "127.0.0.1"
MCP_PATH = "/mcp"
#: Pinned version of `@modelcontextprotocol/conformance`. Bump deliberately.
CONFORMANCE_VERSION = "0.2.0-alpha.9"
def _get_free_port() -> int:
@ -36,12 +47,10 @@ def _require_npx():
@pytest.fixture(scope="module")
def conformance_server(_require_npx):
"""Start the conformance test server in a background thread."""
from tests.conformance.server import server as mcp_server
from tests.conformance.server import MCP_PATH, build_app
port = _get_free_port()
app = mcp_server.http_app(transport="streamable-http", path=MCP_PATH)
config = uvicorn.Config(app, host=HOST, port=port, log_level="warning")
config = uvicorn.Config(build_app(), host=HOST, port=port, log_level="warning")
uv_server = uvicorn.Server(config)
thread = threading.Thread(target=uv_server.run, daemon=True)
@ -66,13 +75,13 @@ def conformance_server(_require_npx):
@pytest.mark.conformance
@pytest.mark.timeout(120)
@pytest.mark.timeout(180)
def test_mcp_conformance(conformance_server):
"""Run the full MCP conformance test suite against the server."""
cmd = [
"npx",
"--yes",
"@modelcontextprotocol/conformance@latest",
f"@modelcontextprotocol/conformance@{CONFORMANCE_VERSION}",
"server",
"--url",
conformance_server,
@ -83,7 +92,7 @@ def test_mcp_conformance(conformance_server):
if EXPECTED_FAILURES.exists():
cmd.extend(["--expected-failures", str(EXPECTED_FAILURES)])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=150)
# Print output for visibility in test results
if result.stdout:

View file

@ -0,0 +1,123 @@
"""Response caching around multi-round-trip asks (SEP-2322).
A guard component answers a call by *returning* an `InputRequiredResult` a
request for client input rather than a final answer. Two things follow for
`ResponseCachingMiddleware`, and they apply equally to tools, prompts, and
resources:
- An ask must never be stored. It carries no content of its own, so caching one
writes an empty result, and every later caller is served that emptiness
instead of being asked the question.
- A continuation leg must bypass the cache entirely. Cache keys are built from
the component's identity and arguments alone, so a continuation shares its key
with a fresh call: reading could hand this leg a prior flow's final answer, and
writing would hand a later fresh caller *this* flow's answer, skipping the
questions altogether.
"""
import mcp_types
from fastmcp import Context, FastMCP
from fastmcp.client.client import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.server.middleware.caching import ResponseCachingMiddleware
def _ask() -> mcp_types.InputRequiredResult:
"""The single-question ask every guard in this module returns."""
params = mcp_types.ElicitRequestFormParams(
message="Which quarter?",
requested_schema={
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
)
request = mcp_types.ElicitRequest(method="elicitation/create", params=params)
return mcp_types.InputRequiredResult(
result_type="input_required",
input_requests={"q": request},
)
def _answer(responses: mcp_types.InputResponses) -> str:
"""The accepted value for the question `_ask` poses."""
result = responses["q"]
assert isinstance(result, mcp_types.ElicitResult)
assert result.content is not None
return str(result.content["q"])
async def _handler(message, response_type, params, ctx):
"""An elicitation handler that always answers "Q3"."""
return ElicitResult(action="accept", content=response_type(q="Q3"))
def cached_guard_server() -> FastMCP:
"""A caching server whose tool, prompt, and resource are all guards."""
mcp = FastMCP("cached-guards")
mcp.add_middleware(ResponseCachingMiddleware())
@mcp.tool
async def summarize_tool(ctx: Context) -> str | mcp_types.InputRequiredResult:
if ctx.input_responses is None:
return _ask()
return f"Summary for {_answer(ctx.input_responses)}"
@mcp.prompt
async def summarize(ctx: Context) -> str | mcp_types.InputRequiredResult:
if ctx.input_responses is None:
return _ask()
return f"Summary for {_answer(ctx.input_responses)}"
@mcp.resource("report://x")
async def report(ctx: Context) -> str | mcp_types.InputRequiredResult:
if ctx.input_responses is None:
return _ask()
return f"Report for {_answer(ctx.input_responses)}"
return mcp
def guard_client() -> Client:
"""A client that answers each round automatically."""
return Client(cached_guard_server(), mode="auto", elicitation_handler=_handler)
class TestGuardsCompleteUnderCaching:
"""Each component type drives its loop to a real answer with caching on."""
async def test_tool(self):
async with guard_client() as client:
result = await client.call_tool("summarize_tool", {})
assert result.data == "Summary for Q3"
async def test_prompt(self):
async with guard_client() as client:
result = await client.get_prompt("summarize")
assert result.messages[0].content.text == "Summary for Q3"
async def test_resource(self):
async with guard_client() as client:
result = await client.read_resource("report://x")
assert result[0].text == "Report for Q3"
class TestAsksAreNotCached:
"""A stored ask would poison every later caller."""
async def test_second_fresh_flow_is_asked_again(self):
"""A second fresh flow must be asked the same question.
Serving it a cached final answer would skip the component's own
per-round logic it would receive an answer it never supplied input for.
"""
async with guard_client() as client:
first = await client.get_prompt("summarize")
second = await client.get_prompt("summarize")
assert first.messages[0].content.text == "Summary for Q3"
assert second.messages[0].content.text == "Summary for Q3"

View file

@ -4,7 +4,9 @@ from __future__ import annotations
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from fastmcp import FastMCP
from fastmcp import Client, Context, FastMCP
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
from fastmcp.telemetry import TRACE_PARENT_KEY
class TestFastMCPProviderTracing:
@ -131,3 +133,53 @@ class TestProviderSpanHierarchy:
assert child_span.parent is not None
assert delegate_span.parent.span_id == parent_span.context.span_id
assert child_span.parent.span_id == delegate_span.context.span_id
class TestModernProxyTracePropagation:
"""A modern proxy relays resources and prompts through the low-level
session so a backend guard's ask can surface (SEP-2322). That path skips
the high-level client's trace injection, so the relay must stamp the
outgoing `_meta` itself otherwise every modern proxy read breaks the
distributed trace, not only the guard rounds."""
@staticmethod
def _backend(seen: dict[str, dict]) -> FastMCP:
backend = FastMCP("trace-backend")
def record(kind: str, ctx: Context) -> None:
rc = ctx.request_context
seen[kind] = dict(rc.meta) if rc is not None and rc.meta else {}
@backend.resource("data://x")
async def concrete(ctx: Context) -> str:
record("resource", ctx)
return "ok"
@backend.resource("data://{part}/y")
async def templated(part: str, ctx: Context) -> str:
record("template", ctx)
return "ok"
@backend.prompt
async def greet(ctx: Context) -> str:
record("prompt", ctx)
return "ok"
return backend
async def test_traceparent_reaches_backend(
self, trace_exporter: InMemorySpanExporter
):
seen: dict[str, dict] = {}
proxy = FastMCPProxy(
client_factory=lambda: ProxyClient(self._backend(seen), mode="auto")
)
async with Client(proxy, mode="auto") as client:
await client.read_resource("data://x")
await client.read_resource("data://p/y")
await client.get_prompt("greet")
assert TRACE_PARENT_KEY in seen["resource"]
assert TRACE_PARENT_KEY in seen["template"]
assert TRACE_PARENT_KEY in seen["prompt"]

View file

@ -0,0 +1,230 @@
"""Guard-mode multi-round-trip for prompts and resources (SEP-2322).
`InputRequiredResult` is a *result type*, not a `tools/call` feature: any
request can resolve to one. A prompt or resource asks for client input exactly
the way a tool does return the ask, read `ctx.input_responses` on the round
that follows.
These tests cover the emission side for prompts, concrete resources, and
resource templates, the 2026-07-28 era gate, and the proxy path, where the ask
must be forwarded to the parent rather than answered inside the proxy (a proxy
has no back-channel to the real user). Tool guards live in
``tests/server/test_mrtr_guards.py``.
"""
from __future__ import annotations
import mcp_types
import pytest
from mcp.shared.exceptions import MCPError
from mcp_types import ElicitRequest, InputRequiredResult
from fastmcp import Client, Context, FastMCP
from fastmcp.client.elicitation import ElicitResult
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."""
answer = responses[key]
assert isinstance(answer, mcp_types.ElicitResult)
assert answer.content is not None
return dict(answer.content)
def _modern_proxy(backend: FastMCP) -> FastMCP:
"""A proxy whose backend client negotiates the modern era, so the backend
can emit an `InputRequiredResult` for the proxy to round-trip."""
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
return FastMCPProxy(client_factory=lambda: ProxyClient(backend, mode="auto"))
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")
class TestResourceGuard:
"""Resources and templates ask for input the same way tools and prompts do."""
@staticmethod
def _resource_server() -> FastMCP:
mcp = FastMCP("resource-guard")
@mcp.resource("data://report")
async def report(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _ask(
_elicit("context", "Which quarter?", "context"),
key="context",
request_state=None,
)
return f"Report for {_accepted(responses, 'context')['context']}"
@mcp.resource("data://report/{section}")
async def section_report(
section: str, ctx: Context
) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _ask(
_elicit("context", f"Which quarter for {section}?", "context"),
key="context",
request_state=None,
)
quarter = _accepted(responses, "context")["context"]
return f"{section} for {quarter}"
return mcp
async def test_resource_emits_input_required(self):
async with Client(self._resource_server(), mode="auto") as client:
result = await client.session.read_resource(
"data://report", allow_input_required=True
)
assert isinstance(result, InputRequiredResult)
assert "context" in result.input_requests
async def test_resource_completes_with_responses(self):
async with Client(self._resource_server(), mode="auto") as client:
done = await client.session.read_resource(
"data://report",
input_responses={
"context": mcp_types.ElicitResult(
action="accept", content={"context": "Q3"}
)
},
)
assert done.contents[0].text == "Report for Q3"
async def test_resource_template_emits_input_required(self):
"""Templates share the converter, so the ask survives there too."""
async with Client(self._resource_server(), mode="auto") as client:
result = await client.session.read_resource(
"data://report/revenue", allow_input_required=True
)
assert isinstance(result, InputRequiredResult)
assert "context" in result.input_requests
async def test_resource_guard_rejected_on_handshake_era(self):
async with Client(self._resource_server(), mode="legacy") as client:
with pytest.raises(MCPError, match="2026-07-28"):
await client.session.read_resource("data://report")
class TestProxyForwarding:
"""A proxy forwards a backend guard's ask instead of answering it."""
async def test_guard_prompt_round_trips_through_proxy(self):
"""A guard prompt behind a proxy surfaces its ask instead of the proxy
trying to answer it. The proxy has no back-channel to the real user, so
driving the ask internally fails with "Elicitation not supported"."""
backend = TestPromptGuard._context_prompt_server()
async def answer(message, response_type, params, ctx):
return ElicitResult(
action="accept", content=response_type(context="quarterly report")
)
async with Client(
_modern_proxy(backend), mode="auto", elicitation_handler=answer
) as client:
result = await client.get_prompt("summarize")
assert result.messages[0].content.text == "Summarizing with quarterly report"
async def test_guard_resource_round_trips_through_proxy(self):
"""Concrete resources and templates forward the ask the same way."""
backend = TestResourceGuard._resource_server()
async def answer(message, response_type, params, ctx):
return ElicitResult(action="accept", content=response_type(context="Q3"))
async with Client(
_modern_proxy(backend), mode="auto", elicitation_handler=answer
) as client:
direct = await client.read_resource("data://report")
templated = await client.read_resource("data://report/revenue")
assert direct[0].text == "Report for Q3"
assert templated[0].text == "revenue for Q3"

View file

@ -135,9 +135,9 @@ async def test_tool_task_cancel():
assert final.status == "cancelled"
async def test_required_mode_without_optin_raises_32003():
async def test_required_mode_without_optin_raises_32021():
"""A legacy client never negotiates the tasks capability, so a required-mode
tool call is rejected with the -32003 missing-capability error."""
tool call is rejected with the -32021 missing-capability error."""
mcp = FastMCP("required-test")
mcp.add_extension(TasksExtension())

View file

@ -1,7 +1,7 @@
"""End-to-end tests for the SEP-2663 `TasksExtension` server adapter.
Covers the decide-and-task interceptor (forbidden/optional/required modes and the
-32003 missing-capability error), the tasks/get|update|cancel handlers, status
-32021 missing-capability error), the tasks/get|update|cancel handlers, status
mapping, inlined completed results, argument-coercion parity, TTL, and capability
advertisement. Server-side tasks are driven in-process via `task_helpers` because
there is no client task-submission API until Phase 4.
@ -360,7 +360,7 @@ async def test_legacy_era_opt_in_is_ignored():
async def test_legacy_era_required_tool_raises_missing_capability():
"""`required` tools refuse legacy-era calls with -32003 even when opted in."""
"""`required` tools refuse legacy-era calls with -32021 even when opted in."""
mcp = _tasks_server()
async with running_task_server(mcp):
srctx = ServerRequestContext(
@ -377,7 +377,7 @@ async def test_legacy_era_required_tool_raises_missing_capability():
with bind_request_context(srctx):
with pytest.raises(MCPError) as exc_info:
await mcp.call_tool("must_task", {"n": 3})
assert exc_info.value.error.code == -32003
assert exc_info.value.error.code == -32021
# ---------------------------------------------------------------------------
@ -409,12 +409,21 @@ async def test_worker_hooks_survive_sibling_server_shutdown():
# ---------------------------------------------------------------------------
# Compliance: -32003 on task methods for non-declaring clients (SEP-2663)
# Compliance: -32021 on task methods for non-declaring clients (SEP-2663)
# ---------------------------------------------------------------------------
def test_missing_capability_code_is_the_protocol_value():
"""The code must track the SDK, not an early SEP-2663 draft.
It shipped hardcoded as -32003, which no client recognizes: SEP-2575
assigns -32021 to MissingRequiredClientCapability.
"""
assert MISSING_REQUIRED_CLIENT_CAPABILITY == -32021
async def test_task_method_without_capability_raises_missing_capability():
"""tasks/get from a client that did not declare the extension gets -32003."""
"""tasks/get from a client that did not declare the extension gets -32021."""
mcp = _tasks_server()
extension = cast(TasksExtension, mcp._extensions[TASKS_EXTENSION_ID])
# A request context with no tasks capability in its _meta.

View file

@ -11,9 +11,14 @@ the real interceptor and handlers via `task_helpers`.
from __future__ import annotations
import asyncio
from typing import Any
import mcp_types
from fastmcp_tasks.context import get_task_scope
from fastmcp_tasks.input_store import acquire_update_lock, release_update_lock
from mcp.shared.exceptions import MCPError
from mcp_types import INTERNAL_ERROR
from fastmcp import Context, FastMCP
from fastmcp_tasks import TasksExtension
@ -58,6 +63,14 @@ def _input_required(
)
def _key_asking(input_requests: dict[str, Any], message: str) -> str:
"""The surfaced key whose parked request asks *message*."""
for key, payload in input_requests.items():
if payload["params"]["message"] == message:
return key
raise AssertionError(f"no parked request asks {message!r}")
async def _park_key(mcp: FastMCP, task_id: str) -> str:
parked = await wait_for_task(
mcp, task_id, target_states=frozenset({"input_required"})
@ -244,3 +257,187 @@ async def test_state_only_guard_round_fails_clearly():
assert final.result is not None
assert final.result["isError"] is True
assert "state-only" in final.result["content"][0]["text"]
async def test_partial_update_keeps_task_parked_on_remaining_request():
"""SEP-2663 partial fulfillment: a leg that asked two questions stays
`input_required` until both are answered, and each `tasks/get` in between
surfaces only what is still outstanding."""
mcp = FastMCP("partial")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def two_questions(ctx: Context) -> str | mcp_types.InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _input_required(
{
"first": _elicit_request("First?"),
"second": _elicit_request("Second?"),
}
)
return f"{_answer(responses, 'first')}+{_answer(responses, 'second')}"
async with running_task_server(mcp):
created = await submit_task(mcp, "two_questions", {})
parked = await wait_for_task(
mcp, created.task_id, target_states=frozenset({"input_required"})
)
assert parked.input_requests is not None
assert len(parked.input_requests) == 2
# Surfaced keys are freshly minted per request, so they carry no order
# a test can rely on. Identify each by the question it asks.
answered = _key_asking(parked.input_requests, "First?")
pending = _key_asking(parked.input_requests, "Second?")
await update_task(
mcp,
created.task_id,
{answered: {"action": "accept", "content": {"value": "one"}}},
)
still_parked = await get_task(mcp, created.task_id)
assert still_parked.status == "input_required"
assert still_parked.input_requests is not None
assert list(still_parked.input_requests) == [pending]
# Answering the last one resumes the leg, which now sees both answers.
await update_task(
mcp,
created.task_id,
{pending: {"action": "accept", "content": {"value": "two"}}},
)
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["content"][0]["text"] == "one+two"
async def test_partial_update_waits_for_a_held_update_lock():
"""An update that arrives while another holds the lock must still land.
SEP-2663 invites a client to answer a multi-request ask one key at a time,
so two updates can be in flight carrying *different* answers. Acknowledging
the one that loses the lock without storing its answer would leave the task
waiting forever on a key the client believes it already sent.
The lock is taken out of band here so the contention is deterministic rather
than dependent on scheduling.
"""
mcp = FastMCP("lock-contention")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def two_questions(ctx: Context) -> str | mcp_types.InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _input_required(
{
"first": _elicit_request("First?"),
"second": _elicit_request("Second?"),
}
)
return f"{_answer(responses, 'first')}+{_answer(responses, 'second')}"
async with running_task_server(mcp):
created = await submit_task(mcp, "two_questions", {})
parked = await wait_for_task(
mcp, created.task_id, target_states=frozenset({"input_required"})
)
assert parked.input_requests is not None
first = _key_asking(parked.input_requests, "First?")
second = _key_asking(parked.input_requests, "Second?")
docket = mcp._docket
assert docket is not None
scope = get_task_scope()
# Simulate a concurrent update in progress.
assert await acquire_update_lock(docket, scope, created.task_id)
pending = asyncio.create_task(
update_task(
mcp,
created.task_id,
{first: {"action": "accept", "content": {"value": "one"}}},
)
)
await asyncio.sleep(0.05)
assert not pending.done(), "update returned while the lock was held"
await release_update_lock(docket, scope, created.task_id)
await pending
# The blocked answer landed, so only the other key remains outstanding.
still_parked = await get_task(mcp, created.task_id)
assert still_parked.status == "input_required"
assert still_parked.input_requests is not None
assert list(still_parked.input_requests) == [second]
await update_task(
mcp,
created.task_id,
{second: {"action": "accept", "content": {"value": "two"}}},
)
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.result is not None
assert final.result["content"][0]["text"] == "one+two"
async def test_final_answer_keeps_task_parked_until_next_leg_is_durable():
"""The last answer must not retire its outstanding marker early.
Outstanding requests are what make a completed-but-parked leg read as
`input_required`. Discarding the final one before the next leg is enqueued
would let a `tasks/get` landing in that window see a finished execution with
no result and report the task complete.
"""
mcp = FastMCP("durable-reentry")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def one_question(ctx: Context) -> str | mcp_types.InputRequiredResult:
responses = ctx.input_responses
if responses is None:
return _input_required({"only": _elicit_request("Only?")})
return f"got {_answer(responses, 'only')}"
async with running_task_server(mcp):
created = await submit_task(mcp, "one_question", {})
key = await _park_key(mcp, created.task_id)
await update_task(
mcp,
created.task_id,
{key: {"action": "accept", "content": {"value": "answer"}}},
)
final = await wait_for_task(mcp, created.task_id)
# The task must land on the real result, never on a phantom completion.
assert final.status == "completed"
assert final.result is not None
assert final.result["content"][0]["text"] == "got answer"
async def test_protocol_error_fails_the_task_with_inlined_error():
"""SEP-2663 reserves `failed` for protocol faults: an `MCPError` raised by
the body is inlined as a JSON-RPC error rather than reported as a completed
task carrying an `isError` result (which is what a `ToolError` produces)."""
mcp = FastMCP("protocol-fault")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def explodes() -> str:
raise MCPError(code=INTERNAL_ERROR, message="protocol fault", data={"x": 1})
async with running_task_server(mcp):
created = await submit_task(mcp, "explodes", {})
final = await wait_for_task(mcp, created.task_id)
assert final.status == "failed"
assert final.result is None
assert final.error is not None
assert final.error["code"] == INTERNAL_ERROR
assert final.error["message"] == "protocol fault"
assert final.error["data"] == {"x": 1}

View file

@ -166,7 +166,7 @@ class TestToolModeEnforcement:
return mcp
async def test_required_mode_without_opt_in_raises(self):
"""Required mode raises -32003 when called without a tasks opt-in."""
"""Required mode raises -32021 when called without a tasks opt-in."""
mcp = self._server()
async with running_task_server(mcp):
with pytest.raises(MCPError) as exc_info:

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import pytest
from mcp import MCPError
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS
from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from fastmcp import Client, FastMCP
from fastmcp.exceptions import (
@ -71,6 +72,20 @@ class TestWireErrorCodes:
assert exc_info.value.error.code == INVALID_PARAMS
assert "Resource not found" in exc_info.value.error.message
async def test_resource_not_found_echoes_uri_in_data(self):
"""SEP-2164 SHOULD: the error names which URI was missing.
A client that pipelined several reads cannot otherwise tell which one
failed from the message alone.
"""
mcp = FastMCP("test-server")
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource_mcp("config://missing")
assert exc_info.value.error.data == {"uri": "config://missing"}
async def test_prompt_not_found_uses_invalid_params(self):
mcp = FastMCP("test-server")
@ -80,3 +95,45 @@ class TestWireErrorCodes:
assert exc_info.value.error.code == INVALID_PARAMS
assert "Unknown prompt" in exc_info.value.error.message
class TestMissingClientCapabilityFromTool:
"""A tool's `-32021` must reach the wire, not become an `isError` result.
SEP-2575 makes this error a statement about the *request* the server
cannot service it at all so flattening it into a tool result would drop
the code and tell the client the call succeeded. Every other `MCPError`
raised under a tool still masks into a result, since those describe how the
call went rather than whether it could run.
"""
@staticmethod
def _server() -> FastMCP:
mcp = FastMCP("capability-test")
@mcp.tool
async def needs_sampling() -> str:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="Client did not declare the required 'sampling' capability",
data={"requiredCapabilities": {"sampling": {}}},
)
@mcp.tool
async def upstream_failed() -> str:
raise MCPError(code=INTERNAL_ERROR, message="upstream exploded")
return mcp
async def test_capability_error_propagates_as_protocol_error(self):
async with Client(self._server()) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("needs_sampling")
assert exc_info.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": {"sampling": {}}}
async def test_other_mcp_errors_still_become_tool_errors(self):
async with Client(self._server()) as client:
with pytest.raises(ToolError):
await client.call_tool("upstream_failed")