Conversation
c9cc22a to
bc76e74
Compare
9b0c1b1 to
3791666
Compare
3791666 to
3902778
Compare
This comment was marked as spam.
This comment was marked as spam.
|
Confirmed and fixed in Went with your option 1: since the PR description's own stated intent is "enabling clients to negotiate older or custom protocol versions," an override has to actually win, not depend on whether the server happens to answer Added:
Both fail on the pre-fix code and pass after (verified via Separate, smaller point from your review I did not change: the arbitrary-custom-version consistency gap ( One more thing surfaced while testing this end-to-end: for an in-process |
negotiate_auto only consulted protocol_version in its initialize() fallback calls, so mode="auto" (the default) silently dropped the override whenever the server/discover probe succeeded first - the override only ever took effect when the probe failed. Since the whole point of protocol_version_override is to let a caller pin an older or custom protocol version, an override must always win: when set, skip the discover probe entirely and go straight to the legacy handshake at that version. Regression tests: a unit test on negotiate_auto proving the probe is skipped even when the stub's discover script would otherwise succeed, and an e2e test over a real streamable-HTTP server (mode="auto" + override) proving only `initialize` is sent and `server/discover` never is. Reported by a static review pass on this PR; verified independently by tracing the actual control flow before applying this fix.
f55e795 to
dce39ec
Compare
RequestResponder and mcp.shared.session were removed upstream since this test was written; message_handler callbacks now receive IncomingMessage (ServerNotification | Exception), matching every other message_handler in this file.
|
Rebased onto main (89 commits) and fixed a genuine lint failure the rebase surfaced: RequestResponder and mcp.shared.session were removed upstream since this test was added. Updated message_handler to the current IncomingMessage type, matching every other handler in this file. Ruff, pyright, and all 92 tests in the file pass. |
pyright's pre-commit hook flagged reportOptionalMemberAccess on client.server_info.name in two new protocol_version_override tests. server_info is Implementation | None; every other call site in this file already asserts not-None first.
There was a problem hiding this comment.
4 issues found across 10 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/client.py">
<violation number="1" location="src/mcp/client/client.py:369">
P2: `protocol_version_override` is accepted without validation and silently changes meaning by mode: a modern version string (e.g. `2026-07-28`) passed to `mode='auto'` is sent through the legacy `initialize` handshake (see `negotiate_auto`'s new pin branch), which modern-only servers reject even though they support that version, while in a `mode=<version>` pin the override is silently ignored. Validate it in `__post_init__` against `HANDSHAKE_PROTOCOL_VERSIONS` (with a hint, mirroring the existing `mode` validation) so legacy-only semantics are enforced at construction instead of failing at connect with a bare -32022.</violation>
<violation number="2" location="src/mcp/client/client.py:465">
P1: `mode="auto"` with an override now calls `initialize`, but in-process `Server`/`MCPServer` connections still select the direct modern dispatcher for every non-`legacy` mode. Make this combination use the legacy connector, or reject it before connecting, so `Client(server, protocol_version_override=...)` does not fail during `__aenter__`.</violation>
</file>
<file name="tests/client/test_session_group.py">
<violation number="1" location="tests/client/test_session_group.py:438">
P3: This test pins only the override branch of the new conditional. The default (None) branch is covered only by the parameterized test's `assert_awaited_once()`, which does not verify call arguments — so a regression that passed `protocol_version=None` instead of omitting it would go undetected. Pin the default shape: assert `initialize()` was awaited with no `protocol_version` argument when `ClientSessionParameters()` is used.</violation>
</file>
<file name="src/mcp/client/session.py">
<violation number="1" location="src/mcp/client/session.py:658">
P2: Older protocol overrides still advertise form and URL elicitation capabilities when the callback is configured. Gate each capability by the requested protocol version; otherwise the server can use an advertisement for features unavailable in the selected protocol.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| await session.initialize() | ||
| elif self.mode == "auto": | ||
| await negotiate_auto(session) | ||
| await negotiate_auto(session, protocol_version=self.protocol_version_override) |
There was a problem hiding this comment.
P1: mode="auto" with an override now calls initialize, but in-process Server/MCPServer connections still select the direct modern dispatcher for every non-legacy mode. Make this combination use the legacy connector, or reject it before connecting, so Client(server, protocol_version_override=...) does not fail during __aenter__.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/client.py, line 465:
<comment>`mode="auto"` with an override now calls `initialize`, but in-process `Server`/`MCPServer` connections still select the direct modern dispatcher for every non-`legacy` mode. Make this combination use the legacy connector, or reject it before connecting, so `Client(server, protocol_version_override=...)` does not fail during `__aenter__`.</comment>
<file context>
@@ -455,9 +457,12 @@ async def __aenter__(self) -> Client:
+ await session.initialize()
elif self.mode == "auto":
- await negotiate_auto(session)
+ await negotiate_auto(session, protocol_version=self.protocol_version_override)
else:
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
</file context>
| derived).""" | ||
|
|
||
| _entered: bool = field(init=False, default=False) | ||
| protocol_version_override: str | None = None |
There was a problem hiding this comment.
P2: protocol_version_override is accepted without validation and silently changes meaning by mode: a modern version string (e.g. 2026-07-28) passed to mode='auto' is sent through the legacy initialize handshake (see negotiate_auto's new pin branch), which modern-only servers reject even though they support that version, while in a mode=<version> pin the override is silently ignored. Validate it in __post_init__ against HANDSHAKE_PROTOCOL_VERSIONS (with a hint, mirroring the existing mode validation) so legacy-only semantics are enforced at construction instead of failing at connect with a bare -32022.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/client.py, line 369:
<comment>`protocol_version_override` is accepted without validation and silently changes meaning by mode: a modern version string (e.g. `2026-07-28`) passed to `mode='auto'` is sent through the legacy `initialize` handshake (see `negotiate_auto`'s new pin branch), which modern-only servers reject even though they support that version, while in a `mode=<version>` pin the override is silently ignored. Validate it in `__post_init__` against `HANDSHAKE_PROTOCOL_VERSIONS` (with a hint, mirroring the existing `mode` validation) so legacy-only semantics are enforced at construction instead of failing at connect with a bare -32022.</comment>
<file context>
@@ -366,6 +366,8 @@ async def main():
derived)."""
_entered: bool = field(init=False, default=False)
+ protocol_version_override: str | None = None
+ """The protocol version to request during initialization. Defaults to the latest version."""
_session: ClientSession | None = field(init=False, default=None)
</file context>
| # The handshake negotiates only legacy versions, where no claim is active. | ||
| capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION), | ||
| protocol_version=protocol_version, | ||
| capabilities=self._build_capabilities(protocol_version), |
There was a problem hiding this comment.
P2: Older protocol overrides still advertise form and URL elicitation capabilities when the callback is configured. Gate each capability by the requested protocol version; otherwise the server can use an advertisement for features unavailable in the selected protocol.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/session.py, line 658:
<comment>Older protocol overrides still advertise form and URL elicitation capabilities when the callback is configured. Gate each capability by the requested protocol version; otherwise the server can use an advertisement for features unavailable in the selected protocol.</comment>
<file context>
@@ -648,15 +648,14 @@ def _build_capabilities(self, version: str) -> types.ClientCapabilities:
- # The handshake negotiates only legacy versions, where no claim is active.
- capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION),
+ protocol_version=protocol_version,
+ capabilities=self._build_capabilities(protocol_version),
client_info=self._client_info,
),
</file context>
| group._exit_stack = stack | ||
| await group._establish_session(server_params, session_params) | ||
|
|
||
| mock_entered_session.initialize.assert_awaited_once_with(protocol_version="2024-11-05") |
There was a problem hiding this comment.
P3: This test pins only the override branch of the new conditional. The default (None) branch is covered only by the parameterized test's assert_awaited_once(), which does not verify call arguments — so a regression that passed protocol_version=None instead of omitting it would go undetected. Pin the default shape: assert initialize() was awaited with no protocol_version argument when ClientSessionParameters() is used.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/client/test_session_group.py, line 438:
<comment>This test pins only the override branch of the new conditional. The default (None) branch is covered only by the parameterized test's `assert_awaited_once()`, which does not verify call arguments — so a regression that passed `protocol_version=None` instead of omitting it would go undetected. Pin the default shape: assert `initialize()` was awaited with no `protocol_version` argument when `ClientSessionParameters()` is used.</comment>
<file context>
@@ -402,3 +402,37 @@ async def test_client_session_group_establish_session_parameterized(
+ group._exit_stack = stack
+ await group._establish_session(server_params, session_params)
+
+ mock_entered_session.initialize.assert_awaited_once_with(protocol_version="2024-11-05")
</file context>
Description
This PR adds support for client session protocol version overrides during initialization, enabling clients to negotiate older or custom protocol versions (e.g.
2024-11-05) with MCP servers.Changes
ClientSession.initialize(...)to accept a customprotocol_version.protocol_versionfield to the high-levelClientdataclass.protocol_versionfield toClientSessionParametersinClientSessionGroup.