diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index e17e1fc5a..3fe313f26 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -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. diff --git a/docs/development/v4-notes/protocol-2026.mdx b/docs/development/v4-notes/protocol-2026.mdx index a38c48810..5cc2c3fd1 100644 --- a/docs/development/v4-notes/protocol-2026.mdx +++ b/docs/development/v4-notes/protocol-2026.mdx @@ -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 diff --git a/fastmcp_slim/fastmcp/server/protocol_versions.py b/fastmcp_slim/fastmcp/server/protocol_versions.py index 307b81442..f7399fb0c 100644 --- a/fastmcp_slim/fastmcp/server/protocol_versions.py +++ b/fastmcp_slim/fastmcp/server/protocol_versions.py @@ -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 diff --git a/tests/server/test_protocol_versions.py b/tests/server/test_protocol_versions.py index 3ff23af2e..6b54ab446 100644 --- a/tests/server/test_protocol_versions.py +++ b/tests/server/test_protocol_versions.py @@ -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"))