From 7699deb99c31b40004cd4e10dafcd70e0848806a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:00:08 -0400 Subject: [PATCH] Pass the MCP conformance suite's draft and pending scenarios Pin the suite, build out the fixture, and fix the protocol gaps it found. --- fastmcp_slim/fastmcp/prompts/base.py | 39 ++ .../fastmcp/server/mixins/mcp_operations.py | 28 +- fastmcp_slim/fastmcp/server/server.py | 19 + fastmcp_tasks/fastmcp_tasks/extension.py | 48 +- fastmcp_tasks/fastmcp_tasks/handlers.py | 55 +- fastmcp_tasks/fastmcp_tasks/input_loop.py | 10 + fastmcp_tasks/fastmcp_tasks/input_store.py | 60 ++- fastmcp_tasks/fastmcp_tasks/models.py | 11 +- tests/conformance/expected-failures.yml | 23 +- tests/conformance/server.py | 498 +++++++++++++++++- tests/conformance/test_conformance.py | 25 +- tests/server/test_mrtr_guards.py | 58 ++ tests/tasks/client/test_client_tool_tasks.py | 4 +- tests/tasks/server/test_extension.py | 19 +- tests/tasks/server/test_guard_reentrant.py | 78 +++ tests/tasks/server/test_task_config.py | 2 +- tests/test_exceptions.py | 57 ++ 17 files changed, 979 insertions(+), 55 deletions(-) diff --git a/fastmcp_slim/fastmcp/prompts/base.py b/fastmcp_slim/fastmcp/prompts/base.py index d23ab0e0f..b7e4fa3f6 100644 --- a/fastmcp_slim/fastmcp/prompts/base.py +++ b/fastmcp_slim/fastmcp/prompts/base.py @@ -10,6 +10,7 @@ import pydantic_core if TYPE_CHECKING: from fastmcp.prompts.function_prompt import FunctionPrompt +import mcp_types from mcp import GetPromptResult from mcp_types import ( 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): """A prompt template that can be rendered with parameters.""" @@ -287,6 +320,12 @@ class Prompt(FastMCPComponent): if isinstance(raw_value, PromptResult): 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): return PromptResult(raw_value, description=self.description, meta=self.meta) diff --git a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py index af85dd08c..642e28879 100644 --- a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py +++ b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py @@ -28,6 +28,7 @@ from fastmcp.exceptions import ( NotFoundError, to_mcp_error, ) +from fastmcp.prompts.base import InputRequiredPromptResult from fastmcp.server.completions import CompletionValues, normalize_completion from fastmcp.server.dependencies import bind_request_context, extract_version_spec from fastmcp.tools.base import InputRequiredToolResult, ToolResult @@ -306,8 +307,12 @@ class MCPOperationsMixin: try: result = await self.read_resource(str(uri), version=version) except (DisabledError, NotFoundError) as e: - raise to_mcp_error( - NotFoundError(f"Resource not found: {str(uri)!r}") + # SEP-2164: echo the requested URI in `data` so a client that + # 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 except FastMCPError as e: # Resource-visible errors (ResourceError, ValidationError, ...) @@ -326,7 +331,7 @@ class MCPOperationsMixin: self: FastMCP, ctx: ServerRequestContext, params: GetPromptRequestParams, - ) -> mcp_types.GetPromptResult: + ) -> mcp_types.GetPromptResult | mcp_types.InputRequiredResult: """Handle MCP 'prompts/get' requests.""" with bind_request_context(ctx): name = params.name @@ -351,6 +356,23 @@ class MCPOperationsMixin: # Masking already happened inside render_prompt. 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() async def _on_set_logging_level( diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index 15e03c5f2..51c0a476c 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -31,6 +31,7 @@ from mcp_types import ( CallToolRequestParams, ToolAnnotations, ) +from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from pydantic import AnyUrl from pydantic import ValidationError as PydanticValidationError from starlette.routing import BaseRoute @@ -1484,6 +1485,24 @@ class FastMCP( ) raise 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}") # Handle actionable errors that should reach the LLM # even when masking is enabled diff --git a/fastmcp_tasks/fastmcp_tasks/extension.py b/fastmcp_tasks/fastmcp_tasks/extension.py index bc34daaa8..7ba4f123d 100644 --- a/fastmcp_tasks/fastmcp_tasks/extension.py +++ b/fastmcp_tasks/fastmcp_tasks/extension.py @@ -32,10 +32,12 @@ from typing import TYPE_CHECKING, Any from mcp.server.context import ServerRequestContext 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 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 ( MethodBinding, ServerExtension, @@ -140,7 +142,7 @@ class TasksExtension(ServerExtension): """Reject a task method from a client that did not declare the extension. 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 on the creating `tools/call`, but the method-level check is an explicit MUST, so enforce it here rather than assume. @@ -156,22 +158,56 @@ class TasksExtension(ServerExtension): 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( self, ctx: ServerRequestContext[Any, Any], params: GetTaskParams ) -> GetTaskResult: - self._require_tasks_capability(ctx) + self._check_task_request(ctx, params.task_id) return await tasks_get(self.server, params.task_id) async def _handle_update( self, ctx: ServerRequestContext[Any, Any], params: UpdateTaskParams ) -> 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) async def _handle_cancel( self, ctx: ServerRequestContext[Any, Any], params: CancelTaskParams ) -> CancelTaskResult: - self._require_tasks_capability(ctx) + self._check_task_request(ctx, params.task_id) return await tasks_cancel(self.server, params.task_id) async def intercept_tool_call( @@ -183,7 +219,7 @@ class TasksExtension(ServerExtension): """Decide whether to run this ``tools/call`` as a task. 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`` never tasks. A non-task call passes straight through to the tool body. """ diff --git a/fastmcp_tasks/fastmcp_tasks/handlers.py b/fastmcp_tasks/fastmcp_tasks/handlers.py index b12aabf19..526416474 100644 --- a/fastmcp_tasks/fastmcp_tasks/handlers.py +++ b/fastmcp_tasks/fastmcp_tasks/handlers.py @@ -42,6 +42,7 @@ from fastmcp_tasks.input_store import ( acquire_update_lock, acquire_update_lock_blocking, clear_outstanding, + discard_outstanding, is_cancelled, load_current_leg, load_task_args, @@ -296,18 +297,26 @@ async def tasks_get(server: FastMCP, task_id: str) -> GetTaskResult: if execution.state == ExecutionState.FAILED: message = "Task failed" + error: dict[str, Any] = { + "code": mcp_types.INTERNAL_ERROR, + "message": message, + } try: await execution.get_result(timeout=timedelta(seconds=0)) # On a FAILED execution, get_result re-raises the exception the task # itself raised — an arbitrary user-defined type, so no narrower catch - # exists. Its message becomes the task's error payload. - except Exception as error: - message = str(error) - return build( - "failed", - status_message=message, - error={"code": mcp_types.INTERNAL_ERROR, "message": message}, - ) + # exists. Its message becomes the task's error payload; an MCPError + # already *is* a JSON-RPC error, so its code and data are preserved + # rather than flattened to an internal error. + except MCPError as protocol_error: + message = protocol_error.error.message + error = {"code": protocol_error.error.code, "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: return build("cancelled") @@ -352,21 +361,37 @@ async def tasks_update( if await is_cancelled(docket, task_scope, task_id): return UpdateTaskResult() - translated = await translate_responses( + matched = await translate_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 # already answered, or the keys are unknown. Idempotent no-op. return UpdateTaskResult() + translated, answered_keys = matched - # Store the answers for the next leg to read, then enqueue that 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. + # Store the answers for the next leg to read. They accumulate: a client + # may answer a multi-request ask one update at a time. 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( server, parse_task_key(base_task_key)["component_identifier"] ) diff --git a/fastmcp_tasks/fastmcp_tasks/input_loop.py b/fastmcp_tasks/fastmcp_tasks/input_loop.py index 07145d5bf..f1f146c09 100644 --- a/fastmcp_tasks/fastmcp_tasks/input_loop.py +++ b/fastmcp_tasks/fastmcp_tasks/input_loop.py @@ -31,6 +31,7 @@ import logging from typing import TYPE_CHECKING, Any import mcp_types +from mcp.shared.exceptions import MCPError from fastmcp.exceptions import FastMCPError from fastmcp.tools.base import InputRequiredToolResult, ToolResult @@ -142,6 +143,15 @@ def reentrant_task_fn( result = await fn(*args, **kwargs) except FastMCPError as 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: logger.exception("background task tool %r raised", tool_name) return _error_result(tool_name, exc) diff --git a/fastmcp_tasks/fastmcp_tasks/input_store.py b/fastmcp_tasks/fastmcp_tasks/input_store.py index d13a6e82e..9dab545ea 100644 --- a/fastmcp_tasks/fastmcp_tasks/input_store.py +++ b/fastmcp_tasks/fastmcp_tasks/input_store.py @@ -37,6 +37,8 @@ import mcp_types from fastmcp_tasks.keys import task_redis_prefix if TYPE_CHECKING: + from collections.abc import Iterable + from docket import Docket logger = logging.getLogger(__name__) @@ -250,6 +252,11 @@ async def store_outstanding( await redis.hset(map_key, surfaced, tool_key) await redis.expire(requests_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: await redis.set(state_key, request_state, ex=ttl_seconds) else: @@ -306,7 +313,7 @@ async def translate_responses( task_id: str, leg: int, 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. ``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 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. + + 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) if not outstanding: @@ -321,6 +332,7 @@ async def translate_responses( mapping = await _read_outstanding_map(docket, task_scope, task_id, leg) translated: dict[str, mcp_types.Result] = {} + matched: list[str] = [] for surfaced_key, raw in responses.items(): payload = outstanding.get(surfaced_key) if payload is None: @@ -331,8 +343,11 @@ async def translate_responses( method = payload.get("method", "elicitation/create") result_type = result_type_for_method(method) 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( @@ -347,6 +362,11 @@ async def store_input_responses( The responses are stored typed-but-serialized (``{"type", "data"}``) so the next leg's context factory reconstructs real result objects keyed by the 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 = { tool_key: { @@ -355,12 +375,38 @@ async def store_input_responses( } for tool_key, result in translated.items() } + responses_key = _input_responses_key(docket, task_scope, task_id) async with docket.redis() as redis: - await redis.set( - _input_responses_key(docket, task_scope, task_id), - json.dumps(stored), - ex=ttl_seconds, - ) + existing_raw = _decode(await redis.get(responses_key)) + if existing_raw: + try: + 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( diff --git a/fastmcp_tasks/fastmcp_tasks/models.py b/fastmcp_tasks/fastmcp_tasks/models.py index 761b7ebf4..a58fde3a1 100644 --- a/fastmcp_tasks/fastmcp_tasks/models.py +++ b/fastmcp_tasks/fastmcp_tasks/models.py @@ -22,6 +22,9 @@ from __future__ import annotations from typing import Any, Literal 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 __all__ = [ @@ -42,8 +45,10 @@ __all__ = [ #: 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 -#: the tasks extension in for the request. -MISSING_REQUIRED_CLIENT_CAPABILITY = -32003 +#: the tasks extension in for the request, as do the `tasks/*` methods when the +#: 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"] @@ -186,7 +191,7 @@ class CancelTaskRequest(BaseModel): 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 in for the request returns this so the client learns which capability to diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml index 46b2081de..9e3ed9dde 100644 --- a/tests/conformance/expected-failures.yml +++ b/tests/conformance/expected-failures.yml @@ -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 diff --git a/tests/conformance/server.py b/tests/conformance/server.py index 9a6a97c28..ed91ee289 100644 --- a/tests/conformance/server.py +++ b/tests/conformance/server.py @@ -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") diff --git a/tests/conformance/test_conformance.py b/tests/conformance/test_conformance.py index 78749f8bc..824c7b401 100644 --- a/tests/conformance/test_conformance.py +++ b/tests/conformance/test_conformance.py @@ -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: diff --git a/tests/server/test_mrtr_guards.py b/tests/server/test_mrtr_guards.py index 8355bdebe..79c03a606 100644 --- a/tests/server/test_mrtr_guards.py +++ b/tests/server/test_mrtr_guards.py @@ -1215,3 +1215,61 @@ class TestHttpTransport: assert asked == ["Where would you like to fly?", "When to Paris?"] assert result.data == "Booked Paris on 2026-08-01" + + +class TestPromptGuard: + """`InputRequiredResult` is a result type, not a tools/call feature, so a + prompt can ask for input the same way a tool does (SEP-2322).""" + + @staticmethod + def _context_prompt_server() -> FastMCP: + mcp = FastMCP("prompt-guard") + + @mcp.prompt + async def summarize(ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return _ask( + _elicit("context", "What context?", "context"), + key="context", + request_state=None, + ) + return f"Summarizing with {_accepted(responses, 'context')['context']}" + + return mcp + + async def test_prompt_emits_input_required(self): + """The asking round reaches the wire as an InputRequiredResult.""" + async with Client(self._context_prompt_server(), mode="auto") as client: + result = await client.session.get_prompt( + "summarize", allow_input_required=True + ) + + assert isinstance(result, InputRequiredResult) + assert "context" in result.input_requests + + async def test_prompt_completes_with_responses(self): + """Answering the ask renders the prompt on the next round.""" + mcp = self._context_prompt_server() + async with Client(mcp, mode="auto") as client: + ask = await client.session.get_prompt( + "summarize", allow_input_required=True + ) + assert isinstance(ask, InputRequiredResult) + done = await client.session.get_prompt( + "summarize", + input_responses={ + "context": mcp_types.ElicitResult( + action="accept", content={"context": "quarterly report"} + ) + }, + ) + + assert done.messages[0].content.text == ("Summarizing with quarterly report") + + async def test_prompt_guard_rejected_on_handshake_era(self): + """The result type only exists at 2026-07-28, so an older connection + gets the era named rather than a generic invalid-result failure.""" + async with Client(self._context_prompt_server(), mode="legacy") as client: + with pytest.raises(MCPError, match="2026-07-28"): + await client.session.get_prompt("summarize") diff --git a/tests/tasks/client/test_client_tool_tasks.py b/tests/tasks/client/test_client_tool_tasks.py index 815adaeba..f809b34d0 100644 --- a/tests/tasks/client/test_client_tool_tasks.py +++ b/tests/tasks/client/test_client_tool_tasks.py @@ -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()) diff --git a/tests/tasks/server/test_extension.py b/tests/tasks/server/test_extension.py index 5260565a5..44dc7ef9c 100644 --- a/tests/tasks/server/test_extension.py +++ b/tests/tasks/server/test_extension.py @@ -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. diff --git a/tests/tasks/server/test_guard_reentrant.py b/tests/tasks/server/test_guard_reentrant.py index 5adad9c32..ef57fee4d 100644 --- a/tests/tasks/server/test_guard_reentrant.py +++ b/tests/tasks/server/test_guard_reentrant.py @@ -14,6 +14,8 @@ from __future__ import annotations from typing import Any import mcp_types +from mcp.shared.exceptions import MCPError +from mcp_types import INTERNAL_ERROR from fastmcp import Context, FastMCP 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["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 + 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} diff --git a/tests/tasks/server/test_task_config.py b/tests/tasks/server/test_task_config.py index 42c5e7e98..f839debff 100644 --- a/tests/tasks/server/test_task_config.py +++ b/tests/tasks/server/test_task_config.py @@ -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: diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index d85ea0322..042274212 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -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")