Gate task registry shim to handshake-era protocol versions

This commit is contained in:
Jeremiah Lowin 2026-07-06 14:54:54 -04:00
commit 8a58b1c4e0
No known key found for this signature in database
2 changed files with 92 additions and 13 deletions

View file

@ -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:

View file

@ -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
# ---------------------------------------------------------------------------