Skip to content

fix: server/discover probe must not commit the negotiated protocol version - #1956

Open
PrathmeshPawar2110 wants to merge 1 commit into
modelcontextprotocol:mainfrom
PrathmeshPawar2110:fix/1954-discovery-fallback
Open

PrathmeshPawar2110 wants to merge 1 commit into
modelcontextprotocol:mainfrom
PrathmeshPawar2110:fix/1954-discovery-fallback

Conversation

@PrathmeshPawar2110

Copy link
Copy Markdown

Problem

A dual-path stdio client sends a server/discover probe and, on timeout, falls back to initialize on the same connection. If the server processes the (still in-flight) probe first, SetNegotiatedProtocolVersion latched the session to 2026-07-28, and the subsequent initialize was then rejected:

McpProtocolException: Request failed (remote): Method 'initialize' is not available on protocol version '2026-07-28'. Use 'server/discover' and per-request metadata instead.

even though the client never actually saw the probe succeed (it had already timed out). If the race went the other way, the session silently downgraded instead, with no error at all.

Repro: a 2.3.0 stdio server that does ~6s of work before RunAsync, connected with default McpClientOptions. The outcome depends on thread-pool ordering.

Fix

A discovery probe is just that — a probe, not a commitment. Only an ordinary modern request (one actually relying on the negotiated version) should latch the session:

  • McpServerImpl.ReadRequestMetadata no longer calls SetNegotiatedProtocolVersion for a server/discover request when no version is established yet.
  • ValidateInitializeRequestBoundary's comment is updated to match: a discovery probe, successful or not, never establishes a version.
  • SendListChangedNotificationAsync's legacy-broadcast gate now checks McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) instead of IsJuly2026OrLaterProtocol(), so an uncommitted probe isn't treated as an initialize-handshake (legacy) client and doesn't get a blind session-wide broadcast it was never entitled to.

Documented the race and the DiscoverProbeTimeout workaround in docs/concepts/stateless/stateless.md.

Testing

  • NegotiatedProtocolVersionTests: a server/discover request leaves NegotiatedProtocolVersion null; a subsequent request still establishes it.
  • RawStreamConformanceTests: a successful (not just failed) discover probe no longer blocks a same-connection initialize fallback; Initialize_AfterEstablishedModernRequest_IsRejected now covers both tools/list and a prior discover probe as the establishing event; a new test confirms a tool list-changed notification isn't broadcast to a session that has only sent an uncommitted discover probe.
dotnet build
# Build succeeded, 0 Warning(s), 0 Error(s)

dotnet test tests/ModelContextProtocol.Tests/ --filter "FullyQualifiedName~RawStreamConformanceTests|FullyQualifiedName~NegotiatedProtocolVersionTests"
# net10.0: 42 passed
# net9.0:  42 passed
# net472:  33 passed (RawStreamConformanceTests is #if !NET472)

dotnet test tests/ModelContextProtocol.Tests/ -c Debug --framework net10.0
# 3308 passed, 17 failed (all StdioClientTransportTests.EscapesCliArgumentsCorrectly,
# a pre-existing failure caused by a space in this environment's checkout path,
# unrelated to this change), 10 skipped

Fixes #1954

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

…rsion

A dual-path stdio client sends a server/discover probe and, on timeout,
falls back to initialize on the same connection. If the server processes
the (still in-flight) probe first, SetNegotiatedProtocolVersion latched
the session to 2026-07-28, and the subsequent initialize was then
rejected with "Method 'initialize' is not available on protocol version
'2026-07-28'" even though the client never saw the probe succeed. If the
race went the other way, the session silently downgraded instead with
no error at all.

A discovery probe is just that — a probe, not a commitment. Only an
ordinary modern request (one actually relying on the negotiated version)
should latch the session:

- McpServerImpl.ReadRequestMetadata no longer calls
  SetNegotiatedProtocolVersion for a server/discover request when no
  version is established yet; ValidateInitializeRequestBoundary's
  comment is updated to match (a discovery probe, successful or not,
  never establishes a version).
- SendListChangedNotificationAsync's legacy-broadcast gate now checks
  McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion)
  instead of IsJuly2026OrLaterProtocol(), so an uncommitted probe isn't
  treated as an initialize-handshake (legacy) client and doesn't get a
  blind session-wide broadcast it was never entitled to.

Tests:
- NegotiatedProtocolVersionTests: a server/discover request leaves
  NegotiatedProtocolVersion null; a subsequent request still establishes it.
- RawStreamConformanceTests: a successful (not just failed) discover
  probe no longer blocks a same-connection initialize fallback;
  Initialize_AfterEstablishedModernRequest_IsRejected now covers both
  tools/list and a prior discover probe as the establishing event; a new
  test confirms a tool list-changed notification isn't broadcast to a
  session that has only sent an uncommitted discover probe.

Documented the race and the DiscoverProbeTimeout workaround in
docs/concepts/stateless/stateless.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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.

server/discover probe timeout falls back to initialize, which a slow 2.3.0 server then rejects (or silently downgrades)

1 participant