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. 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 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. 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 You can also pin exact versions, which is what a server certified against a
single revision wants: single modern revision wants:
```python ```python
mcp = FastMCP("pinned", protocol_versions=["2026-07-28"]) 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 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 version the SDK could never negotiate is a bug in the server, not a runtime
condition worth warning about. 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 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 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 identifiers are not guaranteed to be date-shaped or sortable. This is what makes
accepted when its negotiated version is a member of the declared set, which is "handshake only" expressible at all: under a minimum-version model there is no
what makes "handshake only" expressible at all: under a minimum-version model way to say "the session era", because the modern era is numerically newer while
there is no way to say "the session era", because the modern era is numerically lacking the features that era depends on.
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 ### What clients see

View file

@ -34,6 +34,21 @@ reads it as guidance rather than as a dead end: refused at ``server/discover``
by a handshake-only server, it sees handshake versions in ``supported`` and by a handshake-only server, it sees handshake versions in ``supported`` and
falls back to the initialize handshake on its own. falls back to the initialize handshake on its own.
FastMCP can only *veto* a connection, never steer the version the peer settles
on, and the two eras negotiate their version differently so enforcement is
era-aware. 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
this declaration; FastMCP cannot make it counter-offer a specific revision. A
handshake-version declaration therefore enforces *era* membership, not an exact
revision: a server that declares any handshake version serves the handshake era
and accepts the handshake, running at whatever revision the SDK negotiates, and
only a server that declares no handshake version refuses it. Pinning a single
handshake revision (``["2025-06-18"]``) narrows nothing the SDK will honor the
connection still settles on whatever revision the client and SDK negotiate.
Declaring nothing (the default) serves every era the SDK supports. Declaring nothing (the default) serves every era the SDK supports.
""" """
@ -123,13 +138,41 @@ def _remedy_for(versions: Sequence[str]) -> str:
return "Use one of the protocol versions this server serves." return "Use one of the protocol versions this server serves."
def _serves_version(allowed: Sequence[str], version: str) -> bool:
"""Whether a server declaring ``allowed`` serves a connection at ``version``.
Enforcement is era-aware because the two eras negotiate their version
differently and FastMCP can only *veto* a connection never steer the
version the peer settles on:
* A modern version rides a per-request envelope that pins an exact version,
so membership is exact: the server serves it only when ``version`` is in
``allowed``.
* A handshake version 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 ``allowed``. FastMCP cannot make
the SDK counter-offer a specific revision, so a handshake-version pin
asserts *era* membership only: the server serves the handshake connection
when it declared any handshake version, whatever revision the SDK settled
on.
"""
if version in HANDSHAKE_PROTOCOL_VERSIONS:
return not set(allowed).isdisjoint(HANDSHAKE_PROTOCOL_VERSIONS)
return version in allowed
def protocol_version_error(fastmcp: FastMCP, version: str) -> MCPError | None: def protocol_version_error(fastmcp: FastMCP, version: str) -> MCPError | None:
"""The refusal for ``version``, or ``None`` when the server serves it. """The refusal for ``version``, or ``None`` when the server serves it.
A server that declared nothing (the default) serves every version and never A server that declared nothing (the default) serves every version and never
refuses. Otherwise this is plain set membership: the declared versions are a refuses. Otherwise the decision is era-aware set membership (see
set, not a bound, so a handshake-only server refuses modern connections just ``_serves_version``): a modern version must be an exact member, while a
as a modern-only server refuses handshake connections. handshake version is served whenever the declaration includes any handshake
version, because the SDK negotiates the handshake revision and FastMCP can
only veto not steer the version the connection settles on. A
handshake-only server still refuses modern connections just as a modern-only
server refuses handshake connections; only within-handshake revision pinning
is unenforceable.
The refusal is the spec-standard ``-32022`` unsupported-protocol-version The refusal is the spec-standard ``-32022`` unsupported-protocol-version
error carrying the server's supported list, which is what a negotiating error carrying the server's supported list, which is what a negotiating
@ -138,7 +181,7 @@ def protocol_version_error(fastmcp: FastMCP, version: str) -> MCPError | None:
to the initialize handshake instead of failing the connect. to the initialize handshake instead of failing the connect.
""" """
allowed = fastmcp.protocol_versions allowed = fastmcp.protocol_versions
if allowed is None or version in allowed: if allowed is None or _serves_version(allowed, version):
return None return None
return MCPError( return MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION, code=UNSUPPORTED_PROTOCOL_VERSION,
@ -160,7 +203,9 @@ def handshake_negotiated_version(requested: str | None) -> str:
requested handshake revision is honored; anything else (an unknown string, requested handshake revision is honored; anything else (an unknown string,
or a modern-era version the handshake cannot serve) counters with the newest 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 handshake revision. The connection operates at the returned version, so it is
what the membership check must compare against. the honest value to report as ``requested`` when a modern-only server refuses
the handshake the enforcement decision itself is era-aware (see
``_serves_version``) and does not turn on this exact revision.
""" """
if requested is not None and requested in HANDSHAKE_PROTOCOL_VERSIONS: if requested is not None and requested in HANDSHAKE_PROTOCOL_VERSIONS:
return requested return requested
@ -176,6 +221,14 @@ def enforce_handshake_protocol_version(
Called from the framework-owned initialize path before the handshake Called from the framework-owned initialize path before the handshake
commits, so the client sees a clear connect-time refusal naming what the commits, so the client sees a clear connect-time refusal naming what the
server serves instead of a confusing era error mid tool-call. server serves instead of a confusing era error mid tool-call.
Enforcement is era-level (see ``_serves_version``): a server that declares
any handshake version serves the handshake era and accepts the handshake,
even when the client offers a different handshake revision than the one
pinned the SDK negotiates the revision and FastMCP cannot steer it, only
veto. The refusal fires only for a genuine cross-era mismatch: a modern-only
server has no handshake version to share, so it refuses the handshake and
names the modern versions it does serve.
""" """
if fastmcp.protocol_versions is None or init_message is None: if fastmcp.protocol_versions is None or init_message is None:
return return

View file

@ -4,9 +4,11 @@ coherence checks.
A server declares the *set* of protocol versions it serves. Membership not A server declares the *set* of protocol versions it serves. Membership not
ordering decides whether a connection is accepted, so a handshake-only server ordering decides whether a connection is accepted, so a handshake-only server
refuses modern connections just as a modern-only server refuses handshake refuses modern connections just as a modern-only server refuses handshake
connections. A startup check warns (never raises) when a declared set cannot connections. Enforcement is era-aware: FastMCP can only veto a connection, and
carry a capability the server actually uses, and stays silent when nothing was the SDK negotiates the handshake revision with no knowledge of the declaration,
declared. so a handshake-version pin asserts the handshake *era*, not an exact revision. A
startup check warns (never raises) when a declared set cannot carry a capability
the server actually uses, and stays silent when nothing was declared.
""" """
from __future__ import annotations from __future__ import annotations
@ -252,15 +254,21 @@ def _initialize_request(version: str) -> mcp_types.InitializeRequest:
@pytest.mark.parametrize("offered", ["2024-11-05", "2025-03-26", "2025-06-18"]) @pytest.mark.parametrize("offered", ["2024-11-05", "2025-03-26", "2025-06-18"])
def test_pinned_version_refuses_older_handshake(offered): def test_pinned_version_accepts_other_handshake_revision(offered):
"""The SDK client cannot pin a handshake-era version through `mode`, so the """A handshake-version pin asserts the handshake *era*, not an exact revision.
older-handshake refusal is exercised at the enforcement hook."""
FastMCP can only veto the handshake, and the SDK negotiates the revision with
no knowledge of the pin so a server pinned to one handshake revision still
accepts a client offering another handshake revision. The connection just
settles on whatever the SDK negotiated, not on the pinned revision. (This
replaces a test that asserted the opposite, which encoded the pre-fix bug:
refusing an ordinary handshake client whenever it offered a handshake
revision other than the pinned one.)
"""
mcp = FastMCP("pinned", protocol_versions=["2025-11-25"]) mcp = FastMCP("pinned", protocol_versions=["2025-11-25"])
with pytest.raises(MCPError) as excinfo: # No raise: the pinned revision and the offered revision are both handshake.
enforce_handshake_protocol_version(mcp, _initialize_request(offered)) enforce_handshake_protocol_version(mcp, _initialize_request(offered))
assert "2025-11-25" in excinfo.value.message
assert excinfo.value.code == mcp_types.UNSUPPORTED_PROTOCOL_VERSION
def test_pinned_version_accepts_matching_handshake(): def test_pinned_version_accepts_matching_handshake():
@ -268,6 +276,43 @@ def test_pinned_version_accepts_matching_handshake():
enforce_handshake_protocol_version(mcp, _initialize_request("2025-11-25")) enforce_handshake_protocol_version(mcp, _initialize_request("2025-11-25"))
@pytest.mark.parametrize("offered", ["2025-11-25", "2025-06-18", "garbage", None])
def test_older_handshake_pin_accepts_any_handshake_offer_at_hook(offered):
"""The review-comment bug, at the enforcement hook.
A server pinned to an older handshake revision must not refuse a client that
offers a newer (or unknown, which the SDK counters to the newest) handshake
revision. The SDK negotiates within the handshake era regardless of the pin,
and FastMCP cannot counter-offer the pinned revision only veto so the
honest behavior is to accept, since the server does serve the handshake era.
"""
mcp = FastMCP("older-pin", protocol_versions=["2024-11-05"])
# No raise: the server serves the handshake era, so the handshake is served.
enforce_handshake_protocol_version(mcp, _initialize_request(offered or "garbage"))
async def test_older_handshake_pin_accepts_normal_client_end_to_end():
"""End-to-end review-comment regression: a server pinned to `2024-11-05`
accepts an ordinary legacy client that requests `2025-11-25`.
The pin declares the handshake era; the SDK negotiates the revision. The
connection settles on `2025-11-25` (what the SDK negotiated), not the pinned
`2024-11-05`, which is exactly why a handshake-revision pin is era-level: the
server cannot force the client down to the pinned revision.
"""
mcp = FastMCP("older-pin", protocol_versions=["2024-11-05"])
@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"]
def test_unrestricted_server_never_refuses_handshake(): def test_unrestricted_server_never_refuses_handshake():
mcp = FastMCP("open") mcp = FastMCP("open")
enforce_handshake_protocol_version(mcp, _initialize_request("2024-11-05")) enforce_handshake_protocol_version(mcp, _initialize_request("2024-11-05"))