Skip to content

feat: add protocol version override support for client session initialization - #2652

Open
STiFLeR7 wants to merge 5 commits into
modelcontextprotocol:mainfrom
STiFLeR7:feat/client-protocol-version-override
Open

STiFLeR7 wants to merge 5 commits into
modelcontextprotocol:mainfrom
STiFLeR7:feat/client-protocol-version-override

Conversation

@STiFLeR7

Copy link
Copy Markdown

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

  • Updated ClientSession.initialize(...) to accept a custom protocol_version.
  • Added protocol_version field to the high-level Client dataclass.
  • Added protocol_version field to ClientSessionParameters in ClientSessionGroup.
  • Implemented corresponding unit and integration tests.

@STiFLeR7
STiFLeR7 force-pushed the feat/client-protocol-version-override branch from c9cc22a to bc76e74 Compare May 22, 2026 04:56
@STiFLeR7
STiFLeR7 force-pushed the feat/client-protocol-version-override branch 2 times, most recently from 9b0c1b1 to 3791666 Compare June 26, 2026 09:46
@STiFLeR7
STiFLeR7 force-pushed the feat/client-protocol-version-override branch from 3791666 to 3902778 Compare July 6, 2026 04:51

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 8 files

Re-trigger cubic

@keeltrace

This comment was marked as spam.

@STiFLeR7

Copy link
Copy Markdown
Author

Confirmed and fixed in f55e795a. You're right: negotiate_auto's successful-discovery path (session.adopt(result); return) never consulted protocol_version — it was only threaded into the two initialize() fallback call sites. Traced the exact control flow to confirm before changing anything.

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 server/discover. So negotiate_auto now short-circuits at the top — if protocol_version is set, it skips the probe entirely and goes straight to the legacy handshake at that version. The two now-unreachable inline branches that used to pass protocol_version to the fallback initialize() calls collapsed back to their original unconditional form, since the override is always None by the time the loop runs.

Added:

  • A unit test on negotiate_auto proving the probe is skipped even when the stub's discover script would otherwise return a valid modern result (test_a_protocol_version_override_skips_discovery_and_forces_the_legacy_handshake).
  • An e2e test over a real streamable-HTTP server with mode="auto" (default) + override, asserting initialize is the only method sent and server/discover never is (test_auto_mode_with_a_protocol_version_override_skips_discover_and_initializes), plus its requirements-manifest entry.

Both fail on the pre-fix code and pass after (verified via git stash). Full suite, ruff, and pyright are clean; the only failures are 31 pre-existing ones unrelated to this file (confirmed identical with the fix stashed out).

Separate, smaller point from your review I did not change: the arbitrary-custom-version consistency gap (ClientSession.initialize() still rejects any server-echoed version outside HANDSHAKE_PROTOCOL_VERSIONS) is real but out of scope for this fix — happy to open a follow-up if that's wanted.

One more thing surfaced while testing this end-to-end: for an in-process Server/MCPServer target, Client(server, protocol_version_override=...) with the default mode="auto" currently hits a hard MCPError: Method not found rather than silently dropping the override — _connect_inproc's non-legacy connector wires up a DirectDispatcher peer (modern_on_request) that never implements the legacy initialize RPC at all, so negotiate_auto's new short-circuit has nothing to call. That's a separate, pre-existing connector-selection gap (mode picks the connector before protocol_version_override is known), unrelated to this bug and outside what I'm comfortable deciding unilaterally here. Flagging in case it's worth its own issue — happy to file one if useful.

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.
@STiFLeR7
STiFLeR7 force-pushed the feat/client-protocol-version-override branch from f55e795 to dce39ec Compare September 11, 2026 04:57
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.
@STiFLeR7

Copy link
Copy Markdown
Author

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/mcp/client/client.py
await session.initialize()
elif self.mode == "auto":
await negotiate_auto(session)
await negotiate_auto(session, protocol_version=self.protocol_version_override)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread src/mcp/client/client.py
derived)."""

_entered: bool = field(init=False, default=False)
protocol_version_override: str | None = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread src/mcp/client/session.py
# 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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants