Add server protocol-version floor (provisional min_protocol_version)

Declare a minimum MCP protocol version; refuse handshake clients below it
at connect time and warn on floor/feature incoherence at startup.
This commit is contained in:
Jeremiah Lowin 2026-07-19 21:20:00 -04:00
commit aca5dc99b9
No known key found for this signature in database
7 changed files with 613 additions and 0 deletions

View file

@ -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.

View file

@ -2,6 +2,8 @@
title: 2026-07-28 Protocol Support
---
import { VersionBadge } from "/snippets/version-badge.mdx"
FastMCP v4 serves the sessionless `2026-07-28` protocol era and the session-based handshake eras from a single server, with per-connection auto-detection. This page catalogs what FastMCP provides for the modern era — both the protocol machinery it inherits from the MCP Python SDK and the capabilities FastMCP implements itself on top of that layer. It is the reference for what a v4 deployment can actually do on the modern protocol today.
## Identity assertion (SEP-990): a complete server-side implementation
@ -48,6 +50,72 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. |
## Server protocol floor (DRAFT — provisional API)
<Warning>
This section is a **draft** for an in-progress feature. The public spelling shown
here (`min_protocol_version=`) is **provisional** and expected to change before
release — the min-without-max shape is under review. The underlying mechanics
(connect-time enforcement and startup coherence checks) are stable regardless of
the final spelling.
</Warning>
<VersionBadge version="4.0.0" />
A server can depend on features that exist on only one protocol era. A tool that
returns an `InputRequiredResult` (the modern [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol))
works only on a `2026-07-28` connection. Today a legacy client that reaches such
a tool over the initialize handshake gets a confusing era error *mid tool-call*,
long after connecting — the failure surfaces far from its cause.
The protocol floor moves that failure to connect time. A server declares the
minimum protocol version it requires, and FastMCP refuses any handshake below it
with a clear error naming the required version:
```python
from fastmcp import FastMCP
from fastmcp.types import InputRequiredResult
mcp = FastMCP("guarded", min_protocol_version="2026-07-28")
@mcp.tool
def confirm(action: str) -> str | InputRequiredResult:
... # a modern-only guard tool
```
A legacy client connecting to this server is refused during `initialize` with a
message pointing it at the modern protocol, rather than discovering the
incompatibility inside a tool call. Modern (`server/discover`) clients connect
normally — the modern era satisfies any currently declarable floor, because it is
the newest era.
**Enforcement points.** The floor is applied at the two negotiation points
FastMCP owns, both through the framework's entry in the SDK's middleware layer:
- **Initialize handshake** — the connection's negotiated handshake version is
compared against the floor before the handshake commits; a version below the
floor is refused with `-32602` (invalid params).
- **`server/discover` (modern)** — the modern era is the newest era, so every
modern connection satisfies any floor a server can declare today; discovery is
therefore never refused by a floor in the current version set.
**Startup coherence check.** FastMCP knows what is registered, so at startup it
warns (never fails) when the declared floor and the registered features conflict:
- A guard tool (returns `InputRequiredResult`) under a non-modern floor will fail
for handshake-era clients mid-call — the warning names the tool and recommends
a modern floor.
- A modern floor combined with a `sampling_handler` set to `"fallback"` is a dead
preference: the modern era forbids the server-initiated back-channel, so the
local handler always runs. The warning suggests `"always"` or a lower floor.
Runtime-only back-channel usage (`ctx.elicit`, `ctx.sample`, `ctx.list_roots`)
has no reliable static signal and is deliberately not inferred.
The default is no floor: a server without `min_protocol_version` serves every
protocol era, fully backward compatible.
## Still in the program
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them.

View file

@ -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,

View file

@ -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

View file

@ -0,0 +1,210 @@
"""Server protocol-version floor: declaration, negotiation-time enforcement, and
startup incoherence detection.
A FastMCP server may depend on features that only exist on a particular MCP
protocol era. The clearest example is the modern (``2026-07-28``) multi-round
"guard" pattern (SEP-2322): a tool that returns an ``InputRequiredResult`` works
only on a modern connection. A legacy client that reaches such a tool over the
initialize handshake gets a confusing era error *mid tool-call* instead of a
clear refusal at connect time.
This module lets a server declare a minimum protocol version (a "floor") and
enforces it at the two connection-negotiation points FastMCP owns:
* the initialize handshake (legacy era), refused before the handshake commits;
* ``server/discover`` (modern era), which always satisfies any currently
declarable floor because the modern era is the newest era.
It also runs a conservative, warning-only coherence check at server startup: it
infers the modern requirement from registered guard tools and flags declared
floors that contradict a configured back-channel handler.
.. note::
The public spelling (``FastMCP(min_protocol_version=...)``) is **provisional**
and expected to change. The negotiation hook and the inference rules in this
module are valid under any eventual spelling; only the constructor keyword is
a placeholder.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from mcp.shared.exceptions import MCPError
from mcp_types import INVALID_PARAMS
from mcp_types.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
KNOWN_PROTOCOL_VERSIONS,
LATEST_HANDSHAKE_VERSION,
MODERN_PROTOCOL_VERSIONS,
is_version_at_least,
)
from fastmcp.tools.function_parsing import _contains_input_required
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
import mcp_types
from fastmcp.server.server import FastMCP
from fastmcp.tools.base import Tool
logger = get_logger(__name__)
# The oldest modern (per-request-envelope) protocol version. A floor at or above
# this value means "modern connections only" — no handshake-era client can
# satisfy it, since the handshake era tops out at LATEST_HANDSHAKE_VERSION.
_MODERN_FLOOR = MODERN_PROTOCOL_VERSIONS[0]
def validate_protocol_floor(value: str | None) -> str | None:
"""Validate a declared protocol-version floor at construction time.
Returns the value unchanged when it is ``None`` (no floor) or a known
protocol revision. Raises ``ValueError`` for any unrecognized string a
floor the SDK could never negotiate is a programming error, not a runtime
condition to warn about.
"""
if value is None:
return None
if value not in KNOWN_PROTOCOL_VERSIONS:
raise ValueError(
f"min_protocol_version={value!r} is not a known MCP protocol version. "
f"Known versions: {', '.join(KNOWN_PROTOCOL_VERSIONS)}."
)
return value
def handshake_negotiated_version(requested: str | None) -> str:
"""The version an initialize handshake would settle on for ``requested``.
Mirrors the SDK's ``ServerRunner._negotiate_initialize``: a client's
requested handshake revision is honored; anything else (an unknown string,
or a modern-era version the handshake cannot serve) counters with the newest
handshake revision. The connection operates at the returned version, so it is
what the floor check must compare against.
"""
if requested is not None and requested in HANDSHAKE_PROTOCOL_VERSIONS:
return requested
return LATEST_HANDSHAKE_VERSION
def enforce_handshake_floor(
fastmcp: FastMCP,
init_message: mcp_types.InitializeRequest | None,
) -> None:
"""Refuse an initialize handshake that cannot meet the server's floor.
Called from the framework-owned initialize path (the SDK's middleware layer)
before the handshake commits. When the connection's negotiated handshake
version is below the floor, raises ``MCPError`` so the client sees a clear
connect-time refusal naming the required version instead of a runtime era
error later. A ``None`` floor (the default) never refuses.
"""
floor = fastmcp.min_protocol_version
if floor is None or init_message is None:
return
requested = init_message.params.protocol_version
negotiated = handshake_negotiated_version(requested)
if is_version_at_least(negotiated, floor):
return
detail = (
"Connect using the modern protocol (server/discover) instead."
if is_version_at_least(floor, _MODERN_FLOOR)
else "Upgrade the client or connect with a newer protocol version."
)
raise MCPError(
code=INVALID_PARAMS,
message=(
f"Server {fastmcp.name!r} requires MCP protocol version {floor} or "
f"newer; the initialize handshake offered {requested!r} "
f"(negotiates to {negotiated}). {detail}"
),
data={"requiredProtocolVersion": floor, "offeredProtocolVersion": requested},
)
def tool_requires_modern(tool: Tool) -> bool:
"""True when a tool's return annotation makes it a modern-only guard tool.
A guard tool (SEP-2322) returns an ``InputRequiredResult`` to ask the client
for input across rounds; that pattern exists only on the modern era. Detection
reuses ``_contains_input_required`` over the tool's captured return annotation,
so every union/alias/``Annotated`` shape the parser recognizes is covered. Only
``FunctionTool`` carries a return annotation; other tool kinds return ``False``
(a conservative miss, not a false positive).
"""
if not isinstance(tool, FunctionTool):
return False
return _contains_input_required(tool.return_type)
async def check_protocol_coherence(fastmcp: FastMCP) -> None:
"""Warn at startup when the declared floor and registered features conflict.
Conservative by design: emits actionable warnings, never raises. Two rules:
1. **Modern-only guard tools under a non-modern floor.** If any registered
tool is a guard tool (returns ``InputRequiredResult``) but the floor does
not guarantee a modern connection, handshake-era clients that reach those
tools fail mid-call. Recommends declaring a modern floor.
2. **Modern floor with a back-channel sampling fallback.** A modern floor
forbids the server-initiated back-channel, so a ``sampling_handler`` set to
``"fallback"`` (prefer the client's model, fall back to the local handler)
can never actually reach the client the local handler always runs. Flags
the dead preference.
Runtime-only back-channel usage (``ctx.elicit`` / ``ctx.sample`` /
``ctx.list_roots``) has no reliable static signal, so it is deliberately not
inferred here.
"""
floor = fastmcp.min_protocol_version
floor_is_modern = floor is not None and is_version_at_least(floor, _MODERN_FLOOR)
# Inspect only this server's directly-registered tools. Aggregating mounted
# children would route through their middleware chains (a startup side
# effect); mounted or transformed guard tools are a conservative miss, not a
# false positive. `LocalProvider.list_tools` is side-effect-free.
try:
tools = list(await fastmcp._local_provider.list_tools())
except Exception as exc:
logger.debug("Protocol coherence check could not list tools: %s", exc)
tools = []
if not floor_is_modern:
guard_tools = sorted(t.name for t in tools if tool_requires_modern(t))
if guard_tools:
floor_desc = (
"no minimum protocol version is declared"
if floor is None
else f"the declared floor is {floor}"
)
logger.warning(
"Server %r registers guard tool(s) %s that return "
"InputRequiredResult and require the modern MCP protocol "
"(%s), but %s. Handshake-era clients calling these tools will "
"fail mid-call. Declare min_protocol_version=%r to refuse such "
"clients at connect time.",
fastmcp.name,
", ".join(guard_tools),
_MODERN_FLOOR,
floor_desc,
_MODERN_FLOOR,
)
if (
floor_is_modern
and fastmcp.sampling_handler is not None
and fastmcp.sampling_handler_behavior == "fallback"
):
logger.warning(
"Server %r declares a modern protocol floor (%s) but configures a "
"sampling_handler with behavior 'fallback'. The modern protocol "
"forbids the server-initiated back-channel, so the fallback never "
"reaches the client and the local handler always runs. Use "
"sampling_handler_behavior='always' if that is intended, or lower "
"the floor to allow the client back-channel.",
fastmcp.name,
floor,
)

View file

@ -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

View file

@ -0,0 +1,289 @@
"""Server protocol-version floor: declaration, negotiation-time enforcement, and
startup incoherence detection.
A server may declare a minimum protocol version. The initialize handshake refuses
clients below the floor before the handshake commits; the modern
(``server/discover``) era always satisfies any currently declarable floor. A
conservative startup check warns when the declared floor and the registered
features are incoherent.
The ``min_protocol_version`` spelling is provisional; these tests exercise the
mechanics, which hold under any eventual spelling.
"""
from __future__ import annotations
import logging
import mcp_types
import pytest
from exceptiongroup import BaseExceptionGroup
from mcp.client import Client as SDKClient
from mcp.server import Server as LowLevelServer
from mcp.shared.exceptions import MCPError
from fastmcp import Context, FastMCP
from fastmcp.server.protocol_floor import (
handshake_negotiated_version,
tool_requires_modern,
validate_protocol_floor,
)
from fastmcp.tools.base import Tool
_COHERENCE_LOGGER = "fastmcp.server.protocol_floor"
def _server(mcp: FastMCP) -> LowLevelServer:
"""The lowlevel Server the SDK client connects to in-process."""
return mcp._mcp_server
def _find_mcp_error(exc: BaseException) -> MCPError | None:
"""Unwrap the MCPError a refused in-memory handshake surfaces.
The legacy in-memory transport runs ``initialize`` inside a task group, so a
connect-time refusal propagates as an ``ExceptionGroup`` wrapping the
``MCPError`` rather than the bare error.
"""
if isinstance(exc, MCPError):
return exc
if isinstance(exc, BaseExceptionGroup):
for inner in exc.exceptions:
found = _find_mcp_error(inner)
if found is not None:
return found
if exc.__cause__ is not None:
return _find_mcp_error(exc.__cause__)
return None
# ---------------------------------------------------------------------------
# Construction-time validation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"version",
["2024-11-05", "2025-06-18", "2025-11-25", "2026-07-28", None],
)
def test_valid_floor_accepted(version):
assert validate_protocol_floor(version) == version
assert FastMCP("s", min_protocol_version=version).min_protocol_version == version
@pytest.mark.parametrize("version", ["9999-01-01", "latest", "2026", ""])
def test_unknown_floor_rejected(version):
with pytest.raises(ValueError, match="not a known MCP protocol version"):
validate_protocol_floor(version)
with pytest.raises(ValueError, match="not a known MCP protocol version"):
FastMCP("s", min_protocol_version=version)
def test_default_is_no_floor():
assert FastMCP("s").min_protocol_version is None
# ---------------------------------------------------------------------------
# Handshake negotiation mirror
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"requested, expected",
[
("2025-11-25", "2025-11-25"),
("2025-06-18", "2025-06-18"),
("2024-11-05", "2024-11-05"),
# A modern-era or unknown request counters with the newest handshake.
("2026-07-28", "2025-11-25"),
("garbage", "2025-11-25"),
(None, "2025-11-25"),
],
)
def test_handshake_negotiated_version(requested, expected):
assert handshake_negotiated_version(requested) == expected
# ---------------------------------------------------------------------------
# Guard-tool detection
# ---------------------------------------------------------------------------
def test_guard_tool_detected():
def guard(x: int) -> str | mcp_types.InputRequiredResult:
return "ok"
assert tool_requires_modern(Tool.from_function(guard)) is True
def test_plain_tool_not_flagged():
def plain(x: int) -> int:
return x
assert tool_requires_modern(Tool.from_function(plain)) is False
# ---------------------------------------------------------------------------
# Negotiation-time enforcement (handshake path)
# ---------------------------------------------------------------------------
@pytest.fixture
def floored_server() -> FastMCP:
mcp = FastMCP("floored", min_protocol_version="2026-07-28")
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
return mcp
async def test_modern_floor_refuses_legacy_handshake(floored_server):
with pytest.raises(BaseException) as excinfo:
async with SDKClient(_server(floored_server), mode="legacy") as client:
await client.list_tools()
err = _find_mcp_error(excinfo.value)
assert err is not None
assert err.code == mcp_types.INVALID_PARAMS
assert "2026-07-28" in err.message
assert "server/discover" in err.message
@pytest.mark.parametrize("mode", ["auto", "2026-07-28"])
async def test_modern_floor_allows_modern(floored_server, mode):
async with SDKClient(_server(floored_server), mode=mode) as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["add"]
async def test_no_floor_allows_legacy_handshake():
mcp = FastMCP("open")
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
async with SDKClient(_server(mcp), mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
result = await client.list_tools()
assert [t.name for t in result.tools] == ["add"]
async def test_handshake_floor_allows_equal_version_client():
mcp = FastMCP("hs", min_protocol_version="2025-11-25")
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
async with SDKClient(_server(mcp), mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
result = await client.list_tools()
assert [t.name for t in result.tools] == ["add"]
# ---------------------------------------------------------------------------
# Startup incoherence detection (warnings only)
# ---------------------------------------------------------------------------
def _coherence_warnings(caplog) -> list[str]:
return [
r.getMessage()
for r in caplog.records
if r.name == _COHERENCE_LOGGER and r.levelno == logging.WARNING
]
async def test_guard_tool_without_floor_warns(caplog):
mcp = FastMCP("guardy")
@mcp.tool
def ask(x: int) -> str | mcp_types.InputRequiredResult:
return "ok"
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
async with mcp._lifespan_manager():
pass
warnings = _coherence_warnings(caplog)
assert any("ask" in w and "modern" in w.lower() for w in warnings)
async def test_guard_tool_with_modern_floor_silent(caplog):
mcp = FastMCP("guardy", min_protocol_version="2026-07-28")
@mcp.tool
def ask(x: int) -> str | mcp_types.InputRequiredResult:
return "ok"
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
async with mcp._lifespan_manager():
pass
assert _coherence_warnings(caplog) == []
async def test_modern_floor_with_fallback_sampling_warns(caplog):
async def handler(messages, params, context):
return "x"
mcp = FastMCP(
"samp",
min_protocol_version="2026-07-28",
sampling_handler=handler,
sampling_handler_behavior="fallback",
)
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
async with mcp._lifespan_manager():
pass
warnings = _coherence_warnings(caplog)
assert any("fallback" in w and "back-channel" in w for w in warnings)
async def test_modern_floor_with_always_sampling_silent(caplog):
async def handler(messages, params, context):
return "x"
mcp = FastMCP(
"samp",
min_protocol_version="2026-07-28",
sampling_handler=handler,
sampling_handler_behavior="always",
)
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
async with mcp._lifespan_manager():
pass
assert _coherence_warnings(caplog) == []
async def test_plain_server_is_coherent(caplog):
mcp = FastMCP("clean")
@mcp.tool
def plain(a: int) -> int:
return a
with caplog.at_level(logging.WARNING, logger=_COHERENCE_LOGGER):
async with mcp._lifespan_manager():
pass
assert _coherence_warnings(caplog) == []
async def test_guard_tool_reaches_modern_client(floored_server):
"""A guard tool served under a modern floor works end-to-end on modern."""
mcp = FastMCP("guarded", min_protocol_version="2026-07-28")
@mcp.tool
async def confirm(ctx: Context) -> str | mcp_types.InputRequiredResult:
return "confirmed"
async with SDKClient(_server(mcp), mode="auto") as client:
result = await client.call_tool("confirm", {})
assert result.is_error is False