Skip to content

Commit 3791666

Browse files
committed
feat: add protocol version override support for client session initialization
1 parent 5873402 commit 3791666

8 files changed

Lines changed: 170 additions & 11 deletions

File tree

src/mcp/client/_probe.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def _parse_supported(data: Any) -> list[str] | None:
3838
return None
3939

4040

41-
async def negotiate_auto(session: ClientSession) -> None:
41+
async def negotiate_auto(session: ClientSession, protocol_version: str | None = None) -> None:
4242
"""Drive the ``mode='auto'`` connect-time policy on ``session``.
4343
4444
Probes ``server/discover`` once (twice if the server names a mutual
@@ -65,14 +65,20 @@ async def negotiate_auto(session: ClientSession) -> None:
6565
continue
6666
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
6767
raise # server is modern-only and disjoint — real incompatibility
68-
await session.initialize() # every other rpc-error → legacy (the denylist)
68+
if protocol_version is not None:
69+
await session.initialize(protocol_version=protocol_version)
70+
else:
71+
await session.initialize() # every other rpc-error → legacy (the denylist)
6972
return
7073
# any other exception (httpx.TransportError, ConnectionError, anyio errors,
7174
# RuntimeError from adopt) → propagate
7275
try:
7376
result = types.DiscoverResult.model_validate(raw)
7477
except ValidationError:
75-
await session.initialize() # unparseable result → not modern evidence
78+
if protocol_version is not None:
79+
await session.initialize(protocol_version=protocol_version)
80+
else:
81+
await session.initialize() # unparseable result → not modern evidence
7682
return
7783
session.adopt(result)
7884
return

src/mcp/client/client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ async def main():
200200
"""Callback for handling elicitation requests."""
201201

202202
_entered: bool = field(init=False, default=False)
203+
protocol_version_override: str | None = None
204+
"""The protocol version to request during initialization. Defaults to the latest version."""
203205
_session: ClientSession | None = field(init=False, default=None)
204206
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
205207
_connect: _Connector = field(init=False, repr=False, compare=False)
@@ -250,9 +252,12 @@ async def __aenter__(self) -> Client:
250252
session = await exit_stack.enter_async_context(session)
251253

252254
if self.mode == "legacy":
253-
await session.initialize()
255+
if self.protocol_version_override is not None:
256+
await session.initialize(protocol_version=self.protocol_version_override)
257+
else:
258+
await session.initialize()
254259
elif self.mode == "auto":
255-
await negotiate_auto(session)
260+
await negotiate_auto(session, protocol_version=self.protocol_version_override)
256261
else:
257262
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
258263

src/mcp/client/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,13 @@ def _build_capabilities(self) -> types.ClientCapabilities:
350350
)
351351
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
352352

353-
async def initialize(self) -> types.InitializeResult:
353+
async def initialize(self, protocol_version: str = LATEST_HANDSHAKE_VERSION) -> types.InitializeResult:
354354
if self._initialize_result is not None:
355355
return self._initialize_result
356356
result = await self.send_request(
357357
types.InitializeRequest(
358358
params=types.InitializeRequestParams(
359-
protocol_version=LATEST_HANDSHAKE_VERSION,
359+
protocol_version=protocol_version,
360360
capabilities=self._build_capabilities(),
361361
client_info=self._client_info,
362362
),

src/mcp/client/session_group.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class ClientSessionParameters:
8080
logging_callback: LoggingFnT | None = None
8181
message_handler: MessageHandlerFnT | None = None
8282
client_info: types.Implementation | None = None
83+
protocol_version: str | None = None
8384

8485

8586
class ClientSessionGroup:
@@ -352,7 +353,10 @@ async def _establish_session(
352353
)
353354
)
354355

355-
result = await session.initialize()
356+
if session_params.protocol_version is not None:
357+
result = await session.initialize(protocol_version=session_params.protocol_version)
358+
else:
359+
result = await session.initialize()
356360

357361
# Session successfully initialized.
358362
# Store its stack and register the stack with the main group stack.

tests/client/test_client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
126126
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
127127

128128

129+
async def test_client_custom_protocol_version(app: MCPServer):
130+
"""Test that the client negotiates a custom protocol version when configured."""
131+
async with Client(app, mode="legacy", protocol_version_override="2024-11-05") as client:
132+
assert client.protocol_version == "2024-11-05"
133+
assert client.server_info.name == "test"
134+
135+
129136
async def test_client_with_simple_server(simple_server: Server):
130137
"""Test that from_server works with a basic Server instance."""
131138
async with Client(simple_server) as client:

tests/client/test_probe.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def __init__(self, *script: dict[str, Any] | Exception) -> None:
5252
self._script: list[dict[str, Any] | Exception] = list(script)
5353
self.probed_at: list[str] = []
5454
self.initialized: bool = False
55+
self.initialize_version: str | None = None
5556
self.adopted: types.DiscoverResult | None = None
5657

5758
async def send_discover(self, version: str) -> dict[str, Any]:
@@ -61,16 +62,17 @@ async def send_discover(self, version: str) -> dict[str, Any]:
6162
raise step
6263
return step
6364

64-
async def initialize(self) -> None:
65+
async def initialize(self, protocol_version: str | None = None) -> None:
6566
self.initialized = True
67+
self.initialize_version = protocol_version
6668

6769
def adopt(self, result: types.DiscoverResult) -> None:
6870
self.adopted = result
6971

7072

71-
async def _negotiate(session: _StubSession) -> None:
73+
async def _negotiate(session: _StubSession, protocol_version: str | None = None) -> None:
7274
"""Drive `negotiate_auto` against the stub; cast at one seam so the tests stay suppression-free."""
73-
await negotiate_auto(cast("ClientSession", session))
75+
await negotiate_auto(cast("ClientSession", session), protocol_version=protocol_version)
7476

7577

7678
def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]:
@@ -240,3 +242,20 @@ def test_parse_supported_returns_none_for_anything_not_shaped_like_the_spec_erro
240242
"""`_parse_supported` returns the `supported` list when `error.data` validates as
241243
`UnsupportedProtocolVersionErrorData`, and `None` otherwise — never raises."""
242244
assert _parse_supported(data) == expected
245+
246+
247+
async def test_negotiate_auto_mcp_error_with_custom_protocol_version() -> None:
248+
"""Test that negotiate_auto initializes with a custom protocol version when discover returns an MCPError."""
249+
session = _StubSession(MCPError(code=METHOD_NOT_FOUND, message="nope"))
250+
await _negotiate(session, protocol_version="2024-11-05")
251+
assert session.initialized
252+
assert session.initialize_version == "2024-11-05"
253+
254+
255+
async def test_negotiate_auto_validation_error_with_custom_protocol_version() -> None:
256+
"""Test that negotiate_auto initializes with a custom protocol version when discover returns unparseable result."""
257+
session = _StubSession({"not": "a discover result"})
258+
await _negotiate(session, protocol_version="2024-11-05")
259+
assert session.initialized
260+
assert session.initialize_version == "2024-11-05"
261+

tests/client/test_session.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,90 @@ async def message_handler( # pragma: no cover
153153
assert isinstance(initialized_notification, InitializedNotification)
154154

155155

156+
@pytest.mark.anyio
157+
async def test_client_session_initialize_custom_protocol_version():
158+
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
159+
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
160+
161+
initialized_notification = None
162+
result = None
163+
164+
async def mock_server():
165+
nonlocal initialized_notification
166+
167+
session_message = await client_to_server_receive.receive()
168+
jsonrpc_request = session_message.message
169+
assert isinstance(jsonrpc_request, JSONRPCRequest)
170+
request = client_request_adapter.validate_python(
171+
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
172+
)
173+
assert isinstance(request, InitializeRequest)
174+
assert request.params.protocol_version == "2024-11-05"
175+
176+
result = InitializeResult(
177+
protocol_version="2024-11-05",
178+
capabilities=ServerCapabilities(
179+
logging=None,
180+
resources=None,
181+
tools=None,
182+
experimental=None,
183+
prompts=None,
184+
),
185+
server_info=Implementation(name="mock-server", version="0.1.0"),
186+
instructions="The server instructions.",
187+
)
188+
189+
async with server_to_client_send:
190+
await server_to_client_send.send(
191+
SessionMessage(
192+
JSONRPCResponse(
193+
jsonrpc="2.0",
194+
id=jsonrpc_request.id,
195+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
196+
)
197+
)
198+
)
199+
session_notification = await client_to_server_receive.receive()
200+
jsonrpc_notification = session_notification.message
201+
assert isinstance(jsonrpc_notification, JSONRPCNotification)
202+
initialized_notification = client_notification_adapter.validate_python(
203+
jsonrpc_notification.model_dump(by_alias=True, mode="json", exclude_none=True)
204+
)
205+
206+
# Create a message handler to catch exceptions
207+
async def message_handler( # pragma: no cover
208+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
209+
) -> None:
210+
if isinstance(message, Exception):
211+
raise message
212+
213+
async with (
214+
ClientSession(
215+
server_to_client_receive,
216+
client_to_server_send,
217+
message_handler=message_handler,
218+
) as session,
219+
anyio.create_task_group() as tg,
220+
client_to_server_send,
221+
client_to_server_receive,
222+
server_to_client_send,
223+
server_to_client_receive,
224+
):
225+
tg.start_soon(mock_server)
226+
result = await session.initialize(protocol_version="2024-11-05")
227+
228+
# Assert the result
229+
assert isinstance(result, InitializeResult)
230+
assert result.protocol_version == "2024-11-05"
231+
assert isinstance(result.capabilities, ServerCapabilities)
232+
assert result.server_info == Implementation(name="mock-server", version="0.1.0")
233+
assert result.instructions == "The server instructions."
234+
235+
# Check that the client sent the initialized notification
236+
assert initialized_notification
237+
assert isinstance(initialized_notification, InitializedNotification)
238+
239+
156240
@pytest.mark.anyio
157241
async def test_client_session_custom_client_info():
158242
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)

tests/client/test_session_group.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,37 @@ async def test_client_session_group_establish_session_parameterized(
402402
# 3. Assert returned values
403403
assert returned_server_info is mock_initialize_result.server_info
404404
assert returned_session is mock_entered_session
405+
406+
407+
@pytest.mark.anyio
408+
async def test_client_session_group_establish_session_custom_protocol_version():
409+
with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class:
410+
with mock.patch("mcp.client.session_group.mcp.stdio_client") as mock_stdio_client:
411+
mock_client_cm_instance = mock.AsyncMock(name="stdioClientCM")
412+
mock_read_stream = mock.AsyncMock(name="stdioRead")
413+
mock_write_stream = mock.AsyncMock(name="stdioWrite")
414+
415+
mock_client_cm_instance.__aenter__.return_value = (mock_read_stream, mock_write_stream)
416+
mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None)
417+
mock_stdio_client.return_value = mock_client_cm_instance
418+
419+
mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM")
420+
mock_ClientSession_class.return_value = mock_raw_session_cm
421+
422+
mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance")
423+
mock_raw_session_cm.__aenter__.return_value = mock_entered_session
424+
mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None)
425+
426+
mock_initialize_result = mock.AsyncMock(name="InitializeResult")
427+
mock_initialize_result.server_info = types.Implementation(name="foo", version="1")
428+
mock_entered_session.initialize.return_value = mock_initialize_result
429+
430+
group = ClientSessionGroup()
431+
server_params = StdioServerParameters(command="test_stdio_cmd")
432+
session_params = ClientSessionParameters(protocol_version="2024-11-05")
433+
434+
async with contextlib.AsyncExitStack() as stack:
435+
group._exit_stack = stack
436+
await group._establish_session(server_params, session_params)
437+
438+
mock_entered_session.initialize.assert_awaited_once_with(protocol_version="2024-11-05")

0 commit comments

Comments
 (0)