Skip to content

Commit a6cfd86

Browse files
committed
feat: add protocol version override support for client session initialization
1 parent 9972c21 commit a6cfd86

8 files changed

Lines changed: 171 additions & 13 deletions

File tree

‎src/mcp/client/_probe.py‎

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

5151

52-
async def negotiate_auto(session: ClientSession) -> None:
52+
async def negotiate_auto(session: ClientSession, protocol_version: str | None = None) -> None:
5353
"""Drive the ``mode='auto'`` connect-time policy on ``session``.
5454
5555
Probes ``server/discover`` once (twice if the server names a mutual
@@ -78,7 +78,10 @@ async def negotiate_auto(session: ClientSession) -> None:
7878
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
7979
raise # server is modern-only and disjoint — real incompatibility
8080
try:
81-
await session.initialize() # every other rpc-error → legacy (the denylist)
81+
if protocol_version is not None:
82+
await session.initialize(protocol_version=protocol_version)
83+
else:
84+
await session.initialize() # every other rpc-error → legacy (the denylist)
8285
except MCPError as handshake_exc:
8386
if handshake_exc.code != UNSUPPORTED_PROTOCOL_VERSION or attempt != 0:
8487
raise
@@ -99,7 +102,10 @@ async def negotiate_auto(session: ClientSession) -> None:
99102
try:
100103
result = types.DiscoverResult.model_validate(raw)
101104
except ValidationError:
102-
await session.initialize() # unparseable result → not modern evidence
105+
if protocol_version is not None:
106+
await session.initialize(protocol_version=protocol_version)
107+
else:
108+
await session.initialize() # unparseable result → not modern evidence
103109
return
104110
if not any(v in result.supported_versions for v in MODERN_PROTOCOL_VERSIONS):
105111
# A discover-answering server that advertises no modern version

‎src/mcp/client/client.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ async def main():
366366
derived)."""
367367

368368
_entered: bool = field(init=False, default=False)
369+
protocol_version_override: str | None = None
370+
"""The protocol version to request during initialization. Defaults to the latest version."""
369371
_session: ClientSession | None = field(init=False, default=None)
370372
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
371373
_connect: _Connector = field(init=False, repr=False, compare=False)
@@ -455,9 +457,12 @@ async def __aenter__(self) -> Client:
455457
session = await exit_stack.enter_async_context(session)
456458

457459
if self.mode == "legacy":
458-
await session.initialize()
460+
if self.protocol_version_override is not None:
461+
await session.initialize(protocol_version=self.protocol_version_override)
462+
else:
463+
await session.initialize()
459464
elif self.mode == "auto":
460-
await negotiate_auto(session)
465+
await negotiate_auto(session, protocol_version=self.protocol_version_override)
461466
else:
462467
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
463468

‎src/mcp/client/session.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -648,15 +648,14 @@ def _build_capabilities(self, version: str) -> types.ClientCapabilities:
648648
sampling=sampling, elicitation=elicitation, experimental=None, extensions=extensions, roots=roots
649649
)
650650

651-
async def initialize(self) -> types.InitializeResult:
651+
async def initialize(self, protocol_version: str = LATEST_HANDSHAKE_VERSION) -> types.InitializeResult:
652652
if self._initialize_result is not None:
653653
return self._initialize_result
654654
result = await self.send_request(
655655
types.InitializeRequest(
656656
params=types.InitializeRequestParams(
657-
protocol_version=LATEST_HANDSHAKE_VERSION,
658-
# The handshake negotiates only legacy versions, where no claim is active.
659-
capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION),
657+
protocol_version=protocol_version,
658+
capabilities=self._build_capabilities(protocol_version),
660659
client_info=self._client_info,
661660
),
662661
),

‎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
@@ -130,6 +130,13 @@ async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
130130
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
131131

132132

133+
async def test_client_custom_protocol_version(app: MCPServer):
134+
"""Test that the client negotiates a custom protocol version when configured."""
135+
async with Client(app, mode="legacy", protocol_version_override="2024-11-05") as client:
136+
assert client.protocol_version == "2024-11-05"
137+
assert client.server_info.name == "test"
138+
139+
133140
async def test_client_with_simple_server(simple_server: Server):
134141
"""Test that from_server works with a basic Server instance."""
135142
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
@@ -57,6 +57,7 @@ def __init__(self, *script: dict[str, Any] | Exception, handshake: list[Exceptio
5757
self.probed_at: list[str] = []
5858
self.initialize_calls: int = 0
5959
self.initialized: bool = False
60+
self.initialize_version: str | None = None
6061
self.adopted: types.DiscoverResult | None = None
6162

6263
async def send_discover(self, version: str) -> dict[str, Any]:
@@ -66,19 +67,20 @@ async def send_discover(self, version: str) -> dict[str, Any]:
6667
raise step
6768
return step
6869

69-
async def initialize(self) -> None:
70+
async def initialize(self, protocol_version: str | None = None) -> None:
7071
self.initialize_calls += 1
7172
if self._handshake:
7273
raise self._handshake.pop(0)
7374
self.initialized = True
75+
self.initialize_version = protocol_version
7476

7577
def adopt(self, result: types.DiscoverResult) -> None:
7678
self.adopted = result
7779

7880

79-
async def _negotiate(session: _StubSession) -> None:
81+
async def _negotiate(session: _StubSession, protocol_version: str | None = None) -> None:
8082
"""Drive `negotiate_auto` against the stub; cast at one seam so the tests stay suppression-free."""
81-
await negotiate_auto(cast("ClientSession", session))
83+
await negotiate_auto(cast("ClientSession", session), protocol_version=protocol_version)
8284

8385

8486
def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]:
@@ -331,3 +333,20 @@ def test_parse_supported_returns_none_for_anything_not_shaped_like_the_spec_erro
331333
"""`_parse_supported` returns the `supported` list when `error.data` validates as
332334
`UnsupportedProtocolVersionErrorData`, and `None` otherwise — never raises."""
333335
assert _parse_supported(data) == expected
336+
337+
338+
async def test_negotiate_auto_mcp_error_with_custom_protocol_version() -> None:
339+
"""Test that negotiate_auto initializes with a custom protocol version when discover returns an MCPError."""
340+
session = _StubSession(MCPError(code=METHOD_NOT_FOUND, message="nope"))
341+
await _negotiate(session, protocol_version="2024-11-05")
342+
assert session.initialized
343+
assert session.initialize_version == "2024-11-05"
344+
345+
346+
async def test_negotiate_auto_validation_error_with_custom_protocol_version() -> None:
347+
"""Test that negotiate_auto initializes with a custom protocol version when discover returns unparseable result."""
348+
session = _StubSession({"not": "a discover result"})
349+
await _negotiate(session, protocol_version="2024-11-05")
350+
assert session.initialized
351+
assert session.initialize_version == "2024-11-05"
352+

‎tests/client/test_session.py‎

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

156156

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