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