diff --git a/fastmcp_slim/fastmcp/_sdk_patches.py b/fastmcp_slim/fastmcp/_sdk_patches.py index 8204bb6dc..a77765cfc 100644 --- a/fastmcp_slim/fastmcp/_sdk_patches.py +++ b/fastmcp_slim/fastmcp/_sdk_patches.py @@ -1,12 +1,16 @@ """Temporary in-place patches for gaps in the pinned MCP SDK. -## SEP-1686 task methods missing from the SDK method registries +## SEP-1686 task methods missing from the handshake-era method registries -`mcp==2.0.0b1` ships the task types (`CreateTaskResult`, `GetTaskResult`, -`GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`) but its -`mcp_types.methods` registries have no `tasks/*` rows, and every `tools/call` -result row is a plain `CallToolResult` (2025 eras) or `CallToolResult | -InputRequiredResult` (2026) with no `CreateTaskResult` arm. +This shim compensates for a genuine gap in the SDK's *handshake-era* +(2025-11-25 and earlier) task registry. In the 2025-11-25 SEP-1686 model, tasks +are a first-class part of the core protocol: `CallToolRequestParams` carries a +`task: TaskMetadata` field and a task-augmented `tools/call` returns a +`CreateTaskResult`. `mcp==2.0.0b1` ships those task types (`CreateTaskResult`, +`GetTaskResult`, `GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`) +and the `task` request field, but its `mcp_types.methods` registries were never +wired for them: there are no `tasks/*` rows, and the handshake-era `tools/call` +result rows are a plain `CallToolResult` with no `CreateTaskResult` arm. The lowlevel server runner (`mcp.server.runner`) serializes a handler's result through `serialize_server_result(method, version, ...)` for any method in @@ -19,12 +23,28 @@ their handler results already bypass serialization and reach the wire unvalidated; we still register their result rows here for symmetry and so the maps are consistent if a future SDK adds them to the spec method set. +## Scope: handshake-era versions only + +The widening + `tasks/*` registration is gated to +`HANDSHAKE_PROTOCOL_VERSIONS` (2025-11-25 and earlier) because those are the +versions where the 2025 SEP-1686 task model actually applies and where the +SDK's registry has the genuine gap we compensate for. + +The 2026-07-28 protocol is intentionally NOT patched here. Tasks left the core +protocol in 2026-07-28 and became the separate `io.modelcontextprotocol/tasks` +extension; `CreateTaskResult` and the `task` field on `CallToolRequestParams` +do not exist in that schema (a task-augmented `tools/call` was replaced by the +mutually-recursive `CallToolResult | InputRequiredResult` result). Injecting the +2025-era `CreateTaskResult` into the 2026 `tools/call` union would assert the +wrong task model onto that protocol, so we leave its rows untouched. + This module widens the registries IN PLACE (the maps are `MappingProxyType` views over private dicts, so we reach the backing dict via `gc.get_referents` and mutate it, which the already-bound default-argument references in `mcp_types.methods` observe). `install()` is idempotent. -# TODO(sdk-upstream): remove when mcp>=2.0.0bX includes SEP-1686 in method registries +# TODO(sdk-upstream): remove when mcp>=2.0.0bX wires SEP-1686 into the +# handshake-era method registries. """ from __future__ import annotations @@ -34,6 +54,7 @@ from types import MappingProxyType, UnionType import mcp_types from mcp_types import methods as _methods +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS # Result type for each task method, keyed by the client request method name. _TASK_RESULT_TYPES: dict[str, type] = { @@ -78,8 +99,13 @@ def install() -> None: server_results = _backing_dict(_methods.SERVER_RESULTS) + # Gate to handshake-era versions only: the 2025 SEP-1686 task model applies + # there, and 2026-07-28 tasks are the separate io.modelcontextprotocol/tasks + # extension (see module docstring) — its rows must stay untouched. versions_with_tools_call = { - version for (method, version) in server_results if method == "tools/call" + version + for (method, version) in server_results + if method == "tools/call" and version in HANDSHAKE_PROTOCOL_VERSIONS } for version in versions_with_tools_call: diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py index 7ec968c9e..ac56cc960 100644 --- a/tests/server/test_protocol_eras.py +++ b/tests/server/test_protocol_eras.py @@ -27,6 +27,11 @@ from mcp.client import Client as SDKClient from mcp.client.session import ClientRequestContext from mcp.server import Server as LowLevelServer from mcp.shared.exceptions import MCPError +from mcp_types import methods +from mcp_types.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, + MODERN_PROTOCOL_VERSIONS, +) from pydantic import FileUrl from fastmcp import Client as FastMCPClient @@ -403,10 +408,12 @@ async def test_task_submission_and_get_on_legacy_latest(task_server): "The v2 SDK high-level client (mcp.client.Client) and ClientSession " "expose no `task=` parameter on call_tool, so a task-augmented " "tools/call cannot be submitted through it at any era; a hand-built " - "raw CallToolRequest does not drive FastMCP's task path either. The " - "_sdk_patches shim widens the 2026-07-28 tools/call result union " - "(sdk-feedback.md #1), but there is no client-side surface to reach it. " - "Remove once the SDK client supports task submission." + "raw CallToolRequest does not drive FastMCP's task path either. On " + "2026-07-28 tasks moved to the io.modelcontextprotocol/tasks extension " + "and CreateTaskResult is not part of the tools/call union, so the " + "_sdk_patches shim intentionally does not widen the modern row " + "(sdk-feedback.md #1). Remove once the SDK client supports task " + "submission." ), ) async def test_task_submission_on_modern(task_server): @@ -422,6 +429,52 @@ async def test_task_submission_on_modern(task_server): assert isinstance(result, types.CreateTaskResult) +# --------------------------------------------------------------------------- +# 4b. _sdk_patches registry gating: the SEP-1686 task shim widens ONLY the +# handshake-era rows and leaves the 2026-07-28 (extension-era) rows untouched. +# --------------------------------------------------------------------------- + + +def test_task_shim_widens_handshake_tools_call_rows(): + """Every handshake-era tools/call row gains a CreateTaskResult arm.""" + from fastmcp._sdk_patches import get_union_arms + + for version in HANDSHAKE_PROTOCOL_VERSIONS: + row = methods.SERVER_RESULTS[("tools/call", version)] + assert types.CreateTaskResult in get_union_arms(row), version + + +def test_task_shim_does_not_touch_modern_tools_call_row(): + """The 2026-07-28 tools/call row stays the unpatched MRTR union: tasks are + the io.modelcontextprotocol/tasks extension there, so CreateTaskResult must + not be injected.""" + from fastmcp._sdk_patches import get_union_arms + + row = methods.SERVER_RESULTS[("tools/call", "2026-07-28")] + arms = get_union_arms(row) + assert types.CreateTaskResult not in arms + # Unchanged from the SDK default: the 2026 mutually-recursive tool result + # (CallToolResult | InputRequiredResult), keyed by the version-specific types. + arm_names = {arm.__name__ for arm in arms} + assert arm_names == {"CallToolResult", "InputRequiredResult"} + + +@pytest.mark.parametrize( + "task_method", + ["tasks/get", "tasks/result", "tasks/list", "tasks/cancel"], +) +def test_task_shim_registers_tasks_rows_only_for_handshake_eras(task_method): + """tasks/* result rows exist for handshake-era versions and are absent for + the modern (extension) era.""" + for version in HANDSHAKE_PROTOCOL_VERSIONS: + assert (task_method, version) in methods.SERVER_RESULTS, (task_method, version) + for version in MODERN_PROTOCOL_VERSIONS: + assert (task_method, version) not in methods.SERVER_RESULTS, ( + task_method, + version, + ) + + # --------------------------------------------------------------------------- # 5. Sessionless safety: session-id-keyed paths must not crash on 2026 in-memory # ---------------------------------------------------------------------------