From aca5dc99b90d07522de32ba5ea23deb92dca811a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:20:00 -0400 Subject: [PATCH] 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. --- docs/development/v4-notes/change-register.mdx | 8 + docs/development/v4-notes/protocol-2026.mdx | 68 +++++ fastmcp_slim/fastmcp/server/low_level.py | 7 + .../fastmcp/server/mixins/lifespan.py | 8 + fastmcp_slim/fastmcp/server/protocol_floor.py | 210 +++++++++++++ fastmcp_slim/fastmcp/server/server.py | 23 ++ tests/server/test_protocol_floor.py | 289 ++++++++++++++++++ 7 files changed, 613 insertions(+) create mode 100644 fastmcp_slim/fastmcp/server/protocol_floor.py create mode 100644 tests/server/test_protocol_floor.py diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index 9c051d63c..07f67ed4c 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -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. diff --git a/docs/development/v4-notes/protocol-2026.mdx b/docs/development/v4-notes/protocol-2026.mdx index d6f0ce0c9..5d5b0b44b 100644 --- a/docs/development/v4-notes/protocol-2026.mdx +++ b/docs/development/v4-notes/protocol-2026.mdx @@ -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) + + +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. + + + + +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. diff --git a/fastmcp_slim/fastmcp/server/low_level.py b/fastmcp_slim/fastmcp/server/low_level.py index 927ccf9de..ad7ef2147 100644 --- a/fastmcp_slim/fastmcp/server/low_level.py +++ b/fastmcp_slim/fastmcp/server/low_level.py @@ -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, diff --git a/fastmcp_slim/fastmcp/server/mixins/lifespan.py b/fastmcp_slim/fastmcp/server/mixins/lifespan.py index 77374d3f7..aa6515de2 100644 --- a/fastmcp_slim/fastmcp/server/mixins/lifespan.py +++ b/fastmcp_slim/fastmcp/server/mixins/lifespan.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/protocol_floor.py b/fastmcp_slim/fastmcp/server/protocol_floor.py new file mode 100644 index 000000000..ef91c34f0 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/protocol_floor.py @@ -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, + ) diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index b5ff38231..80f233e1f 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -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 diff --git a/tests/server/test_protocol_floor.py b/tests/server/test_protocol_floor.py new file mode 100644 index 000000000..f24493557 --- /dev/null +++ b/tests/server/test_protocol_floor.py @@ -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