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 1/3] 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
From f5fca97c81b8279360bb187cb7a757fee1925668 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:20:56 -0400
Subject: [PATCH 2/3] Respell server protocol restriction as a version
allowlist
Replace `min_protocol_version` with `protocol_versions`, an allowlist of MCP
protocol versions taking the SDK's own era tuples. Versions are an enumerated
set, not an ordered scalar, so enforcement is set membership rather than a
bound -- which is what makes handshake-only expressible.
Generalize the startup coherence check into a capability -> required-versions
map, and make it silent unless a version set was declared.
---
docs/development/v4-notes/change-register.mdx | 12 +-
docs/development/v4-notes/protocol-2026.mdx | 147 ++++--
fastmcp_slim/fastmcp/server/low_level.py | 27 +-
.../fastmcp/server/mixins/lifespan.py | 11 +-
fastmcp_slim/fastmcp/server/protocol_floor.py | 210 --------
.../fastmcp/server/protocol_versions.py | 320 +++++++++++++
fastmcp_slim/fastmcp/server/server.py | 33 +-
tests/server/test_protocol_floor.py | 289 -----------
tests/server/test_protocol_versions.py | 451 ++++++++++++++++++
9 files changed, 925 insertions(+), 575 deletions(-)
delete mode 100644 fastmcp_slim/fastmcp/server/protocol_floor.py
create mode 100644 fastmcp_slim/fastmcp/server/protocol_versions.py
delete mode 100644 tests/server/test_protocol_floor.py
create mode 100644 tests/server/test_protocol_versions.py
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index 07f67ed4c..35986e420 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -380,13 +380,17 @@ 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)
+### Server protocol-version restriction — New (opt-in feature)
-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.
+A server can declare the set of MCP protocol versions it serves, so a client that shares none of them is refused at connection time instead of failing mid tool-call. The declaration is an allowlist expressed in the SDK's own units — `FastMCP(protocol_versions=MODERN_PROTOCOL_VERSIONS)`, `FastMCP(protocol_versions=HANDSHAKE_PROTOCOL_VERSIONS)`, or exact strings like `["2026-07-28"]` — so authors pass the SDK's era tuples rather than FastMCP-invented alias vocabulary, and those tuples grow on their own when the SDK adds a revision to an era. An unrecognized version string is a `ValueError` at construction.
-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.
+Enforcement is **set membership, not a minimum**, matching the SDK's stated model that versions are an enumerated set rather than an ordered scalar (`mcp_types/version.py`). That is what makes handshake-only expressible: the modern era is numerically newer but *removed* the server-initiated back-channel (`ctx.elicit`/`ctx.sample`/`ctx.list_roots`), so a server built on the back-channel needs the handshake era specifically — a minimum-version model could not say that. Both connection paths are covered: the initialize handshake is refused before it commits, and modern connections are checked on the request itself, since a client pinned to a modern version never probes `server/discover`. Refusals use the spec-standard `-32022` unsupported-protocol-version error with the server's `supported` list, so a `mode="auto"` client refused at discovery by a handshake-only server reads the handshake versions out of the error and completes over `initialize` on its own.
-*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 startup coherence check is now a **capability map** rather than ad-hoc cases: each entry names a capability, the protocol versions that carry it, and a detector. Present entries are multi-round-trip guard tools (modern versions, detected via `_contains_input_required` over tool return annotations) and the client back-channel (handshake versions, detected only from the configuration-level `sampling_handler` + `"fallback"` contradiction — the runtime calls have no static signal). A future capability, notably the 2026 tasks extension, is one entry rather than a new special case. Warnings only, never a hard error, and **silent unless a version set was declared** — a server with an ordinary guard tool and no declaration is fine, and warning there would train people to ignore warnings.
+
+The default is `None`: every protocol version the SDK supports is served. Flipping a server default would disconnect existing clients rather than degrade, so restriction stays strictly opt-in. No `settings.py` entry — this is per-server configuration, not global.
+
+*Verify:* `fastmcp_slim/fastmcp/server/protocol_versions.py` (validation, negotiation mirror, enforcement, capability map, coherence check), `fastmcp_slim/fastmcp/server/low_level.py` (handshake veto in the initialize path, per-request check in the root dispatch), `fastmcp_slim/fastmcp/server/mixins/lifespan.py` (startup coherence call), `tests/server/test_protocol_versions.py`.
### The xfail register — Known gap
diff --git a/docs/development/v4-notes/protocol-2026.mdx b/docs/development/v4-notes/protocol-2026.mdx
index 5d5b0b44b..a38c48810 100644
--- a/docs/development/v4-notes/protocol-2026.mdx
+++ b/docs/development/v4-notes/protocol-2026.mdx
@@ -50,71 +50,130 @@ 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.
-
+## Restricting the protocol versions a server serves
-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.
+Most servers should skip this section. By default a FastMCP server serves every
+protocol version the SDK supports, and that is the right setting for almost
+everything — one server, every client, no configuration.
-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:
+Some servers cannot. A server can depend on a feature that exists on only one
+protocol era, and the two eras are not a ladder: the modern era added the
+multi-round-trip [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol)
+and *removed* the server-initiated back-channel (`ctx.elicit`, `ctx.sample`,
+`ctx.list_roots`) that the handshake eras provide. Neither era is a superset of
+the other, so a server can legitimately need either one.
+
+When a server's whole purpose depends on an era, a client that cannot speak it
+should be told at connect time. Today it finds out mid-call: a handshake-era
+client happily connects to a guard-tool server, lists its tools, calls one, and
+only then gets an era error — a failure surfacing far from its cause. Declaring
+the versions the server serves moves that failure to the connection.
+
+### Declaring versions
+
+Pass the protocol versions the server is willing to serve. The MCP SDK exposes
+each era as a tuple, and those tuples are the durable way to name an era — they
+grow on their own when the SDK adds a revision, so a server pinned to
+`MODERN_PROTOCOL_VERSIONS` keeps working across SDK upgrades:
```python
from fastmcp import FastMCP
from fastmcp.types import InputRequiredResult
+from mcp_types.version import MODERN_PROTOCOL_VERSIONS
-mcp = FastMCP("guarded", min_protocol_version="2026-07-28")
+mcp = FastMCP("guarded", protocol_versions=MODERN_PROTOCOL_VERSIONS)
@mcp.tool
def confirm(action: str) -> str | InputRequiredResult:
- ... # a modern-only guard tool
+ ... # a guard tool: multi-round trips exist only on the modern era
```
-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.
+A server built around the back-channel declares the other 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:
+```python
+from fastmcp import Context, FastMCP
+from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
-- **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.
+mcp = FastMCP("interviewer", protocol_versions=HANDSHAKE_PROTOCOL_VERSIONS)
-**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.
+@mcp.tool
+async def interview(ctx: Context) -> str:
+ answer = await ctx.elicit("What is your name?", response_type=str)
+ return f"Hello, {answer.data}"
+```
-Runtime-only back-channel usage (`ctx.elicit`, `ctx.sample`, `ctx.list_roots`)
-has no reliable static signal and is deliberately not inferred.
+You can also pin exact versions, which is what a server certified against a
+single revision wants:
-The default is no floor: a server without `min_protocol_version` serves every
-protocol era, fully backward compatible.
+```python
+mcp = FastMCP("pinned", protocol_versions=["2026-07-28"])
+```
+
+Any string the SDK does not recognize raises `ValueError` at construction. A
+version the SDK could never negotiate is a bug in the server, not a runtime
+condition worth warning about.
+
+### Membership, not a minimum
+
+The declaration is a **set**, not a floor. Versions are an enumerated set rather
+than an ordered scale — the SDK says so directly, and future revision
+identifiers are not guaranteed to be date-shaped or sortable. A connection is
+accepted when its negotiated version is a member of the declared set, which is
+what makes "handshake only" expressible at all: under a minimum-version model
+there is no way to say "the session era", because the modern era is numerically
+newer while lacking the features that era depends on.
+
+### What clients see
+
+A refused connection gets the spec-standard `-32022` unsupported-protocol-version
+error carrying the server's supported list, so a negotiating client treats it as
+guidance rather than a dead end:
+
+| Server declares | `mode="auto"` client | `mode="legacy"` client | Client pinned to `2026-07-28` |
+| --- | --- | --- | --- |
+| nothing (default) | modern | handshake | modern |
+| `MODERN_PROTOCOL_VERSIONS` | modern | refused | modern |
+| `HANDSHAKE_PROTOCOL_VERSIONS` | falls back to handshake | handshake | refused |
+
+An `auto` client refused at `server/discover` by a handshake-only server reads
+the handshake versions out of the error and completes the connection over
+`initialize` on its own — the restriction steers negotiation instead of breaking
+it. Only a client with no mutual version is refused outright, which is the point.
+
+Enforcement covers both connection paths FastMCP owns. The initialize handshake
+is refused before it commits. Modern connections are checked on the request
+itself, because a client pinned to a modern version never probes
+`server/discover` — refusing discovery alone would let it straight through.
+
+
+`fastmcp.Client` connects in-memory over the handshake by default, so testing a
+modern-only server in-process needs `Client(mcp, mode="auto")`. Over HTTP and
+stdio the default `auto` negotiation applies and no change is needed.
+
+
+### Startup coherence check
+
+FastMCP knows what is registered, so at startup it warns — never fails — when a
+declared version set cannot carry a capability the server actually uses. A
+handshake-only server registering a guard tool gets a warning naming the tool; a
+modern-only server configuring a `sampling_handler` with
+`sampling_handler_behavior="fallback"` gets one too, because the modern era has
+no back-channel for the fallback to reach.
+
+The check is **silent unless you declared something**. A server with ten
+ordinary tools and one guard tool is fine as-is: the guard tool raises a clear
+era error if an old client reaches for it, and warning about that at startup
+would only teach people to ignore warnings. The check fires when you asserted
+something and the registration contradicts the assertion.
+
+Detection is deliberately conservative. `ctx.elicit`, `ctx.sample`, and
+`ctx.list_roots` are runtime calls with no reliable static signal, so only
+configuration-level contradictions are caught — a missed case is a silent
+startup, never a false alarm.
## Still in the program
diff --git a/fastmcp_slim/fastmcp/server/low_level.py b/fastmcp_slim/fastmcp/server/low_level.py
index ad7ef2147..17856e5c6 100644
--- a/fastmcp_slim/fastmcp/server/low_level.py
+++ b/fastmcp_slim/fastmcp/server/low_level.py
@@ -181,6 +181,7 @@ class FastMCPServerMiddleware:
self, ctx: ServerRequestContext, call_next: CallNext
) -> HandlerResult:
from fastmcp.server.dependencies import bind_request_context
+ from fastmcp.server.protocol_versions import protocol_version_error
fastmcp = self._ref()
with (
@@ -192,6 +193,18 @@ class FastMCPServerMiddleware:
return await call_next(ctx)
if ctx.method == "initialize" and ctx.request_id is not None:
return await self._run_initialize_mw(fastmcp, ctx, call_next)
+ # Refuse a request whose protocol version the server does not serve.
+ # The handshake is vetoed at `initialize` above; a modern connection
+ # has no handshake to veto (a client pinned to a modern version can
+ # skip `server/discover` entirely), so the request itself is the
+ # enforcement point. Routing the refusal through the outer pass lets
+ # `on_message`/`on_request` observe it like any other early failure.
+ if ctx.request_id is not None:
+ version_error = protocol_version_error(fastmcp, ctx.protocol_version)
+ if version_error is not None:
+ return await self._run_outer_mw(
+ fastmcp, ctx, call_next, _raise=version_error
+ )
if ctx.request_id is not None and ctx.method in _INTERIOR_METHODS:
return await self._dispatch_component(fastmcp, ctx, call_next)
return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=None)
@@ -326,7 +339,9 @@ 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
+ from fastmcp.server.protocol_versions import (
+ enforce_handshake_protocol_version,
+ )
# Reconstruct the InitializeRequest from the raw params so FastMCP
# middleware `on_initialize` hooks that inspect the message still work.
@@ -350,11 +365,11 @@ class FastMCPServerMiddleware:
_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)
+ # is not one the server serves. 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_protocol_version(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 aa6515de2..fbed10731 100644
--- a/fastmcp_slim/fastmcp/server/mixins/lifespan.py
+++ b/fastmcp_slim/fastmcp/server/mixins/lifespan.py
@@ -246,11 +246,12 @@ 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
+ # Warn (never raise) when a declared `protocol_versions` set cannot
+ # carry a capability the server actually uses — e.g. modern-only
+ # guard tools under a handshake-only declaration. Silent when the
+ # server declared nothing. Runs once per fresh lifespan entry, after
+ # all providers are mounted so their components are visible.
+ from fastmcp.server.protocol_versions import check_protocol_coherence
await check_protocol_coherence(self)
diff --git a/fastmcp_slim/fastmcp/server/protocol_floor.py b/fastmcp_slim/fastmcp/server/protocol_floor.py
deleted file mode 100644
index ef91c34f0..000000000
--- a/fastmcp_slim/fastmcp/server/protocol_floor.py
+++ /dev/null
@@ -1,210 +0,0 @@
-"""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/protocol_versions.py b/fastmcp_slim/fastmcp/server/protocol_versions.py
new file mode 100644
index 000000000..307b81442
--- /dev/null
+++ b/fastmcp_slim/fastmcp/server/protocol_versions.py
@@ -0,0 +1,320 @@
+"""Server protocol-version restriction: declaration, enforcement, and startup
+coherence checks.
+
+A FastMCP server may depend on features that exist on only one MCP protocol era.
+The modern (``2026-07-28``) era added the multi-round-trip "guard" pattern
+(SEP-2322) and *removed* the server-initiated back-channel (``ctx.elicit``,
+``ctx.sample``, ``ctx.list_roots``) that the handshake eras provide. Neither era
+is a superset of the other, so a server can legitimately require either one.
+
+Protocol versions are therefore modeled the way the SDK models them — as *an
+enumerated set, not an ordered scalar* (see ``mcp_types.version``). A server
+declares the set of protocol versions it is willing to serve, and enforcement is
+set membership:
+
+ from mcp_types.version import MODERN_PROTOCOL_VERSIONS
+
+ FastMCP("guarded", protocol_versions=MODERN_PROTOCOL_VERSIONS)
+
+The SDK's era tuples are the durable way to name an era: they grow on their own
+when the SDK adds a revision to an era, so a server pinned to
+``MODERN_PROTOCOL_VERSIONS`` keeps working across SDK upgrades without FastMCP
+inventing alias vocabulary of its own.
+
+Enforcement happens at both connection paths FastMCP owns:
+
+* the initialize handshake, refused before the handshake commits;
+* every modern per-request envelope, refused on the request itself. A client
+ pinned to a modern version never probes ``server/discover``, so refusing
+ discovery alone would not cover it — the request *is* the connection.
+
+Refusals use the spec-standard ``-32022`` unsupported-protocol-version error
+carrying the server's supported list, so a negotiating (``mode="auto"``) client
+reads it as guidance rather than as a dead end: refused at ``server/discover``
+by a handshake-only server, it sees handshake versions in ``supported`` and
+falls back to the initialize handshake on its own.
+
+Declaring nothing (the default) serves every era the SDK supports.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable, Sequence
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from mcp.shared.exceptions import MCPError
+from mcp_types import (
+ UNSUPPORTED_PROTOCOL_VERSION,
+ UnsupportedProtocolVersionErrorData,
+)
+from mcp_types.version import (
+ HANDSHAKE_PROTOCOL_VERSIONS,
+ KNOWN_PROTOCOL_VERSIONS,
+ LATEST_HANDSHAKE_VERSION,
+ MODERN_PROTOCOL_VERSIONS,
+)
+
+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__)
+
+
+def validate_protocol_versions(
+ value: Iterable[str] | None,
+) -> tuple[str, ...] | None:
+ """Validate and normalize a declared set of protocol versions.
+
+ Returns ``None`` unchanged (no restriction), or the declared versions as a
+ deduplicated tuple in SDK order. Raises ``ValueError`` for an empty set (a
+ server that serves no protocol version can never be reached) or for any
+ string the SDK does not recognize — a version the SDK could never negotiate
+ is a programming error, not a runtime condition to warn about.
+ """
+ if value is None:
+ return None
+ declared = list(value)
+ unknown = [v for v in declared if v not in KNOWN_PROTOCOL_VERSIONS]
+ if unknown:
+ raise ValueError(
+ f"protocol_versions contains unknown MCP protocol version(s): "
+ f"{', '.join(repr(v) for v in unknown)}. "
+ f"Known versions: {', '.join(KNOWN_PROTOCOL_VERSIONS)}. "
+ f"Prefer the SDK's era tuples "
+ f"(mcp_types.version.MODERN_PROTOCOL_VERSIONS / "
+ f"HANDSHAKE_PROTOCOL_VERSIONS) over literal strings."
+ )
+ normalized = tuple(v for v in KNOWN_PROTOCOL_VERSIONS if v in declared)
+ if not normalized:
+ raise ValueError(
+ "protocol_versions must name at least one protocol version. "
+ "Pass None (the default) to serve every protocol version."
+ )
+ return normalized
+
+
+def describe_protocol_versions(versions: Sequence[str]) -> str:
+ """Human-readable description of a declared version set for error messages."""
+ if tuple(versions) == MODERN_PROTOCOL_VERSIONS:
+ return (
+ f"the modern protocol ({', '.join(versions)}), reached via server/discover"
+ )
+ if tuple(versions) == HANDSHAKE_PROTOCOL_VERSIONS:
+ return f"the handshake protocol ({', '.join(versions)}), reached via initialize"
+ return ", ".join(versions)
+
+
+def _remedy_for(versions: Sequence[str]) -> str:
+ """Actionable next step for a client that failed the version check."""
+ wants_modern = any(v in MODERN_PROTOCOL_VERSIONS for v in versions)
+ wants_handshake = any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in versions)
+ if wants_modern and not wants_handshake:
+ return "Connect using the modern protocol (server/discover) instead."
+ if wants_handshake and not wants_modern:
+ return "Connect using the initialize handshake instead."
+ return "Use one of the protocol versions this server serves."
+
+
+def protocol_version_error(fastmcp: FastMCP, version: str) -> MCPError | None:
+ """The refusal for ``version``, or ``None`` when the server serves it.
+
+ A server that declared nothing (the default) serves every version and never
+ refuses. Otherwise this is plain set membership: the declared versions are a
+ set, not a bound, so a handshake-only server refuses modern connections just
+ as a modern-only server refuses handshake connections.
+
+ The refusal is the spec-standard ``-32022`` unsupported-protocol-version
+ error carrying the server's supported list, which is what a negotiating
+ client already knows how to read: an ``auto`` client refused at
+ ``server/discover`` sees handshake versions in ``supported`` and falls back
+ to the initialize handshake instead of failing the connect.
+ """
+ allowed = fastmcp.protocol_versions
+ if allowed is None or version in allowed:
+ return None
+ return MCPError(
+ code=UNSUPPORTED_PROTOCOL_VERSION,
+ message=(
+ f"Server {fastmcp.name!r} serves {describe_protocol_versions(allowed)}; "
+ f"this connection uses MCP protocol version {version}. "
+ f"{_remedy_for(allowed)}"
+ ),
+ data=UnsupportedProtocolVersionErrorData(
+ supported=list(allowed), requested=version
+ ).model_dump(by_alias=True, mode="json"),
+ )
+
+
+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 membership check must compare against.
+ """
+ if requested is not None and requested in HANDSHAKE_PROTOCOL_VERSIONS:
+ return requested
+ return LATEST_HANDSHAKE_VERSION
+
+
+def enforce_handshake_protocol_version(
+ fastmcp: FastMCP,
+ init_message: mcp_types.InitializeRequest | None,
+) -> None:
+ """Refuse an initialize handshake the server does not serve.
+
+ Called from the framework-owned initialize path before the handshake
+ commits, so the client sees a clear connect-time refusal naming what the
+ server serves instead of a confusing era error mid tool-call.
+ """
+ if fastmcp.protocol_versions is None or init_message is None:
+ return
+ requested = init_message.params.protocol_version
+ error = protocol_version_error(fastmcp, handshake_negotiated_version(requested))
+ if error is not None:
+ raise error
+
+
+# ---------------------------------------------------------------------------
+# Capability map
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class ProtocolCapability:
+ """A server-side capability that only some protocol versions can carry.
+
+ ``detect`` returns evidence (tool names, a configuration description) that
+ the server actually uses the capability, and is consulted only when the
+ declared version set cannot carry it — so detection never costs anything on
+ a server that declared nothing.
+ """
+
+ key: str
+ label: str
+ versions: tuple[str, ...]
+ remedy: str
+ detect: Callable[[FastMCP, Sequence[Tool]], list[str]]
+
+
+def tool_uses_multi_round_trip(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)
+
+
+def _detect_multi_round_trip(fastmcp: FastMCP, tools: Sequence[Tool]) -> list[str]:
+ return sorted(t.name for t in tools if tool_uses_multi_round_trip(t))
+
+
+def _detect_client_back_channel(fastmcp: FastMCP, tools: Sequence[Tool]) -> list[str]:
+ """Configuration-level evidence that the server wants the client back-channel.
+
+ ``ctx.elicit`` / ``ctx.sample`` / ``ctx.list_roots`` are *runtime* calls with
+ no reliable static signal, so only configuration contradictions are
+ detectable. A ``sampling_handler`` set to ``"fallback"`` says "prefer the
+ client's model, fall back to mine" — a preference that cannot be honored on a
+ protocol version without the back-channel.
+ """
+ if (
+ fastmcp.sampling_handler is not None
+ and fastmcp.sampling_handler_behavior == "fallback"
+ ):
+ return ["sampling_handler with behavior='fallback'"]
+ return []
+
+
+PROTOCOL_CAPABILITIES: tuple[ProtocolCapability, ...] = (
+ ProtocolCapability(
+ key="multi_round_trip",
+ label="multi-round-trip guard tools (they return InputRequiredResult)",
+ versions=MODERN_PROTOCOL_VERSIONS,
+ remedy=(
+ "Include the modern versions in protocol_versions "
+ "(mcp_types.version.MODERN_PROTOCOL_VERSIONS), or stop returning "
+ "InputRequiredResult from these tools."
+ ),
+ detect=_detect_multi_round_trip,
+ ),
+ ProtocolCapability(
+ key="client_back_channel",
+ label=(
+ "the server-initiated client back-channel "
+ "(ctx.elicit / ctx.sample / ctx.list_roots)"
+ ),
+ versions=HANDSHAKE_PROTOCOL_VERSIONS,
+ remedy=(
+ "Include the handshake versions in protocol_versions "
+ "(mcp_types.version.HANDSHAKE_PROTOCOL_VERSIONS), or use "
+ "sampling_handler_behavior='always' so the local handler is the "
+ "intended path."
+ ),
+ detect=_detect_client_back_channel,
+ ),
+ # Extension point: a capability that only some protocol versions can carry
+ # is one entry here, not a new special case in the check below. The 2026
+ # tasks extension is the next expected entry — when FastMCP implements it,
+ # add a ProtocolCapability naming the versions that carry it and a `detect`
+ # that reports the registered task-enabled components.
+)
+
+
+async def check_protocol_coherence(fastmcp: FastMCP) -> None:
+ """Warn at startup when a declared version set contradicts what is registered.
+
+ Warning-only by design, and **silent unless the server declared a version
+ set**. A server with an ordinary mix of tools is fine: a guard tool raises a
+ clear era error if an old client reaches for it, and warning about that at
+ startup would train people to ignore warnings. The check fires only when the
+ author asserted something and the registration contradicts the assertion.
+ """
+ allowed = fastmcp.protocol_versions
+ if allowed is None:
+ return
+
+ unmet = [c for c in PROTOCOL_CAPABILITIES if set(allowed).isdisjoint(c.versions)]
+ if not unmet:
+ return
+
+ # Inspect only this server's directly-registered tools. Aggregating mounted
+ # children would route through their middleware chains (a startup side
+ # effect); mounted or transformed components are a conservative miss, not a
+ # false positive. `LocalProvider.list_tools` is side-effect-free.
+ try:
+ tools: list[Tool] = list(await fastmcp._local_provider.list_tools())
+ except (LookupError, RuntimeError, ValueError) as exc:
+ logger.debug("Protocol coherence check could not list tools: %s", exc)
+ tools = []
+
+ for capability in unmet:
+ evidence = capability.detect(fastmcp, tools)
+ if not evidence:
+ continue
+ logger.warning(
+ "Server %r declares protocol_versions=%s, which cannot carry %s, "
+ "but the server uses it: %s. %s",
+ fastmcp.name,
+ list(allowed),
+ capability.label,
+ ", ".join(evidence),
+ capability.remedy,
+ )
diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py
index 80f233e1f..cc81160cc 100644
--- a/fastmcp_slim/fastmcp/server/server.py
+++ b/fastmcp_slim/fastmcp/server/server.py
@@ -8,6 +8,7 @@ import secrets
from collections.abc import (
AsyncIterator,
Callable,
+ Iterable,
Sequence,
)
from contextlib import (
@@ -77,7 +78,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.protocol_versions import validate_protocol_versions
from fastmcp.server.providers import LocalProvider, Provider
from fastmcp.server.providers.aggregate import AggregateProvider
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
@@ -357,7 +358,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,
+ protocol_versions: Iterable[str] | None = None,
client_log_level: mcp_types.LoggingLevel | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
@@ -532,16 +533,14 @@ 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
+ # The set of MCP protocol versions this server is willing to serve.
+ # Enforced by set membership at both connection paths (the initialize
+ # handshake and the modern per-request envelope) and checked for
+ # coherence against registered features at startup. `None` (the default)
+ # declares no restriction: the server serves every protocol version the
+ # SDK supports, fully backward compatible.
+ self._protocol_versions: tuple[str, ...] | None = validate_protocol_versions(
+ protocol_versions
)
def __repr__(self) -> str:
@@ -564,13 +563,13 @@ class FastMCP(
return self._mcp_server.version
@property
- def min_protocol_version(self) -> str | None:
- """The minimum MCP protocol version this server requires, if declared.
+ def protocol_versions(self) -> tuple[str, ...] | None:
+ """The MCP protocol versions this server serves, if restricted.
- `None` means no floor (serve every protocol era). The spelling is
- provisional; see `fastmcp.server.protocol_floor`.
+ `None` (the default) means no restriction: every protocol version the
+ SDK supports is served. Otherwise the declared versions, in SDK order.
"""
- return self._min_protocol_version
+ return self._protocol_versions
@property
def website_url(self) -> str | None:
diff --git a/tests/server/test_protocol_floor.py b/tests/server/test_protocol_floor.py
deleted file mode 100644
index f24493557..000000000
--- a/tests/server/test_protocol_floor.py
+++ /dev/null
@@ -1,289 +0,0 @@
-"""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
diff --git a/tests/server/test_protocol_versions.py b/tests/server/test_protocol_versions.py
new file mode 100644
index 000000000..3ff23af2e
--- /dev/null
+++ b/tests/server/test_protocol_versions.py
@@ -0,0 +1,451 @@
+"""Server protocol-version restriction: declaration, enforcement, and startup
+coherence checks.
+
+A server declares the *set* of protocol versions it serves. Membership — not
+ordering — decides whether a connection is accepted, so a handshake-only server
+refuses modern connections just as a modern-only server refuses handshake
+connections. A startup check warns (never raises) when a declared set cannot
+carry a capability the server actually uses, and stays silent when nothing was
+declared.
+"""
+
+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 mcp_types.version import (
+ HANDSHAKE_PROTOCOL_VERSIONS,
+ MODERN_PROTOCOL_VERSIONS,
+)
+
+from fastmcp import Client, Context, FastMCP
+from fastmcp.server.protocol_versions import (
+ enforce_handshake_protocol_version,
+ handshake_negotiated_version,
+ tool_uses_multi_round_trip,
+ validate_protocol_versions,
+)
+from fastmcp.tools.base import Tool
+
+_COHERENCE_LOGGER = "fastmcp.server.protocol_versions"
+
+
+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 connection 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(
+ "declared, expected",
+ [
+ (None, None),
+ (MODERN_PROTOCOL_VERSIONS, ("2026-07-28",)),
+ (HANDSHAKE_PROTOCOL_VERSIONS, HANDSHAKE_PROTOCOL_VERSIONS),
+ (["2026-07-28"], ("2026-07-28",)),
+ (["2025-06-18", "2026-07-28"], ("2025-06-18", "2026-07-28")),
+ # Normalized to SDK order, deduplicated.
+ (["2026-07-28", "2024-11-05", "2026-07-28"], ("2024-11-05", "2026-07-28")),
+ # Any iterable, not just a sequence.
+ ({"2025-11-25"}, ("2025-11-25",)),
+ ],
+)
+def test_valid_protocol_versions_normalized(declared, expected):
+ assert validate_protocol_versions(declared) == expected
+ assert FastMCP("s", protocol_versions=declared).protocol_versions == expected
+
+
+@pytest.mark.parametrize(
+ "declared",
+ [["9999-01-01"], ["modern"], ["2026"], [""], ["2026-07-28", "handshake"]],
+)
+def test_unknown_protocol_version_rejected(declared):
+ with pytest.raises(ValueError, match="unknown MCP protocol version"):
+ validate_protocol_versions(declared)
+ with pytest.raises(ValueError, match="unknown MCP protocol version"):
+ FastMCP("s", protocol_versions=declared)
+
+
+def test_empty_protocol_versions_rejected():
+ with pytest.raises(ValueError, match="at least one protocol version"):
+ validate_protocol_versions([])
+ with pytest.raises(ValueError, match="at least one protocol version"):
+ FastMCP("s", protocol_versions=[])
+
+
+def test_default_serves_every_version():
+ assert FastMCP("s").protocol_versions 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_uses_multi_round_trip(Tool.from_function(guard)) is True
+
+
+def test_plain_tool_not_flagged():
+ def plain(x: int) -> int:
+ return x
+
+ assert tool_uses_multi_round_trip(Tool.from_function(plain)) is False
+
+
+# ---------------------------------------------------------------------------
+# Enforcement: modern-only server refuses handshake clients
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def modern_only_server() -> FastMCP:
+ mcp = FastMCP("modern-only", protocol_versions=MODERN_PROTOCOL_VERSIONS)
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ return mcp
+
+
+async def test_modern_only_refuses_handshake(modern_only_server):
+ with pytest.raises(BaseException) as excinfo:
+ async with SDKClient(_server(modern_only_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.UNSUPPORTED_PROTOCOL_VERSION
+ 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_only_allows_modern(modern_only_server, mode):
+ async with SDKClient(_server(modern_only_server), mode=mode) as client:
+ result = await client.list_tools()
+ assert [t.name for t in result.tools] == ["add"]
+
+
+# ---------------------------------------------------------------------------
+# Enforcement: handshake-only server refuses modern clients
+#
+# This case is only expressible because the declaration is a set, not a bound:
+# under a minimum-version model there was no way to say "the session era".
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def handshake_only_server() -> FastMCP:
+ mcp = FastMCP("handshake-only", protocol_versions=HANDSHAKE_PROTOCOL_VERSIONS)
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ return mcp
+
+
+async def test_handshake_only_allows_handshake(handshake_only_server):
+ async with SDKClient(_server(handshake_only_server), 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_only_refuses_pinned_modern_client(handshake_only_server):
+ """A client pinned to a modern version never probes discover, so the refusal
+ has to land on the request itself."""
+ with pytest.raises(BaseException) as excinfo:
+ async with SDKClient(
+ _server(handshake_only_server), mode="2026-07-28"
+ ) as client:
+ await client.list_tools()
+ err = _find_mcp_error(excinfo.value)
+ assert err is not None
+ assert err.code == mcp_types.UNSUPPORTED_PROTOCOL_VERSION
+ assert "initialize" in err.message
+
+
+# ---------------------------------------------------------------------------
+# Enforcement: pinned single version, and the unrestricted default
+# ---------------------------------------------------------------------------
+
+
+async def test_pinned_version_allows_exact_match():
+ mcp = FastMCP("pinned", protocol_versions=["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"]
+
+
+def _initialize_request(version: str) -> mcp_types.InitializeRequest:
+ return mcp_types.InitializeRequest.model_validate(
+ {
+ "method": "initialize",
+ "params": {
+ "protocolVersion": version,
+ "capabilities": {},
+ "clientInfo": {"name": "test", "version": "1"},
+ },
+ }
+ )
+
+
+@pytest.mark.parametrize("offered", ["2024-11-05", "2025-03-26", "2025-06-18"])
+def test_pinned_version_refuses_older_handshake(offered):
+ """The SDK client cannot pin a handshake-era version through `mode`, so the
+ older-handshake refusal is exercised at the enforcement hook."""
+ mcp = FastMCP("pinned", protocol_versions=["2025-11-25"])
+
+ with pytest.raises(MCPError) as excinfo:
+ enforce_handshake_protocol_version(mcp, _initialize_request(offered))
+ assert "2025-11-25" in excinfo.value.message
+ assert excinfo.value.code == mcp_types.UNSUPPORTED_PROTOCOL_VERSION
+
+
+def test_pinned_version_accepts_matching_handshake():
+ mcp = FastMCP("pinned", protocol_versions=["2025-11-25"])
+ enforce_handshake_protocol_version(mcp, _initialize_request("2025-11-25"))
+
+
+def test_unrestricted_server_never_refuses_handshake():
+ mcp = FastMCP("open")
+ enforce_handshake_protocol_version(mcp, _initialize_request("2024-11-05"))
+
+
+async def test_fastmcp_client_default_reaches_handshake_only_server():
+ """`fastmcp.Client` defaults to the handshake in memory, which a
+ handshake-only server serves directly."""
+ mcp = FastMCP("handshake-only", protocol_versions=HANDSHAKE_PROTOCOL_VERSIONS)
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ async with Client(mcp) as client:
+ assert [t.name for t in await client.list_tools()] == ["add"]
+
+
+async def test_fastmcp_client_needs_modern_mode_for_modern_only_server():
+ """The mirror case: a modern-only server refuses the default in-memory
+ handshake, and the refusal names the modern protocol."""
+ mcp = FastMCP("modern-only", protocol_versions=MODERN_PROTOCOL_VERSIONS)
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ with pytest.raises(MCPError, match="server/discover"):
+ async with Client(mcp) as client:
+ await client.list_tools()
+
+ async with Client(mcp, mode="auto") as client:
+ assert [t.name for t in await client.list_tools()] == ["add"]
+
+
+@pytest.mark.parametrize("mode", ["legacy", "auto", "2026-07-28"])
+async def test_default_allows_every_era(mode):
+ mcp = FastMCP("open")
+
+ @mcp.tool
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ async with SDKClient(_server(mcp), mode=mode) as client:
+ result = await client.list_tools()
+ assert [t.name for t in result.tools] == ["add"]
+
+
+# ---------------------------------------------------------------------------
+# Startup coherence check (warnings only, silent unless declared)
+# ---------------------------------------------------------------------------
+
+
+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 _warnings_from_startup(mcp: FastMCP, caplog) -> list[str]:
+ with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
+ async with mcp._lifespan_manager():
+ pass
+ return _coherence_warnings(caplog)
+
+
+async def test_guard_tool_without_declaration_is_silent(caplog):
+ """Declaring nothing asserts nothing, so there is no contradiction to warn
+ about — the guard tool raises its own clear era error if reached."""
+ mcp = FastMCP("guardy")
+
+ @mcp.tool
+ def ask(x: int) -> str | mcp_types.InputRequiredResult:
+ return "ok"
+
+ assert await _warnings_from_startup(mcp, caplog) == []
+
+
+async def test_guard_tool_under_handshake_only_warns(caplog):
+ mcp = FastMCP("guardy", protocol_versions=HANDSHAKE_PROTOCOL_VERSIONS)
+
+ @mcp.tool
+ def ask(x: int) -> str | mcp_types.InputRequiredResult:
+ return "ok"
+
+ warnings = await _warnings_from_startup(mcp, caplog)
+ assert any("ask" in w and "InputRequiredResult" in w for w in warnings)
+
+
+async def test_guard_tool_under_modern_declaration_silent(caplog):
+ mcp = FastMCP("guardy", protocol_versions=MODERN_PROTOCOL_VERSIONS)
+
+ @mcp.tool
+ def ask(x: int) -> str | mcp_types.InputRequiredResult:
+ return "ok"
+
+ assert await _warnings_from_startup(mcp, caplog) == []
+
+
+async def test_guard_tool_under_mixed_declaration_silent(caplog):
+ """A declaration that still includes a modern version can carry guard tools."""
+ mcp = FastMCP("guardy", protocol_versions=["2025-11-25", "2026-07-28"])
+
+ @mcp.tool
+ def ask(x: int) -> str | mcp_types.InputRequiredResult:
+ return "ok"
+
+ assert await _warnings_from_startup(mcp, caplog) == []
+
+
+async def _sampling_handler(messages, params, context):
+ return "x"
+
+
+async def test_fallback_sampling_under_modern_declaration_warns(caplog):
+ mcp = FastMCP(
+ "samp",
+ protocol_versions=MODERN_PROTOCOL_VERSIONS,
+ sampling_handler=_sampling_handler,
+ sampling_handler_behavior="fallback",
+ )
+
+ warnings = await _warnings_from_startup(mcp, caplog)
+ assert any("back-channel" in w and "fallback" in w for w in warnings)
+
+
+async def test_fallback_sampling_without_declaration_is_silent(caplog):
+ mcp = FastMCP(
+ "samp",
+ sampling_handler=_sampling_handler,
+ sampling_handler_behavior="fallback",
+ )
+
+ assert await _warnings_from_startup(mcp, caplog) == []
+
+
+async def test_always_sampling_under_modern_declaration_silent(caplog):
+ mcp = FastMCP(
+ "samp",
+ protocol_versions=MODERN_PROTOCOL_VERSIONS,
+ sampling_handler=_sampling_handler,
+ sampling_handler_behavior="always",
+ )
+
+ assert await _warnings_from_startup(mcp, caplog) == []
+
+
+async def test_fallback_sampling_under_handshake_only_silent(caplog):
+ mcp = FastMCP(
+ "samp",
+ protocol_versions=HANDSHAKE_PROTOCOL_VERSIONS,
+ sampling_handler=_sampling_handler,
+ sampling_handler_behavior="fallback",
+ )
+
+ assert await _warnings_from_startup(mcp, caplog) == []
+
+
+async def test_plain_server_is_coherent(caplog):
+ mcp = FastMCP("clean", protocol_versions=MODERN_PROTOCOL_VERSIONS)
+
+ @mcp.tool
+ def plain(a: int) -> int:
+ return a
+
+ assert await _warnings_from_startup(mcp, caplog) == []
+
+
+async def test_guard_tool_reaches_modern_client():
+ """A guard tool served under a modern declaration works end-to-end."""
+ mcp = FastMCP("guarded", protocol_versions=MODERN_PROTOCOL_VERSIONS)
+
+ @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
From 80f753c1cb4d22a3cea1f9686dd2d53567944696 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 20 Jul 2026 18:06:32 -0400
Subject: [PATCH 3/3] Enforce protocol-version pins by era, not exact handshake
revision
The initialize handshake is negotiated by the SDK with no knowledge of the
server's protocol_versions allowlist, and FastMCP can only veto the handshake,
not steer the negotiated revision. So a server pinned to an older handshake
revision (e.g. ["2024-11-05"]) wrongly refused an ordinary client that
requested a newer handshake revision. Enforce era membership for handshake
versions (exact membership stays for modern per-request versions), refusing
only a genuine cross-era mismatch.
---
docs/development/v4-notes/change-register.mdx | 2 +
docs/development/v4-notes/protocol-2026.mdx | 33 ++++++++--
.../fastmcp/server/protocol_versions.py | 63 ++++++++++++++++--
tests/server/test_protocol_versions.py | 65 ++++++++++++++++---
4 files changed, 142 insertions(+), 21 deletions(-)
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index e17e1fc5a..3fe313f26 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -386,6 +386,8 @@ A server can declare the set of MCP protocol versions it serves, so a client tha
Enforcement is **set membership, not a minimum**, matching the SDK's stated model that versions are an enumerated set rather than an ordered scalar (`mcp_types/version.py`). That is what makes handshake-only expressible: the modern era is numerically newer but *removed* the server-initiated back-channel (`ctx.elicit`/`ctx.sample`/`ctx.list_roots`), so a server built on the back-channel needs the handshake era specifically — a minimum-version model could not say that. Both connection paths are covered: the initialize handshake is refused before it commits, and modern connections are checked on the request itself, since a client pinned to a modern version never probes `server/discover`. Refusals use the spec-standard `-32022` unsupported-protocol-version error with the server's `supported` list, so a `mode="auto"` client refused at discovery by a handshake-only server reads the handshake versions out of the error and completes over `initialize` on its own.
+Membership is **era-aware**, because FastMCP can only *veto* a connection — never steer the version the peer settles on — and the two eras negotiate their version differently. A modern connection pins an exact version in every per-request envelope, so a modern-version declaration enforces exact membership. The handshake era is negotiated by the SDK's initialize handler (`ServerRunner._negotiate_initialize`), which honors the client's requested revision (or counters with the newest handshake revision) with no knowledge of the declaration; FastMCP cannot make it counter-offer a specific revision. So a handshake-version declaration enforces the handshake *era*, not an exact revision: a server that declares any handshake version accepts the handshake and runs at whatever revision the SDK negotiated, and only a server that declares no handshake version refuses it. Pinning a single handshake revision (`["2025-06-18"]`) narrows nothing — the earlier build refused an ordinary client that offered a different handshake revision than the pin, which broke normal handshake negotiation for any server pinned to an older handshake revision; enforcement now refuses the handshake only for a genuine cross-era mismatch (a modern-only server).
+
The startup coherence check is now a **capability map** rather than ad-hoc cases: each entry names a capability, the protocol versions that carry it, and a detector. Present entries are multi-round-trip guard tools (modern versions, detected via `_contains_input_required` over tool return annotations) and the client back-channel (handshake versions, detected only from the configuration-level `sampling_handler` + `"fallback"` contradiction — the runtime calls have no static signal). A future capability, notably the 2026 tasks extension, is one entry rather than a new special case. Warnings only, never a hard error, and **silent unless a version set was declared** — a server with an ordinary guard tool and no declaration is fine, and warning there would train people to ignore warnings.
The default is `None`: every protocol version the SDK supports is served. Flipping a server default would disconnect existing clients rather than degrade, so restriction stays strictly opt-in. No `settings.py` entry — this is per-server configuration, not global.
diff --git a/docs/development/v4-notes/protocol-2026.mdx b/docs/development/v4-notes/protocol-2026.mdx
index a38c48810..5cc2c3fd1 100644
--- a/docs/development/v4-notes/protocol-2026.mdx
+++ b/docs/development/v4-notes/protocol-2026.mdx
@@ -107,12 +107,20 @@ async def interview(ctx: Context) -> str:
```
You can also pin exact versions, which is what a server certified against a
-single revision wants:
+single modern revision wants:
```python
mcp = FastMCP("pinned", protocol_versions=["2026-07-28"])
```
+Exact-revision pinning is enforceable for **modern** versions, which a client
+pins in every per-request envelope. It is not enforceable *within* the handshake
+era: the SDK negotiates the handshake revision and FastMCP can only veto a
+connection, not steer it, so pinning `["2025-06-18"]` still admits a client that
+negotiates `2025-11-25` — the pin asserts the handshake era, and the connection
+settles on whatever the SDK negotiated. See [Membership, not a
+minimum](#membership-not-a-minimum) below.
+
Any string the SDK does not recognize raises `ValueError` at construction. A
version the SDK could never negotiate is a bug in the server, not a runtime
condition worth warning about.
@@ -121,11 +129,24 @@ condition worth warning about.
The declaration is a **set**, not a floor. Versions are an enumerated set rather
than an ordered scale — the SDK says so directly, and future revision
-identifiers are not guaranteed to be date-shaped or sortable. A connection is
-accepted when its negotiated version is a member of the declared set, which is
-what makes "handshake only" expressible at all: under a minimum-version model
-there is no way to say "the session era", because the modern era is numerically
-newer while lacking the features that era depends on.
+identifiers are not guaranteed to be date-shaped or sortable. This is what makes
+"handshake only" expressible at all: under a minimum-version model there is no
+way to say "the session era", because the modern era is numerically newer while
+lacking the features that era depends on.
+
+Membership is enforced **era-aware**, because FastMCP can only veto a connection
+— never steer the version the peer settles on — and the two eras negotiate
+differently. A modern version is pinned exactly in each per-request envelope, so
+a modern-version declaration enforces exact membership: a request at a modern
+version outside the set is refused. A handshake connection is negotiated by the
+SDK's initialize handler, which honors the client's requested revision (or
+counters with the newest handshake revision) with no knowledge of your
+declaration — FastMCP cannot make it counter-offer a specific revision. So a
+handshake-version declaration enforces the handshake *era*: a server that
+declares any handshake version accepts the handshake and runs at whatever
+revision the SDK negotiated, and only a server that declares no handshake version
+(a modern-only server) refuses it. What a handshake-version declaration
+enforces is therefore the era boundary, not a specific handshake revision.
### What clients see
diff --git a/fastmcp_slim/fastmcp/server/protocol_versions.py b/fastmcp_slim/fastmcp/server/protocol_versions.py
index 307b81442..f7399fb0c 100644
--- a/fastmcp_slim/fastmcp/server/protocol_versions.py
+++ b/fastmcp_slim/fastmcp/server/protocol_versions.py
@@ -34,6 +34,21 @@ reads it as guidance rather than as a dead end: refused at ``server/discover``
by a handshake-only server, it sees handshake versions in ``supported`` and
falls back to the initialize handshake on its own.
+FastMCP can only *veto* a connection, never steer the version the peer settles
+on, and the two eras negotiate their version differently — so enforcement is
+era-aware. A modern connection pins an exact version in every per-request
+envelope, so a modern-version declaration enforces exact membership. The
+handshake era is negotiated by the SDK's initialize handler
+(``ServerRunner._negotiate_initialize``), which honors the client's requested
+revision (or counters with the newest handshake revision) with no knowledge of
+this declaration; FastMCP cannot make it counter-offer a specific revision. A
+handshake-version declaration therefore enforces *era* membership, not an exact
+revision: a server that declares any handshake version serves the handshake era
+and accepts the handshake, running at whatever revision the SDK negotiates, and
+only a server that declares no handshake version refuses it. Pinning a single
+handshake revision (``["2025-06-18"]``) narrows nothing the SDK will honor — the
+connection still settles on whatever revision the client and SDK negotiate.
+
Declaring nothing (the default) serves every era the SDK supports.
"""
@@ -123,13 +138,41 @@ def _remedy_for(versions: Sequence[str]) -> str:
return "Use one of the protocol versions this server serves."
+def _serves_version(allowed: Sequence[str], version: str) -> bool:
+ """Whether a server declaring ``allowed`` serves a connection at ``version``.
+
+ Enforcement is era-aware because the two eras negotiate their version
+ differently and FastMCP can only *veto* a connection — never steer the
+ version the peer settles on:
+
+ * A modern version rides a per-request envelope that pins an exact version,
+ so membership is exact: the server serves it only when ``version`` is in
+ ``allowed``.
+ * A handshake version is negotiated by the SDK's initialize handler, which
+ honors the client's requested revision (or counters with the newest
+ handshake revision) with no knowledge of ``allowed``. FastMCP cannot make
+ the SDK counter-offer a specific revision, so a handshake-version pin
+ asserts *era* membership only: the server serves the handshake connection
+ when it declared any handshake version, whatever revision the SDK settled
+ on.
+ """
+ if version in HANDSHAKE_PROTOCOL_VERSIONS:
+ return not set(allowed).isdisjoint(HANDSHAKE_PROTOCOL_VERSIONS)
+ return version in allowed
+
+
def protocol_version_error(fastmcp: FastMCP, version: str) -> MCPError | None:
"""The refusal for ``version``, or ``None`` when the server serves it.
A server that declared nothing (the default) serves every version and never
- refuses. Otherwise this is plain set membership: the declared versions are a
- set, not a bound, so a handshake-only server refuses modern connections just
- as a modern-only server refuses handshake connections.
+ refuses. Otherwise the decision is era-aware set membership (see
+ ``_serves_version``): a modern version must be an exact member, while a
+ handshake version is served whenever the declaration includes any handshake
+ version, because the SDK negotiates the handshake revision and FastMCP can
+ only veto — not steer — the version the connection settles on. A
+ handshake-only server still refuses modern connections just as a modern-only
+ server refuses handshake connections; only within-handshake revision pinning
+ is unenforceable.
The refusal is the spec-standard ``-32022`` unsupported-protocol-version
error carrying the server's supported list, which is what a negotiating
@@ -138,7 +181,7 @@ def protocol_version_error(fastmcp: FastMCP, version: str) -> MCPError | None:
to the initialize handshake instead of failing the connect.
"""
allowed = fastmcp.protocol_versions
- if allowed is None or version in allowed:
+ if allowed is None or _serves_version(allowed, version):
return None
return MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
@@ -160,7 +203,9 @@ def handshake_negotiated_version(requested: str | None) -> str:
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 membership check must compare against.
+ the honest value to report as ``requested`` when a modern-only server refuses
+ the handshake — the enforcement decision itself is era-aware (see
+ ``_serves_version``) and does not turn on this exact revision.
"""
if requested is not None and requested in HANDSHAKE_PROTOCOL_VERSIONS:
return requested
@@ -176,6 +221,14 @@ def enforce_handshake_protocol_version(
Called from the framework-owned initialize path before the handshake
commits, so the client sees a clear connect-time refusal naming what the
server serves instead of a confusing era error mid tool-call.
+
+ Enforcement is era-level (see ``_serves_version``): a server that declares
+ any handshake version serves the handshake era and accepts the handshake,
+ even when the client offers a different handshake revision than the one
+ pinned — the SDK negotiates the revision and FastMCP cannot steer it, only
+ veto. The refusal fires only for a genuine cross-era mismatch: a modern-only
+ server has no handshake version to share, so it refuses the handshake and
+ names the modern versions it does serve.
"""
if fastmcp.protocol_versions is None or init_message is None:
return
diff --git a/tests/server/test_protocol_versions.py b/tests/server/test_protocol_versions.py
index 3ff23af2e..6b54ab446 100644
--- a/tests/server/test_protocol_versions.py
+++ b/tests/server/test_protocol_versions.py
@@ -4,9 +4,11 @@ coherence checks.
A server declares the *set* of protocol versions it serves. Membership — not
ordering — decides whether a connection is accepted, so a handshake-only server
refuses modern connections just as a modern-only server refuses handshake
-connections. A startup check warns (never raises) when a declared set cannot
-carry a capability the server actually uses, and stays silent when nothing was
-declared.
+connections. Enforcement is era-aware: FastMCP can only veto a connection, and
+the SDK negotiates the handshake revision with no knowledge of the declaration,
+so a handshake-version pin asserts the handshake *era*, not an exact revision. A
+startup check warns (never raises) when a declared set cannot carry a capability
+the server actually uses, and stays silent when nothing was declared.
"""
from __future__ import annotations
@@ -252,15 +254,21 @@ def _initialize_request(version: str) -> mcp_types.InitializeRequest:
@pytest.mark.parametrize("offered", ["2024-11-05", "2025-03-26", "2025-06-18"])
-def test_pinned_version_refuses_older_handshake(offered):
- """The SDK client cannot pin a handshake-era version through `mode`, so the
- older-handshake refusal is exercised at the enforcement hook."""
+def test_pinned_version_accepts_other_handshake_revision(offered):
+ """A handshake-version pin asserts the handshake *era*, not an exact revision.
+
+ FastMCP can only veto the handshake, and the SDK negotiates the revision with
+ no knowledge of the pin — so a server pinned to one handshake revision still
+ accepts a client offering another handshake revision. The connection just
+ settles on whatever the SDK negotiated, not on the pinned revision. (This
+ replaces a test that asserted the opposite, which encoded the pre-fix bug:
+ refusing an ordinary handshake client whenever it offered a handshake
+ revision other than the pinned one.)
+ """
mcp = FastMCP("pinned", protocol_versions=["2025-11-25"])
- with pytest.raises(MCPError) as excinfo:
- enforce_handshake_protocol_version(mcp, _initialize_request(offered))
- assert "2025-11-25" in excinfo.value.message
- assert excinfo.value.code == mcp_types.UNSUPPORTED_PROTOCOL_VERSION
+ # No raise: the pinned revision and the offered revision are both handshake.
+ enforce_handshake_protocol_version(mcp, _initialize_request(offered))
def test_pinned_version_accepts_matching_handshake():
@@ -268,6 +276,43 @@ def test_pinned_version_accepts_matching_handshake():
enforce_handshake_protocol_version(mcp, _initialize_request("2025-11-25"))
+@pytest.mark.parametrize("offered", ["2025-11-25", "2025-06-18", "garbage", None])
+def test_older_handshake_pin_accepts_any_handshake_offer_at_hook(offered):
+ """The review-comment bug, at the enforcement hook.
+
+ A server pinned to an older handshake revision must not refuse a client that
+ offers a newer (or unknown, which the SDK counters to the newest) handshake
+ revision. The SDK negotiates within the handshake era regardless of the pin,
+ and FastMCP cannot counter-offer the pinned revision — only veto — so the
+ honest behavior is to accept, since the server does serve the handshake era.
+ """
+ mcp = FastMCP("older-pin", protocol_versions=["2024-11-05"])
+
+ # No raise: the server serves the handshake era, so the handshake is served.
+ enforce_handshake_protocol_version(mcp, _initialize_request(offered or "garbage"))
+
+
+async def test_older_handshake_pin_accepts_normal_client_end_to_end():
+ """End-to-end review-comment regression: a server pinned to `2024-11-05`
+ accepts an ordinary legacy client that requests `2025-11-25`.
+
+ The pin declares the handshake era; the SDK negotiates the revision. The
+ connection settles on `2025-11-25` (what the SDK negotiated), not the pinned
+ `2024-11-05`, which is exactly why a handshake-revision pin is era-level: the
+ server cannot force the client down to the pinned revision.
+ """
+ mcp = FastMCP("older-pin", protocol_versions=["2024-11-05"])
+
+ @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"]
+
+
def test_unrestricted_server_never_refuses_handshake():
mcp = FastMCP("open")
enforce_handshake_protocol_version(mcp, _initialize_request("2024-11-05"))