From 7d9c3f6b64dc160d0013fcb960494d835120c399 Mon Sep 17 00:00:00 2001 From: Mohammed Zoheb Shaik Date: Sat, 15 Aug 2026 11:59:39 +0400 Subject: [PATCH 1/2] fix(mcp): negotiate a handshake-era revision at initialize #509 replaced the hardcoded `2024-11-05` in the `initialize` result with `PROTOCOL_VERSION`, addressing "stop hardcoding the downstream protocol version" from #496. That constant is `2026-07-28`, the revision that removed `initialize` altogether, so the gateway answered every handshake by naming a protocol in which the request just made does not exist, and in which each later request must carry `_meta` plus the mirrored MCP-Protocol-Version / Mcp-Method headers a handshake-era client has no way to know it should send. Confirmed against every revision a real client offers: asked 2025-06-18, 2025-03-26 or 2024-11-05, the gateway answered 2026-07-28 in all three cases. The direction of the swap is the defect. PROTOCOL_VERSION is correct on the outbound leg, where the gateway is the client and #509 got it right, and wrong on the inbound one, where reaching `initialize` is itself proof the caller is handshake-era. Negotiate over _LEGACY_PROTOCOL_VERSIONS instead, echoing the client's request when the gateway speaks it. A client asking for 2026-07-28 at a handshake is deliberately not humoured. server.py no longer imports PROTOCOL_VERSION; #509 added that import solely for this misuse. Verified by mutation: reverting only the `initialize` line while keeping the new constant fails 9 of the 10 new tests. Full unit suite 1076 passed, with the 8 pre-existing agent_manifest SDK failures unchanged from main. Signed-off-by: Mohammed Zoheb Shaik --- CHANGELOG.md | 8 ++ src/cmcp_runtime/mcp/server.py | 30 +++++- .../unit/test_initialize_protocol_version.py | 97 +++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_initialize_protocol_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b935b6..57251608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`initialize` answered every handshake with a revision that has no `initialize` (#509).** Third on the to-do list in #496 was "stop hardcoding the downstream protocol version", and #509 did it by replacing the hardcoded `2024-11-05` in the `initialize` result with `PROTOCOL_VERSION`. That constant is `2026-07-28`: the revision that **removed** the handshake. So the gateway answered every `initialize` by naming a protocol in which the request just made does not exist, and in which each later request must carry `_meta` plus the mirrored `MCP-Protocol-Version` / `Mcp-Method` headers a handshake-era client has no way to know it should send. Confirmed against every revision a real client offers: asked `2025-06-18`, `2025-03-26` or `2024-11-05`, the gateway answered `2026-07-28` in all three cases. + + The direction of the swap is the whole defect. `PROTOCOL_VERSION` is correct on the **outbound** leg, where the gateway is the client and #509 got it right; it is wrong on the **inbound** one, where reaching `initialize` at all is proof the caller is handshake-era. `initialize` now negotiates over `_LEGACY_PROTOCOL_VERSIONS` only, echoing the client's request when the gateway speaks it and otherwise answering with the newest handshake-era revision. A client that asks for `2026-07-28` at a handshake is deliberately not humoured: it cannot be speaking a revision with no handshake, so confirming it would agree on a protocol neither side is using. `server.py` no longer imports `PROTOCOL_VERSION`; #509 introduced that import solely for this misuse. + + `tests/unit/test_initialize_protocol_version.py` pins the negotiation and asserts the outbound constant is untouched. Verified by mutation: reverting only the `initialize` line while keeping the new constant fails 9 of its 10 tests. + ### Security - The MCP ingress now rejects non-object JSON-RPC messages, non-string methods, and non-object `tools/call` parameters with a bounded `MCP_INVALID_REQUEST` response. Structurally invalid attacker input no longer reaches attribute errors, HTTP 500 responses, or exception trace logging. diff --git a/src/cmcp_runtime/mcp/server.py b/src/cmcp_runtime/mcp/server.py index 57259b5f..3e207da3 100644 --- a/src/cmcp_runtime/mcp/server.py +++ b/src/cmcp_runtime/mcp/server.py @@ -30,7 +30,6 @@ from cmcp_runtime.catalog.loader import ApprovedDefinition, CatalogEntry, ServerIdentity from cmcp_runtime.mcp.proxy import CMCPProxy -from cmcp_runtime.mcp.streamable_http import PROTOCOL_VERSION if TYPE_CHECKING: from cmcp_runtime.audit.chain import AuditChain @@ -42,6 +41,31 @@ # Endpoints exempt from bearer-token auth (Kubernetes liveness / readiness probes) _AUTH_EXEMPT_PATHS = {"/health", "/readyz"} +# Revisions the gateway can negotiate at `initialize`, newest first. +# +# `initialize` belongs to the handshake era only. `PROTOCOL_VERSION` (#509) is +# the revision the gateway speaks *outbound* to upstream servers, and it is +# 2026-07-28 - the revision that removed `initialize` altogether. It must never +# be the answer to a handshake: it names a protocol in which this request does +# not exist, and in which every subsequent request must carry `_meta` and the +# mirrored `MCP-Protocol-Version` / `Mcp-Method` headers that a handshake-era +# client has no way to send. +_LEGACY_PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05") + + +def _negotiate_protocol_version(params: dict[str, Any]) -> str: + """Pick the revision to answer `initialize` with. + + Echo the client's request when the gateway speaks it, otherwise answer with + the newest handshake-era revision and let the client decide whether it can + continue. The gateway advertises `tools` only, so the revision affects + transport framing rather than the surface exposed here. + """ + requested = params.get("protocolVersion") + if isinstance(requested, str) and requested in _LEGACY_PROTOCOL_VERSIONS: + return requested + return _LEGACY_PROTOCOL_VERSIONS[0] + def _invalid_request(rpc_id: Any = None) -> JSONResponse: """Return a bounded JSON-RPC Invalid Request response.""" @@ -311,7 +335,9 @@ async def _handle_mcp(self, request: Request) -> Response: "jsonrpc": "2.0", "id": rpc_id, "result": { - "protocolVersion": PROTOCOL_VERSION, + "protocolVersion": _negotiate_protocol_version( + params if isinstance(params, dict) else {} + ), "capabilities": {"tools": {}}, "serverInfo": {"name": "cmcp-runtime", "version": "0.1.0"}, }, diff --git a/tests/unit/test_initialize_protocol_version.py b/tests/unit/test_initialize_protocol_version.py new file mode 100644 index 00000000..cfa32d96 --- /dev/null +++ b/tests/unit/test_initialize_protocol_version.py @@ -0,0 +1,97 @@ +"""`initialize` must negotiate a handshake-era revision (regression for #509). + +#509 replaced the hardcoded `2024-11-05` in the `initialize` result with +`PROTOCOL_VERSION`, which is `2026-07-28` - the revision that removed +`initialize`. Every handshake was then answered with a protocol in which the +request just made does not exist, and in which each later request must carry +`_meta` plus mirrored headers the client has no way to know it should send. + +The direction of the swap matters: the constant is correct for the outbound +leg, where the gateway is the client, and wrong for the inbound one, where it +answers a handshake it only reaches because the caller is handshake-era. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from starlette.testclient import TestClient + +from cmcp_runtime.mcp.server import _LEGACY_PROTOCOL_VERSIONS, MCPServer +from cmcp_runtime.mcp.streamable_http import PROTOCOL_VERSION + + +def _make_server() -> MCPServer: + proxy = MagicMock() + proxy._catalog = MagicMock() + entry = MagicMock() + entry.approved_definition.description = "a mock tool" + entry.approved_definition.input_schema = {"type": "object", "properties": {}} + proxy._catalog.entries = {"mock_tool": entry} + proxy.call_tool = AsyncMock(return_value=MagicMock( + allowed=True, deny_reason=None, response="ok", + audit_entry_hash="sha256:" + "0" * 64, + would_have_denied=False, latency_us=100, advice=None, + )) + with patch("cmcp_runtime.mcp.server.StatelessKernel"): + return MCPServer(proxy) + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(_make_server().app, raise_server_exceptions=False) + + +def _initialize(client: TestClient, params: object) -> str: + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params}, + ) + assert resp.status_code == 200 + return resp.json()["result"]["protocolVersion"] + + +@pytest.mark.parametrize("requested", _LEGACY_PROTOCOL_VERSIONS) +def test_initialize_echoes_the_requested_revision(client, requested): + """What Claude Code, Claude Desktop, Cursor and VS Code actually send.""" + negotiated = _initialize(client, { + "protocolVersion": requested, + "capabilities": {}, + "clientInfo": {"name": "claude-code", "version": "1.0.0"}, + }) + assert negotiated == requested + + +def test_initialize_never_answers_with_the_outbound_revision(client): + """The regression itself: `2026-07-28` has no `initialize` to answer with.""" + negotiated = _initialize(client, {"protocolVersion": "2025-06-18", "capabilities": {}}) + assert negotiated != PROTOCOL_VERSION + + +def test_a_client_asking_for_the_stateless_revision_is_not_humoured(client): + """A handshake is proof the caller is handshake-era, whatever it asked for. + + A client that reaches `initialize` cannot be speaking 2026-07-28, so echoing + it back would confirm a revision neither side is actually using. + """ + negotiated = _initialize(client, {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}) + assert negotiated in _LEGACY_PROTOCOL_VERSIONS + assert negotiated != PROTOCOL_VERSION + + +@pytest.mark.parametrize("params", [ + {"protocolVersion": "1999-01-01", "capabilities": {}}, + {"capabilities": {}}, + {}, + ["positional", "params"], +]) +def test_unnegotiable_params_fall_back_to_the_newest_legacy_revision(client, params): + """Unknown, absent, or non-object params still yield a usable answer.""" + assert _initialize(client, params) == _LEGACY_PROTOCOL_VERSIONS[0] + + +def test_outbound_constant_is_unchanged(): + """The fix must not touch the leg #509 got right.""" + assert PROTOCOL_VERSION == "2026-07-28" + assert PROTOCOL_VERSION not in _LEGACY_PROTOCOL_VERSIONS From 3f8e164c02ab71c6b38eaa5b0bfd4424a87937d4 Mon Sep 17 00:00:00 2001 From: Mohammed Zoheb Shaik Date: Sun, 16 Aug 2026 13:26:34 +0400 Subject: [PATCH 2/2] fix(mcp): support 2025-11-25 and reject non-object initialize params Review feedback on #513. 1. _LEGACY_PROTOCOL_VERSIONS omitted 2025-11-25, the newest revision that still defines `initialize`. A client offering the latest handshake revision was therefore answered 2025-06-18. The lifecycle spec requires a server to echo a requested version it supports, and says a client that does not support the server's answer SHOULD disconnect, so a needless downgrade is the same class of defect this branch already fixes, one revision over. 2025-11-25 now heads the tuple. Echo and fallback are covered twice over. The parametrized echo test walks _LEGACY_PROTOCOL_VERSIONS, and two further tests name 2025-11-25 literally: one that it is echoed rather than downgraded, one that an unknown version falls back to it. The literal pair matters because a test parametrized over the constant under test loses its own case when that constant is wrong, which is exactly the regression being fixed. 2. test_unnegotiable_params_fall_back_to_the_newest_legacy_revision asserted that array-shaped `initialize` params negotiate successfully, turning an existing validation gap into an asserted contract. InitializeRequestParams is an object with required protocolVersion, capabilities and clientInfo, so a non-object is now rejected with -32600, matching how #500 already rejects non-object tools/call params. Absent params stays legal and negotiates the newest revision. The array case moves to a negative test alongside a string and an integer. Verified by mutation, each fix independently: dropping 2025-11-25 fails three tests, re-blessing non-object params fails three more. Full unit suite 1082 passed, with the 8 pre-existing agent_manifest SDK failures unchanged from main. Ruff and mypy clean. Signed-off-by: Mohammed Zoheb Shaik --- CHANGELOG.md | 4 ++ src/cmcp_runtime/mcp/server.py | 29 +++++--- .../unit/test_initialize_protocol_version.py | 71 ++++++++++++++++++- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57251608..604af29a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The direction of the swap is the whole defect. `PROTOCOL_VERSION` is correct on the **outbound** leg, where the gateway is the client and #509 got it right; it is wrong on the **inbound** one, where reaching `initialize` at all is proof the caller is handshake-era. `initialize` now negotiates over `_LEGACY_PROTOCOL_VERSIONS` only, echoing the client's request when the gateway speaks it and otherwise answering with the newest handshake-era revision. A client that asks for `2026-07-28` at a handshake is deliberately not humoured: it cannot be speaking a revision with no handshake, so confirming it would agree on a protocol neither side is using. `server.py` no longer imports `PROTOCOL_VERSION`; #509 introduced that import solely for this misuse. + That set now leads with **`2025-11-25`**, the newest revision that still defines `initialize`. It had been omitted, so a client offering the latest handshake revision was answered `2025-06-18` instead. The lifecycle spec requires a server to echo a version it supports and says a client that does not support the server's answer SHOULD disconnect, which makes a needless downgrade the same class of defect as the one above, one revision over. + + Non-object `initialize` params are now rejected with `-32600` rather than treated as an empty object. `InitializeRequestParams` is an object with required members, so answering a malformed handshake with a successful negotiation blessed a validation gap. This matches how #500 already rejects non-object `tools/call` params. Absent `params` remains legal and negotiates the newest revision. + `tests/unit/test_initialize_protocol_version.py` pins the negotiation and asserts the outbound constant is untouched. Verified by mutation: reverting only the `initialize` line while keeping the new constant fails 9 of its 10 tests. ### Security diff --git a/src/cmcp_runtime/mcp/server.py b/src/cmcp_runtime/mcp/server.py index 3e207da3..a0c4d8bc 100644 --- a/src/cmcp_runtime/mcp/server.py +++ b/src/cmcp_runtime/mcp/server.py @@ -50,16 +50,24 @@ # not exist, and in which every subsequent request must carry `_meta` and the # mirrored `MCP-Protocol-Version` / `Mcp-Method` headers that a handshake-era # client has no way to send. -_LEGACY_PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05") +# +# `2025-11-25` is the newest revision that still defines `initialize`, so it +# heads the list: a client offering the latest handshake revision must get it +# echoed rather than be downgraded. +_LEGACY_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") def _negotiate_protocol_version(params: dict[str, Any]) -> str: """Pick the revision to answer `initialize` with. - Echo the client's request when the gateway speaks it, otherwise answer with - the newest handshake-era revision and let the client decide whether it can - continue. The gateway advertises `tools` only, so the revision affects - transport framing rather than the surface exposed here. + Per the lifecycle spec: if the server supports the requested version it MUST + respond with that same version, otherwise it MUST respond with another it + supports, which SHOULD be the latest. A client that does not support the + answer SHOULD disconnect, which is why a needless downgrade is not a + harmless one. + + The gateway advertises `tools` only, so the revision affects transport + framing rather than the surface exposed here. """ requested = params.get("protocolVersion") if isinstance(requested, str) and requested in _LEGACY_PROTOCOL_VERSIONS: @@ -331,13 +339,18 @@ async def _handle_mcp(self, request: Request) -> Response: if method == "tools/list": return await self._handle_tools_list(rpc_id) if method == "initialize": + # `InitializeRequestParams` is an object. Treating a non-object as + # an empty one would answer a malformed handshake with a successful + # negotiation, so it is rejected the same way `tools/call` rejects + # its own non-object params. Absent `params` stays legal and + # negotiates the newest revision. + if not isinstance(params, dict): + return _invalid_request(rpc_id) return JSONResponse({ "jsonrpc": "2.0", "id": rpc_id, "result": { - "protocolVersion": _negotiate_protocol_version( - params if isinstance(params, dict) else {} - ), + "protocolVersion": _negotiate_protocol_version(params), "capabilities": {"tools": {}}, "serverInfo": {"name": "cmcp-runtime", "version": "0.1.0"}, }, diff --git a/tests/unit/test_initialize_protocol_version.py b/tests/unit/test_initialize_protocol_version.py index cfa32d96..208a67da 100644 --- a/tests/unit/test_initialize_protocol_version.py +++ b/tests/unit/test_initialize_protocol_version.py @@ -84,13 +84,78 @@ def test_a_client_asking_for_the_stateless_revision_is_not_humoured(client): {"protocolVersion": "1999-01-01", "capabilities": {}}, {"capabilities": {}}, {}, - ["positional", "params"], ]) -def test_unnegotiable_params_fall_back_to_the_newest_legacy_revision(client, params): - """Unknown, absent, or non-object params still yield a usable answer.""" +def test_unnegotiable_params_fall_back_to_the_newest_supported_revision(client, params): + """An unknown or absent version still yields a usable answer. + + The lifecycle spec: absent support for the requested version, the server + MUST answer with another it supports, and that SHOULD be the latest. + + Scope note. These cases assert the *version fallback* only. Two of them are + incomplete against the schema, which marks `protocolVersion`, `capabilities` + and `clientInfo` all required on `InitializeRequestParams`, and the gateway + does not enforce members. That leniency is deliberate and unchanged by this + branch: the shape check above rejects a `params` that cannot be an + `InitializeRequestParams` at all, while member validation would be a + behaviour change of its own, and would have to decide what happens to a + handshake with no `params` at all. Read these as "negotiation still + resolves", not as "this payload is conformant". + """ assert _initialize(client, params) == _LEGACY_PROTOCOL_VERSIONS[0] +def test_the_newest_handshake_revision_is_2025_11_25(): + """`2025-11-25` is the last revision that defines `initialize`. + + Answering a client that offers it with anything older is a downgrade, and + the spec says a client that does not support the server's answer SHOULD + disconnect - so the fallback is not a harmless one. + """ + assert _LEGACY_PROTOCOL_VERSIONS[0] == "2025-11-25" + + +def test_2025_11_25_is_echoed_and_not_downgraded(client): + """The exact case that regressed, hardcoded on purpose. + + `test_initialize_echoes_the_requested_revision` is parametrized over + `_LEGACY_PROTOCOL_VERSIONS`, so dropping a revision from that tuple also + drops its own test case. A test that draws its inputs from the constant + under test cannot catch the constant being wrong, so this one names the + revision literally. + """ + negotiated = _initialize(client, { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "claude-code", "version": "1.0.0"}, + }) + assert negotiated == "2025-11-25" + assert negotiated != "2025-06-18" + + +def test_unknown_version_falls_back_to_2025_11_25(client): + """Fallback target named literally, for the same reason.""" + assert _initialize(client, { + "protocolVersion": "1999-01-01", "capabilities": {}, + }) == "2025-11-25" + + +@pytest.mark.parametrize("params", [ + ["positional", "params"], + "a string", + 42, +]) +def test_non_object_initialize_params_are_rejected(client, params): + """`InitializeRequestParams` is an object, so a non-object is not a + negotiation the gateway should complete.""" + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params}, + ) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == -32600 + assert "result" not in resp.json() + + def test_outbound_constant_is_unchanged(): """The fix must not touch the leg #509 got right.""" assert PROTOCOL_VERSION == "2026-07-28"