Skip to content

Commit 04da5c8

Browse files
halter73Copilot
andcommitted
Add fallback negotiation, MinProtocolVersion, server-side _meta protocolVersion validation, and raw stream conformance tests
Phase 2-5 of the draft (2026-07-28) rollout: - McpClientImpl.ConnectAsync: tighten the draft probe fallback to match the spec's stdio rules. Apply a 5-second probe timeout (bounded by InitializationTimeout), broaden the catch to treat any McpProtocolException OR probe-timeout as a legacy-server signal, and special-case the two modern-server JSON-RPC errors (-32004 retries with supported[]; -32003 surfaces). Honor MinProtocolVersion before falling back to legacy initialize. - McpClientOptions: add MinProtocolVersion public string? with XML docs. Setting this to McpSessionHandler.DraftProtocolVersion disables the automatic legacy fallback. - McpServerImpl.CreateDraftStateSyncFilter: reject any per-request _meta/io.modelcontextprotocol/protocolVersion that is not in SupportedProtocolVersions with UnsupportedProtocolVersionException (-32004). The HTTP handler already validated the MCP-Protocol-Version header; this closes the corresponding gap for stdio/Stream and for HTTP bodies where the header is absent. - HttpTaskIntegrationTests: Tasks pin per-session state into the in-memory store, so the tests require stateful HTTP. With Stateless=true the default after Phase 1, the tests would deadlock on the second request because the task ID wasn't visible to the new stateless server invocation. Opt back into stateful mode with WithHttpTransport(options => options.Stateless = false). - New tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs: drive McpServer directly via paired Pipe streams without going through McpClient. Hand-writes JSON-RPC messages and asserts on the exact bytes the server emits. Covers server/discover -> supportedVersions[], draft tools/call without initialize, -32004 with data.supported on unsupported version, legacy initialize on the same dual-era server, and a mixed Discover->Initialize->ToolsCall sequence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cb23930 commit 04da5c8

5 files changed

Lines changed: 285 additions & 21 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -686,27 +686,55 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
686686
DiscoverResult? discoverResult = null;
687687
bool fallbackToLegacy = false;
688688
IList<string>? serverSupportedVersions = null;
689+
690+
// Apply a probe timeout so dual-era clients don't block forever waiting for a
691+
// legacy server that silently drops unknown methods (per stdio.mdx fallback rules).
692+
// The probe timeout is bounded by InitializationTimeout, but we cap it at 5s so we
693+
// can quickly fall back when a server isn't going to respond.
694+
var probeTimeout = TimeSpan.FromSeconds(5);
695+
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token);
696+
if (_options.InitializationTimeout > probeTimeout)
697+
{
698+
probeCts.CancelAfter(probeTimeout);
699+
}
700+
689701
try
690702
{
691703
discoverResult = await SendRequestAsync(
692704
RequestMethods.ServerDiscover,
693705
new DiscoverRequestParams(),
694706
McpJsonUtilities.JsonContext.Default.DiscoverRequestParams,
695707
McpJsonUtilities.JsonContext.Default.DiscoverResult,
696-
cancellationToken: initializationCts.Token).ConfigureAwait(false);
708+
cancellationToken: probeCts.Token).ConfigureAwait(false);
697709
}
698-
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound)
710+
catch (UnsupportedProtocolVersionException ex)
699711
{
700-
// Server doesn't implement server/discover (likely a legacy server). Fall back
701-
// to the legacy initialize handshake per SEP-2575 §"Supporting Multiple Versions".
712+
// Spec-recognized modern-server signal: -32004 with data.supported[]. The server is
713+
// modern but doesn't speak our preferred version. Retry with a mutually supported
714+
// version from data.supported[] instead of falling back to legacy initialize.
702715
fallbackToLegacy = true;
716+
serverSupportedVersions = (IList<string>)ex.Supported;
703717
}
704-
catch (UnsupportedProtocolVersionException ex)
718+
catch (MissingRequiredClientCapabilityException)
719+
{
720+
// Spec-recognized modern-server signal: -32003. The server is modern but rejected
721+
// our capability set. Surface as-is (no fallback): the user must add capabilities.
722+
throw;
723+
}
724+
catch (McpProtocolException)
705725
{
706-
// Server rejected the experimental protocol version at the transport layer.
707-
// Per SEP-2575, fall back to a mutually-supported version reported in ex.Supported.
726+
// Any other JSON-RPC error from the probe indicates a legacy server (e.g.,
727+
// -32601 MethodNotFound, -32602 InvalidParams from a server confused by _meta,
728+
// -32700 ParseError from a server that can't handle our payload shape).
729+
// Per the spec's stdio fallback rules, treat all non-modern errors as a
730+
// legacy-server signal and fall back to the initialize handshake.
731+
fallbackToLegacy = true;
732+
}
733+
catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested)
734+
{
735+
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
736+
// response within a reasonable timeout means the server is legacy. Fall back.
708737
fallbackToLegacy = true;
709-
serverSupportedVersions = (IList<string>)ex.Supported;
710738
}
711739

712740
if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion))
@@ -730,6 +758,18 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
730758
.FirstOrDefault()
731759
?? McpSessionHandler.LatestProtocolVersion;
732760

761+
// Honor MinProtocolVersion: refuse to fall back below the configured minimum.
762+
// String.Compare is the spec's prescribed ordering for ISO-8601 date-based versions.
763+
if (_options.MinProtocolVersion is { } minVersion &&
764+
StringComparer.Ordinal.Compare(fallbackVersion, minVersion) < 0)
765+
{
766+
throw new McpException(
767+
$"Server does not support the configured minimum protocol version '{minVersion}'. " +
768+
(serverSupportedVersions is null
769+
? "The server appears to be a legacy server that requires the deprecated initialize handshake."
770+
: $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."));
771+
}
772+
733773
await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false);
734774
}
735775
else

src/ModelContextProtocol.Core/Client/McpClientOptions.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,37 @@ public sealed class McpClientOptions
4848
/// </remarks>
4949
public string? ProtocolVersion { get; set; }
5050

51+
/// <summary>
52+
/// Gets or sets the minimum protocol version the client will accept during version negotiation.
53+
/// </summary>
54+
/// <remarks>
55+
/// <para>
56+
/// When negotiating with a server that advertises multiple supported versions, or when falling back
57+
/// to a legacy server, the client will refuse any version older than this minimum and surface an
58+
/// <see cref="McpException"/> instead.
59+
/// </para>
60+
/// <para>
61+
/// This is useful when the client requires features (such as the draft revision's removal of the
62+
/// <c>initialize</c> handshake or <c>Mcp-Session-Id</c>) that are not available in older protocol
63+
/// revisions. Setting this to <see cref="McpSessionHandler.DraftProtocolVersion"/> disables the
64+
/// automatic legacy-server fallback that otherwise switches to the <c>initialize</c> handshake.
65+
/// </para>
66+
/// <para>
67+
/// If <see langword="null"/> (the default), the client falls back to any version the server
68+
/// advertises, including legacy versions such as 2025-11-25.
69+
/// </para>
70+
/// <example>
71+
/// <code>
72+
/// var clientOptions = new McpClientOptions
73+
/// {
74+
/// ProtocolVersion = McpSessionHandler.DraftProtocolVersion,
75+
/// MinProtocolVersion = McpSessionHandler.DraftProtocolVersion,
76+
/// };
77+
/// </code>
78+
/// </example>
79+
/// </remarks>
80+
public string? MinProtocolVersion { get; set; }
81+
5182
/// <summary>
5283
/// Gets or sets a timeout for the client-server initialization handshake sequence.
5384
/// </summary>

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ private static JsonRpcMessageFilter ComposeFilters(JsonRpcMessageFilter outer, J
172172
/// <summary>
173173
/// Builds an incoming message filter that, for every JSON-RPC request, synchronizes server-side state
174174
/// (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientCapabilities"/>, <see cref="_clientInfo"/>)
175-
/// from the per-request <c>_meta</c> values projected onto <see cref="JsonRpcMessageContext"/>.
175+
/// from the per-request <c>_meta</c> values projected onto <see cref="JsonRpcMessageContext"/> and
176+
/// validates the per-request protocol version.
176177
/// </summary>
177178
/// <remarks>
178179
/// Under the draft protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so these values
@@ -187,11 +188,23 @@ private JsonRpcMessageFilter CreateDraftStateSyncFilter()
187188
{
188189
bool endpointNameNeedsRefresh = false;
189190

190-
if (context.ProtocolVersion is { } protocolVersion &&
191-
!string.Equals(_negotiatedProtocolVersion, protocolVersion, StringComparison.Ordinal))
191+
if (context.ProtocolVersion is { } protocolVersion)
192192
{
193-
_negotiatedProtocolVersion = protocolVersion;
194-
_sessionHandler.NegotiatedProtocolVersion = protocolVersion;
193+
// Per SEP-2575, the server MUST reject any request whose per-request
194+
// _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions
195+
// with an UnsupportedProtocolVersionError (-32004) carrying the supported list.
196+
if (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion))
197+
{
198+
throw new UnsupportedProtocolVersionException(
199+
requested: protocolVersion,
200+
supported: McpSessionHandler.SupportedProtocolVersions);
201+
}
202+
203+
if (!string.Equals(_negotiatedProtocolVersion, protocolVersion, StringComparison.Ordinal))
204+
{
205+
_negotiatedProtocolVersion = protocolVersion;
206+
_sessionHandler.NegotiatedProtocolVersion = protocolVersion;
207+
}
195208
}
196209

197210
if (context.ClientCapabilities is { } clientCapabilities)

tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public async Task CallToolAsTask_ReturnsTask_WhenServerSupportsTasksAsync()
4848
{
4949
options.TaskStore = taskStore;
5050
})
51-
.WithHttpTransport()
51+
.WithHttpTransport(options => options.Stateless = false)
5252
.WithTools<LongRunningTools>();
5353

5454
await using var app = Builder.Build();
@@ -81,7 +81,7 @@ public async Task GetTaskAsync_ReturnsTaskStatus_WhenTaskExistsAsync()
8181
{
8282
options.TaskStore = taskStore;
8383
})
84-
.WithHttpTransport()
84+
.WithHttpTransport(options => options.Stateless = false)
8585
.WithTools<LongRunningTools>();
8686

8787
await using var app = Builder.Build();
@@ -121,7 +121,7 @@ public async Task ListTasksAsync_ReturnsTasks_WhenTasksExistAsync()
121121
{
122122
options.TaskStore = taskStore;
123123
})
124-
.WithHttpTransport()
124+
.WithHttpTransport(options => options.Stateless = false)
125125
.WithTools<LongRunningTools>();
126126

127127
await using var app = Builder.Build();
@@ -160,7 +160,7 @@ public async Task CancelTaskAsync_CancelsTask_WhenTaskIsRunningAsync()
160160
{
161161
options.TaskStore = taskStore;
162162
})
163-
.WithHttpTransport()
163+
.WithHttpTransport(options => options.Stateless = false)
164164
.WithTools<LongRunningTools>();
165165

166166
await using var app = Builder.Build();
@@ -199,7 +199,7 @@ public async Task GetTaskResultAsync_ReturnsResult_WhenTaskCompletesAsync()
199199
{
200200
options.TaskStore = taskStore;
201201
})
202-
.WithHttpTransport()
202+
.WithHttpTransport(options => options.Stateless = false)
203203
.WithTools<LongRunningTools>();
204204

205205
await using var app = Builder.Build();
@@ -240,7 +240,7 @@ public async Task TasksIsolated_BetweenSessions_WhenMultipleClientsConnectAsync(
240240
{
241241
options.TaskStore = taskStore;
242242
})
243-
.WithHttpTransport()
243+
.WithHttpTransport(options => options.Stateless = false)
244244
.WithTools<LongRunningTools>();
245245

246246
await using var app = Builder.Build();
@@ -279,7 +279,7 @@ public async Task ServerCapabilities_IncludesTasks_WhenTaskStoreConfiguredAsync(
279279
{
280280
options.TaskStore = taskStore;
281281
})
282-
.WithHttpTransport()
282+
.WithHttpTransport(options => options.Stateless = false)
283283
.WithTools<LongRunningTools>();
284284

285285
await using var app = Builder.Build();
@@ -302,7 +302,7 @@ public async Task ListTools_ShowsTaskSupport_WhenToolIsAsyncAsync()
302302
{
303303
options.TaskStore = taskStore;
304304
})
305-
.WithHttpTransport()
305+
.WithHttpTransport(options => options.Stateless = false)
306306
.WithTools<LongRunningTools>();
307307

308308
await using var app = Builder.Build();
@@ -340,3 +340,4 @@ public static string SyncTool([Description("Input message")] string message)
340340
}
341341
}
342342
}
343+

0 commit comments

Comments
 (0)