Skip to content

Commit 3789d00

Browse files
committed
Expose the negotiated protocol version on ServerSession
Handlers had no way to read which protocol revision the initialize handshake settled on. Add ServerSession.protocol_version, backed by the connection state: None before initialization and always None on stateless connections, where no handshake reaches the session and the per-request version lives in the MCP-Protocol-Version header.
1 parent 4468a7f commit 3789d00

4 files changed

Lines changed: 83 additions & 0 deletions

File tree

src/mcp/server/connection.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ class Connection:
8383
"""The full `initialize` request params; `None` before initialization."""
8484

8585
protocol_version: str | None
86+
"""The protocol version negotiated during `initialize`; `None` before
87+
initialization, and always `None` on stateless connections (no handshake
88+
reaches them). Handlers read this as `ServerSession.protocol_version`."""
8689

8790
initialized: anyio.Event
8891
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);

src/mcp/server/session.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ def client_params(self) -> types.InitializeRequestParams | None:
5050
"""The client's `initialize` request params; `None` before initialization."""
5151
return self._connection.client_params
5252

53+
@property
54+
def protocol_version(self) -> str | None:
55+
"""The protocol version negotiated during `initialize`.
56+
57+
`None` before initialization completes, and always `None` on stateless
58+
connections (no handshake reaches them; on streamable HTTP the
59+
per-request version is the `MCP-Protocol-Version` header, available
60+
via `ctx.request.headers`).
61+
"""
62+
return self._connection.protocol_version
63+
5364
async def send_request(
5465
self,
5566
request: types.ServerRequest,

tests/client/test_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ async def test_client_is_initialized(app: MCPServer):
113113
assert client.initialize_result.server_info.name == "test"
114114

115115

116+
async def test_client_initialize_result_exposes_negotiated_protocol_version(app: MCPServer):
117+
"""The negotiated protocol version is readable after initialization."""
118+
async with Client(app) as client:
119+
assert client.initialize_result.protocol_version == types.LATEST_PROTOCOL_VERSION
120+
121+
116122
async def test_client_with_simple_server(simple_server: Server):
117123
"""Test that from_server works with a basic Server instance."""
118124
async with Client(simple_server) as client:

tests/server/test_session.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pytest
1212

1313
from mcp import types
14+
from mcp.server import Server, ServerRequestContext
1415
from mcp.server.connection import Connection
1516
from mcp.server.session import ServerSession
1617
from mcp.shared.dispatcher import CallOptions
@@ -26,6 +27,8 @@
2627
SamplingToolsCapability,
2728
)
2829

30+
from .test_runner import connected_runner
31+
2932

3033
class StubDispatcher:
3134
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
@@ -158,3 +161,63 @@ def test_check_client_capability_delegates_to_connection():
158161
session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability()))
159162
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
160163
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False
164+
165+
166+
def _runner_server(seen_versions: list[str | None]) -> Server[dict[str, Any]]:
167+
"""A lowlevel Server whose tools/list handler records `ctx.session.protocol_version`."""
168+
169+
async def list_tools(
170+
ctx: ServerRequestContext[dict[str, Any], Any], params: types.PaginatedRequestParams | None
171+
) -> types.ListToolsResult:
172+
seen_versions.append(ctx.session.protocol_version)
173+
return types.ListToolsResult(tools=[])
174+
175+
return Server(name="test-server", version="0.0.1", on_list_tools=list_tools)
176+
177+
178+
def _init_params(protocol_version: str) -> dict[str, Any]:
179+
return InitializeRequestParams(
180+
protocol_version=protocol_version,
181+
capabilities=ClientCapabilities(),
182+
client_info=Implementation(name="test-client", version="1.0"),
183+
).model_dump(by_alias=True, exclude_none=True)
184+
185+
186+
@pytest.mark.anyio
187+
async def test_protocol_version_is_none_before_initialize():
188+
async with connected_runner(_runner_server([]), initialized=False) as (_client, runner):
189+
assert runner.session.protocol_version is None
190+
191+
192+
@pytest.mark.anyio
193+
async def test_protocol_version_is_negotiated_version_after_initialize():
194+
"""A supported requested version is echoed back and readable on the session,
195+
both directly and from inside a handler via `ctx.session`."""
196+
seen: list[str | None] = []
197+
async with connected_runner(_runner_server(seen), initialized=False) as (client, runner):
198+
result = await client.send_raw_request("initialize", _init_params("2025-03-26"))
199+
assert result["protocolVersion"] == "2025-03-26"
200+
assert runner.session.protocol_version == "2025-03-26"
201+
await client.send_raw_request("tools/list", None)
202+
assert seen == ["2025-03-26"]
203+
204+
205+
@pytest.mark.anyio
206+
async def test_protocol_version_reads_latest_when_requested_version_unsupported():
207+
"""An unsupported requested version negotiates down to LATEST_PROTOCOL_VERSION."""
208+
async with connected_runner(_runner_server([]), initialized=False) as (client, runner):
209+
result = await client.send_raw_request("initialize", _init_params("1999-01-01"))
210+
assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION
211+
assert runner.session.protocol_version == LATEST_PROTOCOL_VERSION
212+
213+
214+
@pytest.mark.anyio
215+
async def test_protocol_version_is_none_on_stateless_connection():
216+
"""Stateless connections never see a handshake: requests flow, but the
217+
negotiated version legitimately stays None."""
218+
seen: list[str | None] = []
219+
async with connected_runner(_runner_server(seen), initialized=False, stateless=True) as (client, runner):
220+
result = await client.send_raw_request("tools/list", None)
221+
assert result == {"tools": []}
222+
assert seen == [None]
223+
assert runner.session.protocol_version is None

0 commit comments

Comments
 (0)