Pass the MCP conformance suite's draft and pending scenarios

Pin the suite, build out the fixture, and fix the protocol gaps it found.
This commit is contained in:
Jeremiah Lowin 2026-07-26 15:00:08 -04:00
commit 7699deb99c
No known key found for this signature in database
17 changed files with 979 additions and 55 deletions

View file

@ -10,6 +10,7 @@ import pydantic_core
if TYPE_CHECKING: if TYPE_CHECKING:
from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.prompts.function_prompt import FunctionPrompt
import mcp_types
from mcp import GetPromptResult from mcp import GetPromptResult
from mcp_types import ( from mcp_types import (
AudioContent, AudioContent,
@ -188,6 +189,38 @@ class PromptResult(pydantic.BaseModel):
) )
class InputRequiredPromptResult(PromptResult):
"""The full result of a single multi-round-trip prompt leg (SEP-2322).
`InputRequiredResult` is a result type, not a `tools/call` feature: any
request may resolve to one. When a prompt returns an `InputRequiredResult`
from its body to ask the client for input, that ask is the legitimate
result of this `prompts/get` so FastMCP wraps it in this `PromptResult`
subclass, mirroring `InputRequiredToolResult`, and it flows through the
middleware chain as an ordinary return value.
Invariant: the wrapped `InputRequiredResult` is never rendered as prompt
messages. `messages` is always empty; the wire handler (`_on_get_prompt`)
reads `.input_required` and returns it to the runner.
"""
input_required: mcp_types.InputRequiredResult = Field(
description="The client-input request this leg resolved to (SEP-2322)"
)
def __init__(self, input_required: mcp_types.InputRequiredResult) -> None:
# Bypass PromptResult's message-normalizing __init__: an input-required
# leg carries no messages (see the invariant above), and
# `input_required` is a required field PromptResult.__init__ can't set.
pydantic.BaseModel.__init__(
self,
messages=[],
description=None,
meta=None,
input_required=input_required,
)
class Prompt(FastMCPComponent): class Prompt(FastMCPComponent):
"""A prompt template that can be rendered with parameters.""" """A prompt template that can be rendered with parameters."""
@ -287,6 +320,12 @@ class Prompt(FastMCPComponent):
if isinstance(raw_value, PromptResult): if isinstance(raw_value, PromptResult):
return raw_value return raw_value
if isinstance(raw_value, mcp_types.InputRequiredResult):
# The prompt asked the client for input (SEP-2322). Wrap it so the
# ask travels the middleware chain as an ordinary result; the wire
# handler unwraps it.
return InputRequiredPromptResult(raw_value)
if isinstance(raw_value, str): if isinstance(raw_value, str):
return PromptResult(raw_value, description=self.description, meta=self.meta) return PromptResult(raw_value, description=self.description, meta=self.meta)

View file

@ -28,6 +28,7 @@ from fastmcp.exceptions import (
NotFoundError, NotFoundError,
to_mcp_error, to_mcp_error,
) )
from fastmcp.prompts.base import InputRequiredPromptResult
from fastmcp.server.completions import CompletionValues, normalize_completion from fastmcp.server.completions import CompletionValues, normalize_completion
from fastmcp.server.dependencies import bind_request_context, extract_version_spec from fastmcp.server.dependencies import bind_request_context, extract_version_spec
from fastmcp.tools.base import InputRequiredToolResult, ToolResult from fastmcp.tools.base import InputRequiredToolResult, ToolResult
@ -306,8 +307,12 @@ class MCPOperationsMixin:
try: try:
result = await self.read_resource(str(uri), version=version) result = await self.read_resource(str(uri), version=version)
except (DisabledError, NotFoundError) as e: except (DisabledError, NotFoundError) as e:
raise to_mcp_error( # SEP-2164: echo the requested URI in `data` so a client that
NotFoundError(f"Resource not found: {str(uri)!r}") # pipelined several reads can tell which one is missing.
raise MCPError(
code=INVALID_PARAMS,
message=f"Resource not found: {str(uri)!r}",
data={"uri": str(uri)},
) from e ) from e
except FastMCPError as e: except FastMCPError as e:
# Resource-visible errors (ResourceError, ValidationError, ...) # Resource-visible errors (ResourceError, ValidationError, ...)
@ -326,7 +331,7 @@ class MCPOperationsMixin:
self: FastMCP, self: FastMCP,
ctx: ServerRequestContext, ctx: ServerRequestContext,
params: GetPromptRequestParams, params: GetPromptRequestParams,
) -> mcp_types.GetPromptResult: ) -> mcp_types.GetPromptResult | mcp_types.InputRequiredResult:
"""Handle MCP 'prompts/get' requests.""" """Handle MCP 'prompts/get' requests."""
with bind_request_context(ctx): with bind_request_context(ctx):
name = params.name name = params.name
@ -351,6 +356,23 @@ class MCPOperationsMixin:
# Masking already happened inside render_prompt. # Masking already happened inside render_prompt.
raise to_mcp_error(e) from e raise to_mcp_error(e) from e
if isinstance(result, InputRequiredPromptResult):
# The prompt requested client input (SEP-2322). As with tools,
# the multi-round-trip result type only exists at 2026-07-28, so
# name the era problem on an older connection rather than
# failing as a generic "invalid result".
if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS:
raise MCPError(
code=INVALID_PARAMS,
message=(
f"Prompt {name!r} returned an InputRequiredResult to "
"request client input, but the multi-round-trip result "
"type (SEP-2322) only exists at MCP 2026-07-28; this "
f"connection negotiated {ctx.protocol_version!r}."
),
)
return result.input_required
return result.to_mcp_prompt_result() return result.to_mcp_prompt_result()
async def _on_set_logging_level( async def _on_set_logging_level(

View file

@ -31,6 +31,7 @@ from mcp_types import (
CallToolRequestParams, CallToolRequestParams,
ToolAnnotations, ToolAnnotations,
) )
from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import AnyUrl from pydantic import AnyUrl
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from starlette.routing import BaseRoute from starlette.routing import BaseRoute
@ -1484,6 +1485,24 @@ class FastMCP(
) )
raise raise
except Exception as e: except Exception as e:
# Most MCPErrors raised under a tool describe how the call
# went — a timeout, an upstream error a proxy forwarded —
# and are masked into an `isError` result like any other
# failure. A missing-client-capability error is different:
# it says the request cannot be serviced at all, and
# SEP-2575 requires it on the wire as -32021 (HTTP 400).
# Flattening it into a result would drop the code and tell
# the client the call had succeeded.
if (
isinstance(e, MCPError)
and e.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
):
logger.debug(
"Tool %r requires a client capability the client did "
"not declare",
name,
)
raise
logger.exception(f"Error calling tool {name!r}") logger.exception(f"Error calling tool {name!r}")
# Handle actionable errors that should reach the LLM # Handle actionable errors that should reach the LLM
# even when masking is enabled # even when masking is enabled

View file

@ -32,10 +32,12 @@ from typing import TYPE_CHECKING, Any
from mcp.server.context import ServerRequestContext from mcp.server.context import ServerRequestContext
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp.shared.inbound import MCP_NAME_HEADER, decode_header_value
from mcp_types.jsonrpc import HEADER_MISMATCH
from mcp_types.version import MODERN_PROTOCOL_VERSIONS from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from fastmcp.exceptions import NotFoundError from fastmcp.exceptions import NotFoundError
from fastmcp.server.dependencies import extract_version_spec from fastmcp.server.dependencies import extract_version_spec, get_http_request
from fastmcp.server.extensions import ( from fastmcp.server.extensions import (
MethodBinding, MethodBinding,
ServerExtension, ServerExtension,
@ -140,7 +142,7 @@ class TasksExtension(ServerExtension):
"""Reject a task method from a client that did not declare the extension. """Reject a task method from a client that did not declare the extension.
SEP-2663: a client issuing `tasks/get`/`tasks/update`/`tasks/cancel` SEP-2663: a client issuing `tasks/get`/`tasks/update`/`tasks/cancel`
without the tasks capability in the request's `_meta` gets -32003. A without the tasks capability in the request's `_meta` gets -32021. A
client normally only holds a taskId because it declared the capability client normally only holds a taskId because it declared the capability
on the creating `tools/call`, but the method-level check is an explicit on the creating `tools/call`, but the method-level check is an explicit
MUST, so enforce it here rather than assume. MUST, so enforce it here rather than assume.
@ -156,22 +158,56 @@ class TasksExtension(ServerExtension):
data=missing_capability_error_data(), data=missing_capability_error_data(),
) )
def _require_matching_task_route(self, task_id: str) -> None:
"""Reject a task method whose `Mcp-Name` header disagrees with its body.
SEP-2243 mirrors a request's name-shaped field into `Mcp-Name` so
intermediaries can route without parsing the body, and requires servers
that read the body to check the two agree. SEP-2663 extends that to the
tasks namespace, where the name-shaped field is `taskId`. The core SDK's
pre-dispatch ladder only knows the base protocol's name-bearing methods,
so the extension enforces its own.
"""
try:
request = get_http_request()
except RuntimeError:
# Not an HTTP transport, so there are no routing headers to check.
return
header = request.headers.get(MCP_NAME_HEADER)
if header is None:
return
if decode_header_value(header) != task_id:
raise MCPError(
code=HEADER_MISMATCH,
message=(
f"{MCP_NAME_HEADER} header does not match the request body's "
"'taskId' parameter"
),
)
def _check_task_request(
self, ctx: ServerRequestContext[Any, Any], task_id: str
) -> None:
"""Run both gates every `tasks/*` method shares."""
self._require_tasks_capability(ctx)
self._require_matching_task_route(task_id)
async def _handle_get( async def _handle_get(
self, ctx: ServerRequestContext[Any, Any], params: GetTaskParams self, ctx: ServerRequestContext[Any, Any], params: GetTaskParams
) -> GetTaskResult: ) -> GetTaskResult:
self._require_tasks_capability(ctx) self._check_task_request(ctx, params.task_id)
return await tasks_get(self.server, params.task_id) return await tasks_get(self.server, params.task_id)
async def _handle_update( async def _handle_update(
self, ctx: ServerRequestContext[Any, Any], params: UpdateTaskParams self, ctx: ServerRequestContext[Any, Any], params: UpdateTaskParams
) -> UpdateTaskResult: ) -> UpdateTaskResult:
self._require_tasks_capability(ctx) self._check_task_request(ctx, params.task_id)
return await tasks_update(self.server, params.task_id, params.input_responses) return await tasks_update(self.server, params.task_id, params.input_responses)
async def _handle_cancel( async def _handle_cancel(
self, ctx: ServerRequestContext[Any, Any], params: CancelTaskParams self, ctx: ServerRequestContext[Any, Any], params: CancelTaskParams
) -> CancelTaskResult: ) -> CancelTaskResult:
self._require_tasks_capability(ctx) self._check_task_request(ctx, params.task_id)
return await tasks_cancel(self.server, params.task_id) return await tasks_cancel(self.server, params.task_id)
async def intercept_tool_call( async def intercept_tool_call(
@ -183,7 +219,7 @@ class TasksExtension(ServerExtension):
"""Decide whether to run this ``tools/call`` as a task. """Decide whether to run this ``tools/call`` as a task.
Consults the tool's ``TaskConfig`` mode and the client's per-request Consults the tool's ``TaskConfig`` mode and the client's per-request
opt-in: ``required`` always tasks (raising -32003 if the client did not opt-in: ``required`` always tasks (raising -32021 if the client did not
opt in), ``optional`` tasks only when the client opted in, ``forbidden`` opt in), ``optional`` tasks only when the client opted in, ``forbidden``
never tasks. A non-task call passes straight through to the tool body. never tasks. A non-task call passes straight through to the tool body.
""" """

View file

@ -42,6 +42,7 @@ from fastmcp_tasks.input_store import (
acquire_update_lock, acquire_update_lock,
acquire_update_lock_blocking, acquire_update_lock_blocking,
clear_outstanding, clear_outstanding,
discard_outstanding,
is_cancelled, is_cancelled,
load_current_leg, load_current_leg,
load_task_args, load_task_args,
@ -296,18 +297,26 @@ async def tasks_get(server: FastMCP, task_id: str) -> GetTaskResult:
if execution.state == ExecutionState.FAILED: if execution.state == ExecutionState.FAILED:
message = "Task failed" message = "Task failed"
error: dict[str, Any] = {
"code": mcp_types.INTERNAL_ERROR,
"message": message,
}
try: try:
await execution.get_result(timeout=timedelta(seconds=0)) await execution.get_result(timeout=timedelta(seconds=0))
# On a FAILED execution, get_result re-raises the exception the task # On a FAILED execution, get_result re-raises the exception the task
# itself raised — an arbitrary user-defined type, so no narrower catch # itself raised — an arbitrary user-defined type, so no narrower catch
# exists. Its message becomes the task's error payload. # exists. Its message becomes the task's error payload; an MCPError
except Exception as error: # already *is* a JSON-RPC error, so its code and data are preserved
message = str(error) # rather than flattened to an internal error.
return build( except MCPError as protocol_error:
"failed", message = protocol_error.error.message
status_message=message, error = {"code": protocol_error.error.code, "message": message}
error={"code": mcp_types.INTERNAL_ERROR, "message": message}, if protocol_error.error.data is not None:
) error["data"] = protocol_error.error.data
except Exception as unexpected:
message = str(unexpected)
error = {"code": mcp_types.INTERNAL_ERROR, "message": message}
return build("failed", status_message=message, error=error)
if execution.state == ExecutionState.CANCELLED: if execution.state == ExecutionState.CANCELLED:
return build("cancelled") return build("cancelled")
@ -352,21 +361,37 @@ async def tasks_update(
if await is_cancelled(docket, task_scope, task_id): if await is_cancelled(docket, task_scope, task_id):
return UpdateTaskResult() return UpdateTaskResult()
translated = await translate_responses( matched = await translate_responses(
docket, task_scope, task_id, leg_number, input_responses docket, task_scope, task_id, leg_number, input_responses
) )
if translated is None: if matched is None:
# Nothing matched the current leg's outstanding requests: the leg was # Nothing matched the current leg's outstanding requests: the leg was
# already answered, or the keys are unknown. Idempotent no-op. # already answered, or the keys are unknown. Idempotent no-op.
return UpdateTaskResult() return UpdateTaskResult()
translated, answered_keys = matched
# Store the answers for the next leg to read, then enqueue that leg. # Store the answers for the next leg to read. They accumulate: a client
# Ordering matters: the answers must be in Redis before the next leg's # may answer a multi-request ask one update at a time.
# worker context loads them, and current_leg must not advance to an
# execution that is not yet durable — so enqueue (with its durable wait)
# precedes the pointer swap.
await store_input_responses(docket, task_scope, task_id, translated) await store_input_responses(docket, task_scope, task_id, translated)
# Retire only what this update answered. While anything is still
# outstanding the task stays `input_required` and `tasks/get` surfaces
# the remaining keys — the leg re-enters only once every request has an
# answer (SEP-2663 partial fulfillment).
await discard_outstanding(
docket, task_scope, task_id, leg_number, answered_keys
)
still_pending = await read_outstanding_inputs(
docket, task_scope, task_id, leg_number
)
if still_pending:
return UpdateTaskResult()
# Every request is answered, so enqueue the next leg. Ordering matters:
# the answers must be in Redis before the next leg's worker context
# loads them, and current_leg must not advance to an execution that is
# not yet durable — so enqueue (with its durable wait) precedes the
# pointer swap.
component = await registered_component_for_key( component = await registered_component_for_key(
server, parse_task_key(base_task_key)["component_identifier"] server, parse_task_key(base_task_key)["component_identifier"]
) )

View file

@ -31,6 +31,7 @@ import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import mcp_types import mcp_types
from mcp.shared.exceptions import MCPError
from fastmcp.exceptions import FastMCPError from fastmcp.exceptions import FastMCPError
from fastmcp.tools.base import InputRequiredToolResult, ToolResult from fastmcp.tools.base import InputRequiredToolResult, ToolResult
@ -142,6 +143,15 @@ def reentrant_task_fn(
result = await fn(*args, **kwargs) result = await fn(*args, **kwargs)
except FastMCPError as exc: except FastMCPError as exc:
return _error_result(tool_name, exc) return _error_result(tool_name, exc)
except MCPError:
# A protocol fault, not a tool error. SEP-2663 reserves `failed`
# for exactly this, so it must escape the wrapper: the Docket
# execution fails and `tasks/get` inlines the JSON-RPC error
# instead of reporting a completed task with an `isError` result.
logger.exception(
"background task tool %r raised a protocol error", tool_name
)
raise
except Exception as exc: except Exception as exc:
logger.exception("background task tool %r raised", tool_name) logger.exception("background task tool %r raised", tool_name)
return _error_result(tool_name, exc) return _error_result(tool_name, exc)

View file

@ -37,6 +37,8 @@ import mcp_types
from fastmcp_tasks.keys import task_redis_prefix from fastmcp_tasks.keys import task_redis_prefix
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable
from docket import Docket from docket import Docket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -250,6 +252,11 @@ async def store_outstanding(
await redis.hset(map_key, surfaced, tool_key) await redis.hset(map_key, surfaced, tool_key)
await redis.expire(requests_key, ttl_seconds) await redis.expire(requests_key, ttl_seconds)
await redis.expire(map_key, ttl_seconds) await redis.expire(map_key, ttl_seconds)
# The answers that drove this leg have been consumed by the body that
# just parked, so drop them: responses accumulate per leg (a client may
# answer a multi-request ask one key at a time), and a stale carry-over
# would make the next leg look already-answered.
await redis.delete(_input_responses_key(docket, task_scope, task_id))
if request_state is not None: if request_state is not None:
await redis.set(state_key, request_state, ex=ttl_seconds) await redis.set(state_key, request_state, ex=ttl_seconds)
else: else:
@ -306,7 +313,7 @@ async def translate_responses(
task_id: str, task_id: str,
leg: int, leg: int,
responses: dict[str, Any], responses: dict[str, Any],
) -> dict[str, mcp_types.Result] | None: ) -> tuple[dict[str, mcp_types.Result], list[str]] | None:
"""Translate a ``tasks/update`` payload into typed, tool-keyed responses. """Translate a ``tasks/update`` payload into typed, tool-keyed responses.
``responses`` is keyed by the surfaced keys the client received for ``leg``. ``responses`` is keyed by the surfaced keys the client received for ``leg``.
@ -314,6 +321,10 @@ async def translate_responses(
answer is validated into the result type its request maps to and re-keyed to answer is validated into the result type its request maps to and re-keyed to
the tool's own request key. Returns ``None`` when nothing matched, so the the tool's own request key. Returns ``None`` when nothing matched, so the
caller can treat a stale or empty update as an idempotent no-op. caller can treat a stale or empty update as an idempotent no-op.
Returns the tool-keyed answers alongside the surfaced keys they came from,
so the caller can retire exactly the answered requests and leave the rest
outstanding.
""" """
outstanding = await read_outstanding_inputs(docket, task_scope, task_id, leg) outstanding = await read_outstanding_inputs(docket, task_scope, task_id, leg)
if not outstanding: if not outstanding:
@ -321,6 +332,7 @@ async def translate_responses(
mapping = await _read_outstanding_map(docket, task_scope, task_id, leg) mapping = await _read_outstanding_map(docket, task_scope, task_id, leg)
translated: dict[str, mcp_types.Result] = {} translated: dict[str, mcp_types.Result] = {}
matched: list[str] = []
for surfaced_key, raw in responses.items(): for surfaced_key, raw in responses.items():
payload = outstanding.get(surfaced_key) payload = outstanding.get(surfaced_key)
if payload is None: if payload is None:
@ -331,8 +343,11 @@ async def translate_responses(
method = payload.get("method", "elicitation/create") method = payload.get("method", "elicitation/create")
result_type = result_type_for_method(method) result_type = result_type_for_method(method)
translated[tool_key] = result_type.model_validate(raw) translated[tool_key] = result_type.model_validate(raw)
matched.append(surfaced_key)
return translated or None if not translated:
return None
return translated, matched
async def store_input_responses( async def store_input_responses(
@ -347,6 +362,11 @@ async def store_input_responses(
The responses are stored typed-but-serialized (``{"type", "data"}``) so the The responses are stored typed-but-serialized (``{"type", "data"}``) so the
next leg's context factory reconstructs real result objects keyed by the next leg's context factory reconstructs real result objects keyed by the
tool's own request keys. tool's own request keys.
Answers merge into whatever the leg has already collected: a client may
answer a multi-request ask one `tasks/update` at a time, and the leg only
re-enters once every request has been answered. Callers hold the per-task
update lock, so the read-modify-write cannot interleave.
""" """
stored = { stored = {
tool_key: { tool_key: {
@ -355,12 +375,38 @@ async def store_input_responses(
} }
for tool_key, result in translated.items() for tool_key, result in translated.items()
} }
responses_key = _input_responses_key(docket, task_scope, task_id)
async with docket.redis() as redis: async with docket.redis() as redis:
await redis.set( existing_raw = _decode(await redis.get(responses_key))
_input_responses_key(docket, task_scope, task_id), if existing_raw:
json.dumps(stored), try:
ex=ttl_seconds, existing = json.loads(existing_raw)
) except json.JSONDecodeError:
existing = {}
if isinstance(existing, dict):
stored = {**existing, **stored}
await redis.set(responses_key, json.dumps(stored), ex=ttl_seconds)
async def discard_outstanding(
docket: Docket,
task_scope: str | None,
task_id: str,
leg: int,
surfaced_keys: Iterable[str],
) -> None:
"""Drop just the surfaced keys an update answered, keeping the rest pending.
Partial fulfillment (SEP-2663): a leg that asked several questions stays
``input_required`` until all are answered, and each ``tasks/get`` in between
must surface only the still-unanswered keys.
"""
keys = list(surfaced_keys)
if not keys:
return
async with docket.redis() as redis:
await redis.hdel(_requests_key(docket, task_scope, task_id, leg), *keys)
await redis.hdel(_map_key(docket, task_scope, task_id, leg), *keys)
async def clear_outstanding( async def clear_outstanding(

View file

@ -22,6 +22,9 @@ from __future__ import annotations
from typing import Any, Literal from typing import Any, Literal
from mcp_types import RequestParams, Result from mcp_types import RequestParams, Result
from mcp_types.jsonrpc import (
MISSING_REQUIRED_CLIENT_CAPABILITY as _MISSING_REQUIRED_CLIENT_CAPABILITY,
)
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
__all__ = [ __all__ = [
@ -42,8 +45,10 @@ __all__ = [
#: JSON-RPC error code for "Missing Required Client Capability" (SEP-2663). A #: JSON-RPC error code for "Missing Required Client Capability" (SEP-2663). A
#: tool whose task mode is `required` returns this when the client did not opt #: tool whose task mode is `required` returns this when the client did not opt
#: the tasks extension in for the request. #: the tasks extension in for the request, as do the `tasks/*` methods when the
MISSING_REQUIRED_CLIENT_CAPABILITY = -32003 #: client never negotiated the extension. Re-exported from the SDK so the code
#: tracks the protocol rather than an early draft's number.
MISSING_REQUIRED_CLIENT_CAPABILITY = _MISSING_REQUIRED_CLIENT_CAPABILITY
TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"]
@ -186,7 +191,7 @@ class CancelTaskRequest(BaseModel):
def missing_capability_error_data() -> dict[str, Any]: def missing_capability_error_data() -> dict[str, Any]:
"""Build the `data.requiredCapabilities` payload for a -32003 error. """Build the `data.requiredCapabilities` payload for a -32021 error.
A `required`-mode tool called without the client opting the tasks extension A `required`-mode tool called without the client opting the tasks extension
in for the request returns this so the client learns which capability to in for the request returns this so the client learns which capability to

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: server:
- completion-complete # Resource subscriptions (resources/subscribe, resources/unsubscribe) are not
- server-sse-polling # 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-subscribe
- resources-unsubscribe - 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 json
import sys import sys
from enum import Enum as PyEnum from enum import Enum as PyEnum
from typing import Annotated
import mcp_types 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 pydantic import BaseModel, Field
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
from fastmcp.prompts import Message from fastmcp.prompts import Message
from fastmcp.server.completions import CompletionValues
from fastmcp.server.context import Context from fastmcp.server.context import Context
from fastmcp.server.event_store import EventStore
from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.function_tool import FunctionTool
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.types import Audio, Image from fastmcp.utilities.types import Audio, Image
from fastmcp_tasks import TasksExtension
# Minimal 1x1 red PNG for image tests (89 bytes) # Minimal 1x1 red PNG for image tests (89 bytes)
_1X1_PNG = base64.b64decode( _1X1_PNG = base64.b64decode(
@ -47,6 +63,29 @@ _SILENT_WAV = (
server = FastMCP("conformance-test-server", dereference_schemas=False) 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 # Tools
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -261,6 +300,7 @@ server.add_tool(
"type": "object", "type": "object",
"$defs": { "$defs": {
"address": { "address": {
"$anchor": "address",
"type": "object", "type": "object",
"properties": { "properties": {
"street": {"type": "string"}, "street": {"type": "string"},
@ -272,12 +312,423 @@ server.add_tool(
"name": {"type": "string"}, "name": {"type": "string"},
"address": {"$ref": "#/$defs/address"}, "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, "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 # 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__": if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 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. """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. Requires Node.js and npx to be available on PATH.
Mark: pytest -m conformance Mark: pytest -m conformance
""" """
@ -17,7 +26,9 @@ import uvicorn
CONFORMANCE_DIR = Path(__file__).parent CONFORMANCE_DIR = Path(__file__).parent
EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml" EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml"
HOST = "127.0.0.1" 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: def _get_free_port() -> int:
@ -36,12 +47,10 @@ def _require_npx():
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def conformance_server(_require_npx): def conformance_server(_require_npx):
"""Start the conformance test server in a background thread.""" """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() port = _get_free_port()
app = mcp_server.http_app(transport="streamable-http", path=MCP_PATH) config = uvicorn.Config(build_app(), host=HOST, port=port, log_level="warning")
config = uvicorn.Config(app, host=HOST, port=port, log_level="warning")
uv_server = uvicorn.Server(config) uv_server = uvicorn.Server(config)
thread = threading.Thread(target=uv_server.run, daemon=True) thread = threading.Thread(target=uv_server.run, daemon=True)
@ -66,13 +75,13 @@ def conformance_server(_require_npx):
@pytest.mark.conformance @pytest.mark.conformance
@pytest.mark.timeout(120) @pytest.mark.timeout(180)
def test_mcp_conformance(conformance_server): def test_mcp_conformance(conformance_server):
"""Run the full MCP conformance test suite against the server.""" """Run the full MCP conformance test suite against the server."""
cmd = [ cmd = [
"npx", "npx",
"--yes", "--yes",
"@modelcontextprotocol/conformance@latest", f"@modelcontextprotocol/conformance@{CONFORMANCE_VERSION}",
"server", "server",
"--url", "--url",
conformance_server, conformance_server,
@ -83,7 +92,7 @@ def test_mcp_conformance(conformance_server):
if EXPECTED_FAILURES.exists(): if EXPECTED_FAILURES.exists():
cmd.extend(["--expected-failures", str(EXPECTED_FAILURES)]) 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 # Print output for visibility in test results
if result.stdout: if result.stdout:

View file

@ -1215,3 +1215,61 @@ class TestHttpTransport:
assert asked == ["Where would you like to fly?", "When to Paris?"] assert asked == ["Where would you like to fly?", "When to Paris?"]
assert result.data == "Booked Paris on 2026-08-01" 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")

View file

@ -135,9 +135,9 @@ async def test_tool_task_cancel():
assert final.status == "cancelled" 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 """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 = FastMCP("required-test")
mcp.add_extension(TasksExtension()) mcp.add_extension(TasksExtension())

View file

@ -1,7 +1,7 @@
"""End-to-end tests for the SEP-2663 `TasksExtension` server adapter. """End-to-end tests for the SEP-2663 `TasksExtension` server adapter.
Covers the decide-and-task interceptor (forbidden/optional/required modes and the 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 mapping, inlined completed results, argument-coercion parity, TTL, and capability
advertisement. Server-side tasks are driven in-process via `task_helpers` because advertisement. Server-side tasks are driven in-process via `task_helpers` because
there is no client task-submission API until Phase 4. 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(): 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() mcp = _tasks_server()
async with running_task_server(mcp): async with running_task_server(mcp):
srctx = ServerRequestContext( srctx = ServerRequestContext(
@ -377,7 +377,7 @@ async def test_legacy_era_required_tool_raises_missing_capability():
with bind_request_context(srctx): with bind_request_context(srctx):
with pytest.raises(MCPError) as exc_info: with pytest.raises(MCPError) as exc_info:
await mcp.call_tool("must_task", {"n": 3}) 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(): 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() mcp = _tasks_server()
extension = cast(TasksExtension, mcp._extensions[TASKS_EXTENSION_ID]) extension = cast(TasksExtension, mcp._extensions[TASKS_EXTENSION_ID])
# A request context with no tasks capability in its _meta. # A request context with no tasks capability in its _meta.

View file

@ -14,6 +14,8 @@ from __future__ import annotations
from typing import Any from typing import Any
import mcp_types import mcp_types
from mcp.shared.exceptions import MCPError
from mcp_types import INTERNAL_ERROR
from fastmcp import Context, FastMCP from fastmcp import Context, FastMCP
from fastmcp_tasks import TasksExtension from fastmcp_tasks import TasksExtension
@ -244,3 +246,79 @@ async def test_state_only_guard_round_fails_clearly():
assert final.result is not None assert final.result is not None
assert final.result["isError"] is True assert final.result["isError"] is True
assert "state-only" in final.result["content"][0]["text"] 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
keys = sorted(parked.input_requests)
assert len(keys) == 2
answered, pending = keys[0], keys[1]
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_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 return mcp
async def test_required_mode_without_opt_in_raises(self): 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() mcp = self._server()
async with running_task_server(mcp): async with running_task_server(mcp):
with pytest.raises(MCPError) as exc_info: with pytest.raises(MCPError) as exc_info:

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import pytest import pytest
from mcp import MCPError from mcp import MCPError
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS from mcp_types import INTERNAL_ERROR, INVALID_PARAMS
from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from fastmcp import Client, FastMCP from fastmcp import Client, FastMCP
from fastmcp.exceptions import ( from fastmcp.exceptions import (
@ -71,6 +72,20 @@ class TestWireErrorCodes:
assert exc_info.value.error.code == INVALID_PARAMS assert exc_info.value.error.code == INVALID_PARAMS
assert "Resource not found" in exc_info.value.error.message 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): async def test_prompt_not_found_uses_invalid_params(self):
mcp = FastMCP("test-server") mcp = FastMCP("test-server")
@ -80,3 +95,45 @@ class TestWireErrorCodes:
assert exc_info.value.error.code == INVALID_PARAMS assert exc_info.value.error.code == INVALID_PARAMS
assert "Unknown prompt" in exc_info.value.error.message 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")