mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 21:14:17 +02:00
Add server protocol-version floor (provisional min_protocol_version)
Declare a minimum MCP protocol version; refuse handshake clients below it at connect time and warn on floor/feature incoherence at startup.
This commit is contained in:
parent
2676864163
commit
aca5dc99b9
7 changed files with 613 additions and 0 deletions
|
|
@ -380,6 +380,14 @@ A tool can gather client input across rounds on a `2026-07-28` call by returning
|
|||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`input_responses`/`request_state` properties), `fastmcp_slim/fastmcp/server/low_level.py` (`RequestStateBoundary` install), `fastmcp_slim/fastmcp/server/server.py` (`request_state_security` param), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_call_tool` input-required passthrough + era gate), `fastmcp_slim/fastmcp/tools/base.py` (`InputRequiredToolResult`), `tests/server/test_mrtr_guards.py`.
|
||||
|
||||
### Server protocol floor — New (opt-in feature, provisional API)
|
||||
|
||||
A server can declare the minimum MCP protocol version it requires, so a client that cannot meet it is refused at connection time instead of failing mid tool-call. The motivating case is modern-only guard tools (a tool returning `InputRequiredResult`, SEP-2322): under `FastMCP(min_protocol_version="2026-07-28")` a legacy client is refused during the initialize handshake with a clear `-32602` error naming the required version and pointing at the modern protocol, rather than discovering the incompatibility inside a call. Enforcement runs through FastMCP's entry in the SDK's middleware layer at the two negotiation points the framework owns: the initialize handshake (the negotiated handshake version is compared against the floor before the handshake commits) and `server/discover` (the modern era is the newest era, so it satisfies any currently declarable floor — discovery is never refused by a floor today). At startup a conservative, warning-only coherence check flags declared/registered conflicts: guard tools under a non-modern floor (handshake clients would fail mid-call) and a modern floor paired with a `"fallback"` sampling handler (the modern era forbids the back-channel, so the fallback is dead). Runtime-only back-channel usage (`ctx.elicit`/`ctx.sample`/`ctx.list_roots`) has no reliable static signal and is not inferred. The default is no floor — every era is served, fully backward compatible.
|
||||
|
||||
The public spelling (`min_protocol_version=`) is **provisional** and expected to change (the min-without-max shape is under review); the enforcement hook and inference rules are stable regardless of the eventual keyword. No `settings.py` entry yet.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/protocol_floor.py` (validation, negotiation mirror, enforcement, coherence check), `fastmcp_slim/fastmcp/server/low_level.py` (`enforce_handshake_floor` in the initialize path), `fastmcp_slim/fastmcp/server/mixins/lifespan.py` (startup coherence call), `tests/server/test_protocol_floor.py`.
|
||||
|
||||
### The xfail register — Known gap
|
||||
|
||||
Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
title: 2026-07-28 Protocol Support
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
FastMCP v4 serves the sessionless `2026-07-28` protocol era and the session-based handshake eras from a single server, with per-connection auto-detection. This page catalogs what FastMCP provides for the modern era — both the protocol machinery it inherits from the MCP Python SDK and the capabilities FastMCP implements itself on top of that layer. It is the reference for what a v4 deployment can actually do on the modern protocol today.
|
||||
|
||||
## Identity assertion (SEP-990): a complete server-side implementation
|
||||
|
|
@ -48,6 +50,72 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
|
|||
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
|
||||
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. |
|
||||
|
||||
## Server protocol floor (DRAFT — provisional API)
|
||||
|
||||
<Warning>
|
||||
This section is a **draft** for an in-progress feature. The public spelling shown
|
||||
here (`min_protocol_version=`) is **provisional** and expected to change before
|
||||
release — the min-without-max shape is under review. The underlying mechanics
|
||||
(connect-time enforcement and startup coherence checks) are stable regardless of
|
||||
the final spelling.
|
||||
</Warning>
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
A server can depend on features that exist on only one protocol era. A tool that
|
||||
returns an `InputRequiredResult` (the modern [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol))
|
||||
works only on a `2026-07-28` connection. Today a legacy client that reaches such
|
||||
a tool over the initialize handshake gets a confusing era error *mid tool-call*,
|
||||
long after connecting — the failure surfaces far from its cause.
|
||||
|
||||
The protocol floor moves that failure to connect time. A server declares the
|
||||
minimum protocol version it requires, and FastMCP refuses any handshake below it
|
||||
with a clear error naming the required version:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.types import InputRequiredResult
|
||||
|
||||
mcp = FastMCP("guarded", min_protocol_version="2026-07-28")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def confirm(action: str) -> str | InputRequiredResult:
|
||||
... # a modern-only guard tool
|
||||
```
|
||||
|
||||
A legacy client connecting to this server is refused during `initialize` with a
|
||||
message pointing it at the modern protocol, rather than discovering the
|
||||
incompatibility inside a tool call. Modern (`server/discover`) clients connect
|
||||
normally — the modern era satisfies any currently declarable floor, because it is
|
||||
the newest era.
|
||||
|
||||
**Enforcement points.** The floor is applied at the two negotiation points
|
||||
FastMCP owns, both through the framework's entry in the SDK's middleware layer:
|
||||
|
||||
- **Initialize handshake** — the connection's negotiated handshake version is
|
||||
compared against the floor before the handshake commits; a version below the
|
||||
floor is refused with `-32602` (invalid params).
|
||||
- **`server/discover` (modern)** — the modern era is the newest era, so every
|
||||
modern connection satisfies any floor a server can declare today; discovery is
|
||||
therefore never refused by a floor in the current version set.
|
||||
|
||||
**Startup coherence check.** FastMCP knows what is registered, so at startup it
|
||||
warns (never fails) when the declared floor and the registered features conflict:
|
||||
|
||||
- A guard tool (returns `InputRequiredResult`) under a non-modern floor will fail
|
||||
for handshake-era clients mid-call — the warning names the tool and recommends
|
||||
a modern floor.
|
||||
- A modern floor combined with a `sampling_handler` set to `"fallback"` is a dead
|
||||
preference: the modern era forbids the server-initiated back-channel, so the
|
||||
local handler always runs. The warning suggests `"always"` or a lower floor.
|
||||
|
||||
Runtime-only back-channel usage (`ctx.elicit`, `ctx.sample`, `ctx.list_roots`)
|
||||
has no reliable static signal and is deliberately not inferred.
|
||||
|
||||
The default is no floor: a server without `min_protocol_version` serves every
|
||||
protocol era, fully backward compatible.
|
||||
|
||||
## Still in the program
|
||||
|
||||
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@ class FastMCPServerMiddleware:
|
|||
) -> HandlerResult:
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.middleware.middleware import MiddlewareContext
|
||||
from fastmcp.server.protocol_floor import enforce_handshake_floor
|
||||
|
||||
# Reconstruct the InitializeRequest from the raw params so FastMCP
|
||||
# middleware `on_initialize` hooks that inspect the message still work.
|
||||
|
|
@ -348,6 +349,12 @@ class FastMCPServerMiddleware:
|
|||
async def call_original_handler(
|
||||
_mw_ctx: MiddlewareContext,
|
||||
) -> mcp_types.InitializeResult | None:
|
||||
# Refuse the handshake before it commits when the negotiated version
|
||||
# is below the server's declared protocol floor. Raising MCPError
|
||||
# here (before call_next) vetoes initialize on the framework-owned
|
||||
# path, so the client sees a clear connect-time refusal instead of a
|
||||
# runtime era error mid tool-call.
|
||||
enforce_handshake_floor(fastmcp, init_message)
|
||||
# call_next(ctx) runs the rest of the SDK chain, which for
|
||||
# initialize returns the serialized InitializeResult dict. FastMCP
|
||||
# middleware `on_initialize` hooks expect a typed InitializeResult,
|
||||
|
|
|
|||
|
|
@ -246,6 +246,14 @@ class LifespanMixin:
|
|||
for provider in self.providers:
|
||||
await stack.enter_async_context(provider.lifespan())
|
||||
|
||||
# Warn (never raise) when the declared protocol floor and the
|
||||
# registered features are incoherent — e.g. modern-only guard tools
|
||||
# with no modern floor. Runs once per fresh lifespan entry, after all
|
||||
# providers are mounted so their components are visible.
|
||||
from fastmcp.server.protocol_floor import check_protocol_coherence
|
||||
|
||||
await check_protocol_coherence(self)
|
||||
|
||||
self._started.set()
|
||||
try:
|
||||
yield
|
||||
|
|
|
|||
210
fastmcp_slim/fastmcp/server/protocol_floor.py
Normal file
210
fastmcp_slim/fastmcp/server/protocol_floor.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Server protocol-version floor: declaration, negotiation-time enforcement, and
|
||||
startup incoherence detection.
|
||||
|
||||
A FastMCP server may depend on features that only exist on a particular MCP
|
||||
protocol era. The clearest example is the modern (``2026-07-28``) multi-round
|
||||
"guard" pattern (SEP-2322): a tool that returns an ``InputRequiredResult`` works
|
||||
only on a modern connection. A legacy client that reaches such a tool over the
|
||||
initialize handshake gets a confusing era error *mid tool-call* instead of a
|
||||
clear refusal at connect time.
|
||||
|
||||
This module lets a server declare a minimum protocol version (a "floor") and
|
||||
enforces it at the two connection-negotiation points FastMCP owns:
|
||||
|
||||
* the initialize handshake (legacy era), refused before the handshake commits;
|
||||
* ``server/discover`` (modern era), which always satisfies any currently
|
||||
declarable floor because the modern era is the newest era.
|
||||
|
||||
It also runs a conservative, warning-only coherence check at server startup: it
|
||||
infers the modern requirement from registered guard tools and flags declared
|
||||
floors that contradict a configured back-channel handler.
|
||||
|
||||
.. note::
|
||||
The public spelling (``FastMCP(min_protocol_version=...)``) is **provisional**
|
||||
and expected to change. The negotiation hook and the inference rules in this
|
||||
module are valid under any eventual spelling; only the constructor keyword is
|
||||
a placeholder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import INVALID_PARAMS
|
||||
from mcp_types.version import (
|
||||
HANDSHAKE_PROTOCOL_VERSIONS,
|
||||
KNOWN_PROTOCOL_VERSIONS,
|
||||
LATEST_HANDSHAKE_VERSION,
|
||||
MODERN_PROTOCOL_VERSIONS,
|
||||
is_version_at_least,
|
||||
)
|
||||
|
||||
from fastmcp.tools.function_parsing import _contains_input_required
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import mcp_types
|
||||
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The oldest modern (per-request-envelope) protocol version. A floor at or above
|
||||
# this value means "modern connections only" — no handshake-era client can
|
||||
# satisfy it, since the handshake era tops out at LATEST_HANDSHAKE_VERSION.
|
||||
_MODERN_FLOOR = MODERN_PROTOCOL_VERSIONS[0]
|
||||
|
||||
|
||||
def validate_protocol_floor(value: str | None) -> str | None:
|
||||
"""Validate a declared protocol-version floor at construction time.
|
||||
|
||||
Returns the value unchanged when it is ``None`` (no floor) or a known
|
||||
protocol revision. Raises ``ValueError`` for any unrecognized string — a
|
||||
floor the SDK could never negotiate is a programming error, not a runtime
|
||||
condition to warn about.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if value not in KNOWN_PROTOCOL_VERSIONS:
|
||||
raise ValueError(
|
||||
f"min_protocol_version={value!r} is not a known MCP protocol version. "
|
||||
f"Known versions: {', '.join(KNOWN_PROTOCOL_VERSIONS)}."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def handshake_negotiated_version(requested: str | None) -> str:
|
||||
"""The version an initialize handshake would settle on for ``requested``.
|
||||
|
||||
Mirrors the SDK's ``ServerRunner._negotiate_initialize``: a client's
|
||||
requested handshake revision is honored; anything else (an unknown string,
|
||||
or a modern-era version the handshake cannot serve) counters with the newest
|
||||
handshake revision. The connection operates at the returned version, so it is
|
||||
what the floor check must compare against.
|
||||
"""
|
||||
if requested is not None and requested in HANDSHAKE_PROTOCOL_VERSIONS:
|
||||
return requested
|
||||
return LATEST_HANDSHAKE_VERSION
|
||||
|
||||
|
||||
def enforce_handshake_floor(
|
||||
fastmcp: FastMCP,
|
||||
init_message: mcp_types.InitializeRequest | None,
|
||||
) -> None:
|
||||
"""Refuse an initialize handshake that cannot meet the server's floor.
|
||||
|
||||
Called from the framework-owned initialize path (the SDK's middleware layer)
|
||||
before the handshake commits. When the connection's negotiated handshake
|
||||
version is below the floor, raises ``MCPError`` so the client sees a clear
|
||||
connect-time refusal naming the required version instead of a runtime era
|
||||
error later. A ``None`` floor (the default) never refuses.
|
||||
"""
|
||||
floor = fastmcp.min_protocol_version
|
||||
if floor is None or init_message is None:
|
||||
return
|
||||
requested = init_message.params.protocol_version
|
||||
negotiated = handshake_negotiated_version(requested)
|
||||
if is_version_at_least(negotiated, floor):
|
||||
return
|
||||
detail = (
|
||||
"Connect using the modern protocol (server/discover) instead."
|
||||
if is_version_at_least(floor, _MODERN_FLOOR)
|
||||
else "Upgrade the client or connect with a newer protocol version."
|
||||
)
|
||||
raise MCPError(
|
||||
code=INVALID_PARAMS,
|
||||
message=(
|
||||
f"Server {fastmcp.name!r} requires MCP protocol version {floor} or "
|
||||
f"newer; the initialize handshake offered {requested!r} "
|
||||
f"(negotiates to {negotiated}). {detail}"
|
||||
),
|
||||
data={"requiredProtocolVersion": floor, "offeredProtocolVersion": requested},
|
||||
)
|
||||
|
||||
|
||||
def tool_requires_modern(tool: Tool) -> bool:
|
||||
"""True when a tool's return annotation makes it a modern-only guard tool.
|
||||
|
||||
A guard tool (SEP-2322) returns an ``InputRequiredResult`` to ask the client
|
||||
for input across rounds; that pattern exists only on the modern era. Detection
|
||||
reuses ``_contains_input_required`` over the tool's captured return annotation,
|
||||
so every union/alias/``Annotated`` shape the parser recognizes is covered. Only
|
||||
``FunctionTool`` carries a return annotation; other tool kinds return ``False``
|
||||
(a conservative miss, not a false positive).
|
||||
"""
|
||||
if not isinstance(tool, FunctionTool):
|
||||
return False
|
||||
return _contains_input_required(tool.return_type)
|
||||
|
||||
|
||||
async def check_protocol_coherence(fastmcp: FastMCP) -> None:
|
||||
"""Warn at startup when the declared floor and registered features conflict.
|
||||
|
||||
Conservative by design: emits actionable warnings, never raises. Two rules:
|
||||
|
||||
1. **Modern-only guard tools under a non-modern floor.** If any registered
|
||||
tool is a guard tool (returns ``InputRequiredResult``) but the floor does
|
||||
not guarantee a modern connection, handshake-era clients that reach those
|
||||
tools fail mid-call. Recommends declaring a modern floor.
|
||||
2. **Modern floor with a back-channel sampling fallback.** A modern floor
|
||||
forbids the server-initiated back-channel, so a ``sampling_handler`` set to
|
||||
``"fallback"`` (prefer the client's model, fall back to the local handler)
|
||||
can never actually reach the client — the local handler always runs. Flags
|
||||
the dead preference.
|
||||
|
||||
Runtime-only back-channel usage (``ctx.elicit`` / ``ctx.sample`` /
|
||||
``ctx.list_roots``) has no reliable static signal, so it is deliberately not
|
||||
inferred here.
|
||||
"""
|
||||
floor = fastmcp.min_protocol_version
|
||||
floor_is_modern = floor is not None and is_version_at_least(floor, _MODERN_FLOOR)
|
||||
|
||||
# Inspect only this server's directly-registered tools. Aggregating mounted
|
||||
# children would route through their middleware chains (a startup side
|
||||
# effect); mounted or transformed guard tools are a conservative miss, not a
|
||||
# false positive. `LocalProvider.list_tools` is side-effect-free.
|
||||
try:
|
||||
tools = list(await fastmcp._local_provider.list_tools())
|
||||
except Exception as exc:
|
||||
logger.debug("Protocol coherence check could not list tools: %s", exc)
|
||||
tools = []
|
||||
|
||||
if not floor_is_modern:
|
||||
guard_tools = sorted(t.name for t in tools if tool_requires_modern(t))
|
||||
if guard_tools:
|
||||
floor_desc = (
|
||||
"no minimum protocol version is declared"
|
||||
if floor is None
|
||||
else f"the declared floor is {floor}"
|
||||
)
|
||||
logger.warning(
|
||||
"Server %r registers guard tool(s) %s that return "
|
||||
"InputRequiredResult and require the modern MCP protocol "
|
||||
"(%s), but %s. Handshake-era clients calling these tools will "
|
||||
"fail mid-call. Declare min_protocol_version=%r to refuse such "
|
||||
"clients at connect time.",
|
||||
fastmcp.name,
|
||||
", ".join(guard_tools),
|
||||
_MODERN_FLOOR,
|
||||
floor_desc,
|
||||
_MODERN_FLOOR,
|
||||
)
|
||||
|
||||
if (
|
||||
floor_is_modern
|
||||
and fastmcp.sampling_handler is not None
|
||||
and fastmcp.sampling_handler_behavior == "fallback"
|
||||
):
|
||||
logger.warning(
|
||||
"Server %r declares a modern protocol floor (%s) but configures a "
|
||||
"sampling_handler with behavior 'fallback'. The modern protocol "
|
||||
"forbids the server-initiated back-channel, so the fallback never "
|
||||
"reaches the client and the local handler always runs. Use "
|
||||
"sampling_handler_behavior='always' if that is intended, or lower "
|
||||
"the floor to allow the client back-channel.",
|
||||
fastmcp.name,
|
||||
floor,
|
||||
)
|
||||
|
|
@ -77,6 +77,7 @@ from fastmcp.server.middleware.middleware import (
|
|||
mark_interior_dispatched,
|
||||
)
|
||||
from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin
|
||||
from fastmcp.server.protocol_floor import validate_protocol_floor
|
||||
from fastmcp.server.providers import LocalProvider, Provider
|
||||
from fastmcp.server.providers.aggregate import AggregateProvider
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
|
|
@ -356,6 +357,7 @@ class FastMCP(
|
|||
session_state_store: AsyncKeyValue | None = None,
|
||||
sampling_handler: SamplingHandler | None = None,
|
||||
sampling_handler_behavior: Literal["always", "fallback"] | None = None,
|
||||
min_protocol_version: str | None = None,
|
||||
client_log_level: mcp_types.LoggingLevel | None = None,
|
||||
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
|
|
@ -530,6 +532,18 @@ class FastMCP(
|
|||
sampling_handler_behavior or "fallback"
|
||||
)
|
||||
|
||||
# Minimum MCP protocol version this server requires. Enforced at
|
||||
# connection negotiation (the initialize handshake refuses clients below
|
||||
# the floor) and checked for coherence against registered features at
|
||||
# startup. `None` (the default) declares no floor: the server serves
|
||||
# every protocol era, fully backward compatible.
|
||||
#
|
||||
# NOTE: the `min_protocol_version` spelling is provisional; see
|
||||
# `fastmcp.server.protocol_floor`.
|
||||
self._min_protocol_version: str | None = validate_protocol_floor(
|
||||
min_protocol_version
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}({self.name!r})"
|
||||
|
||||
|
|
@ -549,6 +563,15 @@ class FastMCP(
|
|||
def version(self) -> str | None:
|
||||
return self._mcp_server.version
|
||||
|
||||
@property
|
||||
def min_protocol_version(self) -> str | None:
|
||||
"""The minimum MCP protocol version this server requires, if declared.
|
||||
|
||||
`None` means no floor (serve every protocol era). The spelling is
|
||||
provisional; see `fastmcp.server.protocol_floor`.
|
||||
"""
|
||||
return self._min_protocol_version
|
||||
|
||||
@property
|
||||
def website_url(self) -> str | None:
|
||||
return self._mcp_server.website_url
|
||||
|
|
|
|||
289
tests/server/test_protocol_floor.py
Normal file
289
tests/server/test_protocol_floor.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""Server protocol-version floor: declaration, negotiation-time enforcement, and
|
||||
startup incoherence detection.
|
||||
|
||||
A server may declare a minimum protocol version. The initialize handshake refuses
|
||||
clients below the floor before the handshake commits; the modern
|
||||
(``server/discover``) era always satisfies any currently declarable floor. A
|
||||
conservative startup check warns when the declared floor and the registered
|
||||
features are incoherent.
|
||||
|
||||
The ``min_protocol_version`` spelling is provisional; these tests exercise the
|
||||
mechanics, which hold under any eventual spelling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import mcp_types
|
||||
import pytest
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
from mcp.client import Client as SDKClient
|
||||
from mcp.server import Server as LowLevelServer
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.server.protocol_floor import (
|
||||
handshake_negotiated_version,
|
||||
tool_requires_modern,
|
||||
validate_protocol_floor,
|
||||
)
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
_COHERENCE_LOGGER = "fastmcp.server.protocol_floor"
|
||||
|
||||
|
||||
def _server(mcp: FastMCP) -> LowLevelServer:
|
||||
"""The lowlevel Server the SDK client connects to in-process."""
|
||||
return mcp._mcp_server
|
||||
|
||||
|
||||
def _find_mcp_error(exc: BaseException) -> MCPError | None:
|
||||
"""Unwrap the MCPError a refused in-memory handshake surfaces.
|
||||
|
||||
The legacy in-memory transport runs ``initialize`` inside a task group, so a
|
||||
connect-time refusal propagates as an ``ExceptionGroup`` wrapping the
|
||||
``MCPError`` rather than the bare error.
|
||||
"""
|
||||
if isinstance(exc, MCPError):
|
||||
return exc
|
||||
if isinstance(exc, BaseExceptionGroup):
|
||||
for inner in exc.exceptions:
|
||||
found = _find_mcp_error(inner)
|
||||
if found is not None:
|
||||
return found
|
||||
if exc.__cause__ is not None:
|
||||
return _find_mcp_error(exc.__cause__)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction-time validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version",
|
||||
["2024-11-05", "2025-06-18", "2025-11-25", "2026-07-28", None],
|
||||
)
|
||||
def test_valid_floor_accepted(version):
|
||||
assert validate_protocol_floor(version) == version
|
||||
assert FastMCP("s", min_protocol_version=version).min_protocol_version == version
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["9999-01-01", "latest", "2026", ""])
|
||||
def test_unknown_floor_rejected(version):
|
||||
with pytest.raises(ValueError, match="not a known MCP protocol version"):
|
||||
validate_protocol_floor(version)
|
||||
with pytest.raises(ValueError, match="not a known MCP protocol version"):
|
||||
FastMCP("s", min_protocol_version=version)
|
||||
|
||||
|
||||
def test_default_is_no_floor():
|
||||
assert FastMCP("s").min_protocol_version is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handshake negotiation mirror
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested, expected",
|
||||
[
|
||||
("2025-11-25", "2025-11-25"),
|
||||
("2025-06-18", "2025-06-18"),
|
||||
("2024-11-05", "2024-11-05"),
|
||||
# A modern-era or unknown request counters with the newest handshake.
|
||||
("2026-07-28", "2025-11-25"),
|
||||
("garbage", "2025-11-25"),
|
||||
(None, "2025-11-25"),
|
||||
],
|
||||
)
|
||||
def test_handshake_negotiated_version(requested, expected):
|
||||
assert handshake_negotiated_version(requested) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guard-tool detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_guard_tool_detected():
|
||||
def guard(x: int) -> str | mcp_types.InputRequiredResult:
|
||||
return "ok"
|
||||
|
||||
assert tool_requires_modern(Tool.from_function(guard)) is True
|
||||
|
||||
|
||||
def test_plain_tool_not_flagged():
|
||||
def plain(x: int) -> int:
|
||||
return x
|
||||
|
||||
assert tool_requires_modern(Tool.from_function(plain)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Negotiation-time enforcement (handshake path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def floored_server() -> FastMCP:
|
||||
mcp = FastMCP("floored", min_protocol_version="2026-07-28")
|
||||
|
||||
@mcp.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
async def test_modern_floor_refuses_legacy_handshake(floored_server):
|
||||
with pytest.raises(BaseException) as excinfo:
|
||||
async with SDKClient(_server(floored_server), mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
err = _find_mcp_error(excinfo.value)
|
||||
assert err is not None
|
||||
assert err.code == mcp_types.INVALID_PARAMS
|
||||
assert "2026-07-28" in err.message
|
||||
assert "server/discover" in err.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["auto", "2026-07-28"])
|
||||
async def test_modern_floor_allows_modern(floored_server, mode):
|
||||
async with SDKClient(_server(floored_server), mode=mode) as client:
|
||||
result = await client.list_tools()
|
||||
assert [t.name for t in result.tools] == ["add"]
|
||||
|
||||
|
||||
async def test_no_floor_allows_legacy_handshake():
|
||||
mcp = FastMCP("open")
|
||||
|
||||
@mcp.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
async with SDKClient(_server(mcp), mode="legacy") as client:
|
||||
assert client.protocol_version == "2025-11-25"
|
||||
result = await client.list_tools()
|
||||
assert [t.name for t in result.tools] == ["add"]
|
||||
|
||||
|
||||
async def test_handshake_floor_allows_equal_version_client():
|
||||
mcp = FastMCP("hs", min_protocol_version="2025-11-25")
|
||||
|
||||
@mcp.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
async with SDKClient(_server(mcp), mode="legacy") as client:
|
||||
assert client.protocol_version == "2025-11-25"
|
||||
result = await client.list_tools()
|
||||
assert [t.name for t in result.tools] == ["add"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup incoherence detection (warnings only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _coherence_warnings(caplog) -> list[str]:
|
||||
return [
|
||||
r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.name == _COHERENCE_LOGGER and r.levelno == logging.WARNING
|
||||
]
|
||||
|
||||
|
||||
async def test_guard_tool_without_floor_warns(caplog):
|
||||
mcp = FastMCP("guardy")
|
||||
|
||||
@mcp.tool
|
||||
def ask(x: int) -> str | mcp_types.InputRequiredResult:
|
||||
return "ok"
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
|
||||
async with mcp._lifespan_manager():
|
||||
pass
|
||||
|
||||
warnings = _coherence_warnings(caplog)
|
||||
assert any("ask" in w and "modern" in w.lower() for w in warnings)
|
||||
|
||||
|
||||
async def test_guard_tool_with_modern_floor_silent(caplog):
|
||||
mcp = FastMCP("guardy", min_protocol_version="2026-07-28")
|
||||
|
||||
@mcp.tool
|
||||
def ask(x: int) -> str | mcp_types.InputRequiredResult:
|
||||
return "ok"
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
|
||||
async with mcp._lifespan_manager():
|
||||
pass
|
||||
|
||||
assert _coherence_warnings(caplog) == []
|
||||
|
||||
|
||||
async def test_modern_floor_with_fallback_sampling_warns(caplog):
|
||||
async def handler(messages, params, context):
|
||||
return "x"
|
||||
|
||||
mcp = FastMCP(
|
||||
"samp",
|
||||
min_protocol_version="2026-07-28",
|
||||
sampling_handler=handler,
|
||||
sampling_handler_behavior="fallback",
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
|
||||
async with mcp._lifespan_manager():
|
||||
pass
|
||||
|
||||
warnings = _coherence_warnings(caplog)
|
||||
assert any("fallback" in w and "back-channel" in w for w in warnings)
|
||||
|
||||
|
||||
async def test_modern_floor_with_always_sampling_silent(caplog):
|
||||
async def handler(messages, params, context):
|
||||
return "x"
|
||||
|
||||
mcp = FastMCP(
|
||||
"samp",
|
||||
min_protocol_version="2026-07-28",
|
||||
sampling_handler=handler,
|
||||
sampling_handler_behavior="always",
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
|
||||
async with mcp._lifespan_manager():
|
||||
pass
|
||||
|
||||
assert _coherence_warnings(caplog) == []
|
||||
|
||||
|
||||
async def test_plain_server_is_coherent(caplog):
|
||||
mcp = FastMCP("clean")
|
||||
|
||||
@mcp.tool
|
||||
def plain(a: int) -> int:
|
||||
return a
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
|
||||
async with mcp._lifespan_manager():
|
||||
pass
|
||||
|
||||
assert _coherence_warnings(caplog) == []
|
||||
|
||||
|
||||
async def test_guard_tool_reaches_modern_client(floored_server):
|
||||
"""A guard tool served under a modern floor works end-to-end on modern."""
|
||||
mcp = FastMCP("guarded", min_protocol_version="2026-07-28")
|
||||
|
||||
@mcp.tool
|
||||
async def confirm(ctx: Context) -> str | mcp_types.InputRequiredResult:
|
||||
return "confirmed"
|
||||
|
||||
async with SDKClient(_server(mcp), mode="auto") as client:
|
||||
result = await client.call_tool("confirm", {})
|
||||
assert result.is_error is False
|
||||
Loading…
Add table
Add a link
Reference in a new issue