diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index c393d3de0..85b292a4a 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -58,6 +58,8 @@ The `2026-07-28` protocol revision goes further than stateless mode: it removes - **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the `initialize` flow on the same endpoint. - **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported initialize-capable version; anything else — including a timeout — falls back to `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. +On a dual-era stdio server, discovery alone does not commit the connection to the modern protocol. If the client times out while the server is starting, its `initialize` fallback can still succeed even when the server processes the discovery probe first. Once the server processes an ordinary modern request, it rejects a later `initialize`. To avoid falling back during slow startup, increase . + The era is cached per instance, so the probe cost is paid only on the first connect. **Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 76a65ed29..adf17a3e4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -282,7 +282,10 @@ transportProtocolVersion is not null || ProjectModernMetadata(request, meta!, metadataProtocolVersion); - if (!protocolVersionAlreadyEstablished) + // Discovery is a probe, not a commitment to use the modern protocol. A client may have + // timed out waiting for this response and already sent an initialize fallback on the same + // stream. Only an ordinary modern request should prevent that fallback. + if (!protocolVersionAlreadyEstablished && request.Method != RequestMethods.ServerDiscover) { SetNegotiatedProtocolVersion(metadataProtocolVersion); } @@ -389,7 +392,7 @@ private JsonRpcMessageFilter PrependServerInfoFilter(JsonRpcMessageFilter inner, private void ValidateInitializeRequestBoundary(JsonRpcRequest request) { // Modern revisions removed initialize. An established modern session is authoritative even when - // the new request has no version header; a failed discovery probe does not establish a version. + // the new request has no version header; a discovery probe does not establish a version. string? modernProtocolVersion = McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion) ? request.Context!.ProtocolVersion : McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ? _negotiatedProtocolVersion : @@ -946,7 +949,8 @@ private async Task SendListChangedNotificationAsync(string notificationMethod) // Initialize-handshake clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. // subscriptions/listen is a SEP-2575 feature, so clients on the 2026-07-28 or later revision instead get // a fan-out limited to the notification types they explicitly subscribed to. - if (!IsJuly2026OrLaterProtocol()) + // An uncommitted discovery probe is not an initialized legacy client. + if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion)) { await this.SendNotificationAsync(notificationMethod).ConfigureAwait(false); return; diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index b4bf440ac..8c7b7ca33 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -50,6 +50,24 @@ public NegotiatedProtocolVersionTests(ITestOutputHelper testOutputHelper) _reader = new StreamReader(_serverToClient.Reader.AsStream()); } + [Fact] + public async Task ServerDiscover_DoesNotEstablishProtocolVersion_ButSubsequentRequestDoes() + { + var ct = TestContext.Current.CancellationToken; + var discover = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject { ["_meta"] = PerRequestMetadata() }, + }; + + Assert.IsType(await SendAndReceiveAsync(discover, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); + } + [Fact] public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterChange() { diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index a41adfc22..cb6d37240 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -1,6 +1,7 @@ #if !NET472 using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; @@ -165,15 +166,23 @@ public async Task InitializeHandshake_StillWorks_OnJuly2026ProtocolDefaultServer Assert.Null(result["_meta"]?[MetaKeys.ServerInfo]); } - [Fact] - public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed() + [Theory] + [InlineData("9999-99-99")] + [InlineData(McpProtocolVersions.July2026ProtocolVersion)] + public async Task DiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed(string probeVersion) { - // Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails. - // This is the sequence a permissive client emits against an unknown server: the failed probe never - // establishes a version, so initialize is still available. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"); + // A client can time out before receiving even a successful discovery response. Process the probe + // first to deterministically exercise the ordering that used to reject its initialize fallback. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment(probeVersion) + "}}"); var discover = await ReadAsync(); - Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue()); + if (probeVersion == McpProtocolVersions.July2026ProtocolVersion) + { + Assert.NotNull(discover["result"]); + } + else + { + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue()); + } await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); @@ -188,14 +197,20 @@ public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCa } [Theory] - [InlineData("tools/list")] - [InlineData("server/discover")] - public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod) + [InlineData(false)] + [InlineData(true)] + public async Task Initialize_AfterEstablishedModernRequest_IsRejected(bool discoverFirst) { + if (discoverFirst) + { + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":0,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + Assert.NotNull((await ReadAsync())["result"]); + } + // A modern request locks the session's protocol version. initialize declares nothing on stdio, so // without this gate it would force the session back to a handshake version and make the modern // _meta on in-flight requests opaque. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); var established = await ReadAsync(); Assert.NotNull(established["result"]); @@ -210,5 +225,26 @@ await SendAsync( var call = await ReadAsync(); Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue()); } + + [Fact] + public async Task DiscoverProbe_Then_ToolListChanged_IsNotBroadcast_BeforeProtocolCommits() + { + // An uncommitted discovery probe is not an initialized legacy client: the session-wide + // notifications/tools/list_changed broadcast those clients get must not fire for a session that + // has not yet negotiated any protocol version, even though the probe itself succeeded. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + Assert.NotNull((await ReadAsync())["result"]); + + var toolCollection = _services.GetRequiredService>().Value.ToolCollection!; + toolCollection.Add(McpServerTool.Create((string text) => $"added:{text}", new() { Name = "added" })); + + // The stream carries messages in order, so if the broadcast had fired it would be the next line + // read, ahead of this request's own response. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":2,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + July2026ProtocolMetaFragment() + "}}"); + var next = await ReadAsync(); + Assert.Equal(2, next["id"]!.GetValue()); + } } #endif