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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue