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:
Jeremiah Lowin 2026-07-20 18:06:32 -04:00
commit 80f753c1cb
No known key found for this signature in database
4 changed files with 142 additions and 21 deletions

View file

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

View file

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