Skip to content

Commit 1044f42

Browse files
committed
Report the negotiated protocol_version once initialized; clarify envelope-overwrite semantics
- ClientSession.protocol_version returns the value from the InitializeResult once one exists (negotiated for stateful, the pin for stateless via the synthesized result), falling back to the pin only before the handshake. A stateful pin is the requested version, not a guarantee of the negotiated one; inbound validation now keys off what the server actually agreed to. - Reword lifecycle:stateless:caller-meta-preserved: the three envelope keys overwrite caller-supplied values for those keys; non-colliding caller keys are preserved. The capstone now passes a colliding key and the wire snapshot proves the overwrite.
1 parent 05211a3 commit 1044f42

4 files changed

Lines changed: 22 additions & 9 deletions

File tree

src/mcp/client/session.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,14 @@ def initialize_result(self) -> types.InitializeResult | None:
351351

352352
@property
353353
def protocol_version(self) -> str | None:
354-
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction."""
355-
if self._pinned_version is not None:
356-
return self._pinned_version
357-
return self._initialize_result.protocol_version if self._initialize_result else None
354+
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction.
355+
356+
Once `initialize()` has completed, this is the version the server actually
357+
negotiated (which can differ from a stateful pin); before that, the pin.
358+
"""
359+
if self._initialize_result is not None:
360+
return self._initialize_result.protocol_version
361+
return self._pinned_version
358362

359363
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
360364
"""Send a ping request."""

tests/client/test_session.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1432,8 +1432,10 @@ async def do_initialize() -> None:
14321432
assert isinstance(out.message, JSONRPCRequest)
14331433
assert out.message.params is not None
14341434
assert out.message.params["protocolVersion"] == "2025-06-18"
1435+
assert session.protocol_version == "2025-06-18"
1436+
# Server negotiates a different (older) supported version than the pin requested.
14351437
result = InitializeResult(
1436-
protocol_version="2025-06-18",
1438+
protocol_version="2025-03-26",
14371439
capabilities=ServerCapabilities(),
14381440
server_info=Implementation(name="mock-server", version="0.1.0"),
14391441
)
@@ -1450,6 +1452,8 @@ async def do_initialize() -> None:
14501452
# measures only what the second initialize() emits.
14511453
notif = await from_client.receive()
14521454
assert isinstance(notif.message, JSONRPCNotification)
1455+
# The property reports the negotiated version, not the pin, once the handshake is done.
1456+
assert session.protocol_version == "2025-03-26"
14531457
# A second call returns the cached result without a second handshake frame.
14541458
again = await session.initialize()
14551459
assert again is first[0]

tests/interaction/_requirements.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,8 @@ def __post_init__(self) -> None:
379379
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
380380
behavior=(
381381
"Caller-supplied _meta keys on a request survive the per-request envelope merge: the "
382-
"io.modelcontextprotocol/* keys are added alongside, never overwriting the caller's keys."
382+
"three io.modelcontextprotocol/* envelope keys overwrite any caller-supplied values for "
383+
"those keys; non-colliding caller keys are preserved."
383384
),
384385
added_in="2026-07-28",
385386
),

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,11 @@ async def on_response(response: httpx.Response) -> None:
240240
),
241241
ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session,
242242
):
243-
result = await session.call_tool("add", {"a": 2, "b": 3}, meta={"custom-key": "x"})
243+
result = await session.call_tool(
244+
"add",
245+
{"a": 2, "b": 3},
246+
meta={"custom-key": "x", "io.modelcontextprotocol/protocolVersion": "evil"},
247+
)
244248

245249
assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot(
246250
{"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"}
@@ -254,8 +258,8 @@ async def on_response(response: httpx.Response) -> None:
254258
)
255259
assert all("initialize" not in body["method"] for body in bodies)
256260

257-
# The tools/call POST carries the body-derived headers, and its _meta envelope merges the
258-
# caller's key alongside the three io.modelcontextprotocol/* keys.
261+
# The tools/call POST carries the body-derived headers, and its _meta envelope overwrites the
262+
# caller's colliding io.modelcontextprotocol/* key while preserving the non-colliding caller key.
259263
call = requests[0]
260264
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-")} == snapshot(
261265
{"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}

0 commit comments

Comments
 (0)