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.