Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/concepts/stateless/stateless.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <xref:ModelContextProtocol.Client.McpClientOptions.DiscoverProbeTimeout>.

The era is cached per <xref:ModelContextProtocol.Client.IClientTransport> instance, so the probe cost is paid only on the first connect.

**Opting out of fallback.** Pin <xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion> 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 <xref:ModelContextProtocol.McpException> 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.
Expand Down
10 changes: 7 additions & 3 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 :
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonRpcResponse>(await SendAndReceiveAsync(discover, ct));
Assert.Null(_server.NegotiatedProtocolVersion);

Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct));
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion);
}

[Fact]
public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterChange()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<int>());
if (probeVersion == McpProtocolVersions.July2026ProtocolVersion)
{
Assert.NotNull(discover["result"]);
}
else
{
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue<int>());
}

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();
Expand All @@ -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"]);

Expand All @@ -210,5 +225,26 @@ await SendAsync(
var call = await ReadAsync();
Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
}

[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<IOptions<McpServerOptions>>().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<int>());
}
}
#endif