mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -123,13 +138,41 @@ def _remedy_for(versions: Sequence[str]) -> str:
|
|||
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:
|
||||
"""The refusal for ``version``, or ``None`` when the server serves it.
|
||||
|
||||
A server that declared nothing (the default) serves every version and never
|
||||
refuses. Otherwise this is plain set membership: the declared versions are a
|
||||
set, not a bound, so a handshake-only server refuses modern connections just
|
||||
as a modern-only server refuses handshake connections.
|
||||
refuses. Otherwise the decision is era-aware set membership (see
|
||||
``_serves_version``): a modern version must be an exact member, while a
|
||||
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
|
||||
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.
|
||||
"""
|
||||
allowed = fastmcp.protocol_versions
|
||||
if allowed is None or version in allowed:
|
||||
if allowed is None or _serves_version(allowed, version):
|
||||
return None
|
||||
return MCPError(
|
||||
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,
|
||||
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
|
||||
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:
|
||||
return requested
|
||||
|
|
@ -176,6 +221,14 @@ def enforce_handshake_protocol_version(
|
|||
Called from the framework-owned initialize path before the handshake
|
||||
commits, so the client sees a clear connect-time refusal naming what the
|
||||
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:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ coherence checks.
|
|||
A server declares the *set* of protocol versions it serves. Membership — not
|
||||
ordering — decides whether a connection is accepted, so a handshake-only server
|
||||
refuses modern connections just as a modern-only server refuses handshake
|
||||
connections. 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.
|
||||
connections. Enforcement is era-aware: FastMCP can only veto a connection, and
|
||||
the SDK negotiates the handshake revision with no knowledge of the declaration,
|
||||
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
|
||||
|
|
@ -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"])
|
||||
def test_pinned_version_refuses_older_handshake(offered):
|
||||
"""The SDK client cannot pin a handshake-era version through `mode`, so the
|
||||
older-handshake refusal is exercised at the enforcement hook."""
|
||||
def test_pinned_version_accepts_other_handshake_revision(offered):
|
||||
"""A handshake-version pin asserts the handshake *era*, not an exact revision.
|
||||
|
||||
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"])
|
||||
|
||||
with pytest.raises(MCPError) as excinfo:
|
||||
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
|
||||
# No raise: the pinned revision and the offered revision are both handshake.
|
||||
enforce_handshake_protocol_version(mcp, _initialize_request(offered))
|
||||
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
@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():
|
||||
mcp = FastMCP("open")
|
||||
enforce_handshake_protocol_version(mcp, _initialize_request("2024-11-05"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue