Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ 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.

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

- 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.
Expand Down
43 changes: 41 additions & 2 deletions src/cmcp_runtime/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +41,39 @@
# 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.
#
# `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.

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:
return requested
return _LEGACY_PROTOCOL_VERSIONS[0]


def _invalid_request(rpc_id: Any = None) -> JSONResponse:
"""Return a bounded JSON-RPC Invalid Request response."""
Expand Down Expand Up @@ -307,11 +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": PROTOCOL_VERSION,
"protocolVersion": _negotiate_protocol_version(params),
"capabilities": {"tools": {}},
"serverInfo": {"name": "cmcp-runtime", "version": "0.1.0"},
},
Expand Down
162 changes: 162 additions & 0 deletions tests/unit/test_initialize_protocol_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""`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": {}},
{},
])
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"
assert PROTOCOL_VERSION not in _LEGACY_PROTOCOL_VERSIONS
Loading