Add temporary SDK shim widening SERVER_RESULTS for SEP-1686 tasks

This commit is contained in:
Jeremiah Lowin 2026-07-05 21:20:12 -04:00
commit fe0de0df56
No known key found for this signature in database
2 changed files with 110 additions and 1 deletions

View file

@ -5,10 +5,14 @@ import warnings
from importlib.metadata import PackageNotFoundError, version as _version
from typing import TYPE_CHECKING
from fastmcp import _install_hints
from fastmcp import _install_hints, _sdk_patches
from fastmcp.settings import Settings
from fastmcp.utilities.logging import configure_logging as _configure_logging
# Apply temporary SDK registry patches (SEP-1686 task methods) before any
# client/server use. See fastmcp._sdk_patches for the upstream-gap rationale.
_sdk_patches.install()
if TYPE_CHECKING:
from fastmcp.client import Client as Client
from fastmcp.apps.app import FastMCPApp as FastMCPApp

View file

@ -0,0 +1,105 @@
"""Temporary in-place patches for gaps in the pinned MCP SDK.
## SEP-1686 task methods missing from the SDK 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.
The lowlevel server runner (`mcp.server.runner`) serializes a handler's result
through `serialize_server_result(method, version, ...)` for any method in
`SPEC_CLIENT_METHODS`. `tools/call` is such a method, so when a FastMCP tool is
submitted as a background task (`client.call_tool(..., task=True)`) the handler
returns a `CreateTaskResult`, which fails validation against the un-widened
`tools/call` surface row -> the client sees "Handler returned an invalid
result". The `tasks/*` methods themselves are NOT in `SPEC_CLIENT_METHODS`, so
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.
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
"""
from __future__ import annotations
import gc
from types import MappingProxyType, UnionType
import mcp_types
from mcp_types import methods as _methods
# Result type for each task method, keyed by the client request method name.
_TASK_RESULT_TYPES: dict[str, type] = {
"tasks/get": mcp_types.GetTaskResult,
"tasks/result": mcp_types.GetTaskPayloadResult,
"tasks/list": mcp_types.ListTasksResult,
"tasks/cancel": mcp_types.CancelTaskResult,
}
_installed = False
def _backing_dict(proxy: object) -> dict:
"""Return the mutable dict a MappingProxyType wraps.
The `mcp_types.methods` surface maps are `MappingProxyType` views; their
sole dict referent is the backing store the module's functions read through
their default `surface=` arguments.
"""
referents = [r for r in gc.get_referents(proxy) if isinstance(r, dict)]
if len(referents) != 1:
raise RuntimeError(
"expected exactly one backing dict for the method registry proxy, "
f"found {len(referents)}"
)
return referents[0]
def install() -> None:
"""Widen the SDK's server-result registry for SEP-1686 task methods.
Idempotent. Safe to call at import time before any client/server use.
"""
global _installed
if _installed:
return
if not isinstance(_methods.SERVER_RESULTS, MappingProxyType):
# Registry shape changed upstream; the shim no longer applies.
_installed = True
return
server_results = _backing_dict(_methods.SERVER_RESULTS)
versions_with_tools_call = {
version for (method, version) in server_results if method == "tools/call"
}
for version in versions_with_tools_call:
# (a) widen tools/call so a CreateTaskResult validates (task submission).
existing = server_results[("tools/call", version)]
arms = get_union_arms(existing)
if mcp_types.CreateTaskResult not in arms:
server_results[("tools/call", version)] = (
existing | mcp_types.CreateTaskResult
)
# (b) register the tasks/* result rows for the same versions.
for method, result_type in _TASK_RESULT_TYPES.items():
server_results.setdefault((method, version), result_type)
_installed = True
def get_union_arms(row: type | UnionType) -> tuple[type, ...]:
"""Return the member types of a result row, whether a single type or union."""
if isinstance(row, UnionType):
return tuple(row.__args__)
return (row,)