mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 10:18:08 +02:00
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.
This commit is contained in:
parent
6107ad184c
commit
80f753c1cb
4 changed files with 142 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue