Skip to content

Commit 3902778

Browse files
committed
feat: add protocol version override support for client session initialization
1 parent 53117cb commit 3902778

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
@@ -44,7 +44,7 @@ def _parse_supported(data: Any) -> list[str] | None:
4444
return None
4545

4646

47-
async def negotiate_auto(session: ClientSession) -> None:
47+
async def negotiate_auto(session: ClientSession, protocol_version: str | None = None) -> None:
4848
"""Drive the ``mode='auto'`` connect-time policy on ``session``.
4949
5050
Probes ``server/discover`` once (twice if the server names a mutual
@@ -73,7 +73,10 @@ async def negotiate_auto(session: ClientSession) -> None:
7373
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
7474
raise # server is modern-only and disjoint — real incompatibility
7575
try:
76-
await session.initialize() # every other rpc-error → legacy (the denylist)
76+
if protocol_version is not None:
77+
await session.initialize(protocol_version=protocol_version)
78+
else:
79+
await session.initialize() # every other rpc-error → legacy (the denylist)
7780
except MCPError as handshake_exc:
7881
if handshake_exc.code != UNSUPPORTED_PROTOCOL_VERSION or attempt != 0:
7982
raise
@@ -94,7 +97,10 @@ async def negotiate_auto(session: ClientSession) -> None:
9497
try:
9598
result = types.DiscoverResult.model_validate(raw)
9699
except ValidationError:
97-
await session.initialize() # unparseable result → not modern evidence
100+
if protocol_version is not None:
101+
await session.initialize(protocol_version=protocol_version)
102+
else:
103+
await session.initialize() # unparseable result → not modern evidence
98104
return
99105
session.adopt(result)
100106
return

src/mcp/client/client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ async def main():
354354
`target_id` when the server is not a URL (no identity can be derived)."""
355355

356356
_entered: bool = field(init=False, default=False)
357+
protocol_version_override: str | None = None
358+
"""The protocol version to request during initialization. Defaults to the latest version."""
357359
_session: ClientSession | None = field(init=False, default=None)
358360
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
359361
_connect: _Connector = field(init=False, repr=False, compare=False)
@@ -439,9 +441,12 @@ async def __aenter__(self) -> Client:
439441
session = await exit_stack.enter_async_context(session)
440442

441443
if self.mode == "legacy":
442-
await session.initialize()
444+
if self.protocol_version_override is not None:
445+
await session.initialize(protocol_version=self.protocol_version_override)
446+
else:
447+
await session.initialize()
443448
elif self.mode == "auto":
444-
await negotiate_auto(session)
449+
await negotiate_auto(session, protocol_version=self.protocol_version_override)
445450
else:
446451
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
447452

src/mcp/client/session.py

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

553-
async def initialize(self) -> types.InitializeResult:
553+
async def initialize(self, protocol_version: str = LATEST_HANDSHAKE_VERSION) -> types.InitializeResult:
554554
if self._initialize_result is not None:
555555
return self._initialize_result
556556
result = await self.send_request(
557557
types.InitializeRequest(
558558
params=types.InitializeRequestParams(
559-
protocol_version=LATEST_HANDSHAKE_VERSION,
560-
# The handshake negotiates only legacy versions, where no claim is active.
561-
capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION),
559+
protocol_version=protocol_version,
560+
capabilities=self._build_capabilities(protocol_version),
562561
client_info=self._client_info,
563562
),
564563
),

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
@@ -128,6 +128,13 @@ async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
128128
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
129129

130130

131+
async def test_client_custom_protocol_version(app: MCPServer):
132+
"""Test that the client negotiates a custom protocol version when configured."""
133+
async with Client(app, mode="legacy", protocol_version_override="2024-11-05") as client:
134+
assert client.protocol_version == "2024-11-05"
135+
assert client.server_info.name == "test"
136+
137+
131138
async def test_client_with_simple_server(simple_server: Server):
132139
"""Test that from_server works with a basic Server instance."""
133140
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]:
@@ -319,3 +321,20 @@ def test_parse_supported_returns_none_for_anything_not_shaped_like_the_spec_erro
319321
"""`_parse_supported` returns the `supported` list when `error.data` validates as
320322
`UnsupportedProtocolVersionErrorData`, and `None` otherwise — never raises."""
321323
assert _parse_supported(data) == expected
324+
325+
326+
async def test_negotiate_auto_mcp_error_with_custom_protocol_version() -> None:
327+
"""Test that negotiate_auto initializes with a custom protocol version when discover returns an MCPError."""
328+
session = _StubSession(MCPError(code=METHOD_NOT_FOUND, message="nope"))
329+
await _negotiate(session, protocol_version="2024-11-05")
330+
assert session.initialized
331+
assert session.initialize_version == "2024-11-05"
332+
333+
334+
async def test_negotiate_auto_validation_error_with_custom_protocol_version() -> None:
335+
"""Test that negotiate_auto initializes with a custom protocol version when discover returns unparseable result."""
336+
session = _StubSession({"not": "a discover result"})
337+
await _negotiate(session, protocol_version="2024-11-05")
338+
assert session.initialized
339+
assert session.initialize_version == "2024-11-05"
340+

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)