mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
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.
This commit is contained in:
parent
2f4448af36
commit
f5fca97c81
9 changed files with 931 additions and 581 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
<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>
|
||||
## Restricting the protocol versions a server serves
|
||||
|
||||
<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.
|
||||
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.
|
||||
|
||||
<Note>
|
||||
`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.
|
||||
</Note>
|
||||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue