Skip to content

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

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

STiFLeR7 wants to merge 6 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.

1 issue 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/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
Comment thread src/mcp/client/client.py
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>

Comment thread tests/client/test_session_group.py
…mode connect

cubic's review found two real gaps in the protocol_version_override feature:

- Client(server, mode="auto", protocol_version_override=...) against an
  in-process Server/MCPServer failed to connect. negotiate_auto's override
  path calls session.initialize() directly, but _connect_inproc picked the
  handshake-less DirectDispatcher for every non-"legacy" mode, so there was
  no JSON-RPC dispatcher for initialize() to run on. _build_session now
  collapses this combination to the legacy connector.
- protocol_version_override accepted any string uncritically: a modern
  version silently misbehaved instead of being rejected, and setting it
  alongside a version-pinned mode was silently ignored rather than erroring.
  Both are now validated in __post_init__, mirroring the existing mode check.

Also pinned the untested default (None) branch's exact initialize() call
shape in test_session_group.py, per the same review.
@STiFLeR7

Copy link
Copy Markdown
Author

Fixed cubic's P1 (in-proc auto-mode + override never connected) and P2 (no validation on the override) findings, plus the test-coverage gap. Left the elicitation-capability-gating note unaddressed pending confirmation it applies across handshake-era sub-versions.

@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.

1 issue found across 3 files (changes from recent commits).

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:395">
P2: The `hint` expression's `else ""` branch executes only when `protocol_version_override` is in neither `HANDSHAKE_PROTOCOL_VERSIONS` nor `MODERN_PROTOCOL_VERSIONS`; every test passes either `"2024-11-05"` or `LATEST_MODERN_VERSION`, so that branch is uncovered and the repo's 100% branch-coverage CI (`fail_under = 100`, `branch = true`) will fail. Add a test passing an invalid non-modern override (e.g. `"1999-01-01"`) that asserts the same `ValueError`, exercising the empty-hint arm.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/mcp/client/client.py

if self.protocol_version_override is not None:
if self.protocol_version_override not in HANDSHAKE_PROTOCOL_VERSIONS:
hint = (

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: The hint expression's else "" branch executes only when protocol_version_override is in neither HANDSHAKE_PROTOCOL_VERSIONS nor MODERN_PROTOCOL_VERSIONS; every test passes either "2024-11-05" or LATEST_MODERN_VERSION, so that branch is uncovered and the repo's 100% branch-coverage CI (fail_under = 100, branch = true) will fail. Add a test passing an invalid non-modern override (e.g. "1999-01-01") that asserts the same ValueError, exercising the empty-hint arm.

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 395:

<comment>The `hint` expression's `else ""` branch executes only when `protocol_version_override` is in neither `HANDSHAKE_PROTOCOL_VERSIONS` nor `MODERN_PROTOCOL_VERSIONS`; every test passes either `"2024-11-05"` or `LATEST_MODERN_VERSION`, so that branch is uncovered and the repo's 100% branch-coverage CI (`fail_under = 100`, `branch = true`) will fail. Add a test passing an invalid non-modern override (e.g. `"1999-01-01"`) that asserts the same `ValueError`, exercising the empty-hint arm.</comment>

<file context>
@@ -385,6 +390,23 @@ def __post_init__(self) -> None:
 
+        if self.protocol_version_override is not None:
+            if self.protocol_version_override not in HANDSHAKE_PROTOCOL_VERSIONS:
+                hint = (
+                    f" ({self.protocol_version_override!r} is a modern version; mode='auto' already negotiates it)"
+                    if self.protocol_version_override in MODERN_PROTOCOL_VERSIONS
</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