Skip to content

Commit 5845866

Browse files
halter73Copilot
andcommitted
Remove stateless check from ClientSupportsMrtr and flow protocol version header
ClientSupportsMrtr now purely reflects whether the client negotiated the MRTR protocol version, independent of server transport mode. The stateless guard is moved to the call site that gates the high-level await path (which requires storing continuations). In stateless mode, each request creates a new McpServerImpl that never sees the initialize handshake. The Mcp-Protocol-Version header is now flowed via JsonRpcMessageContext.ProtocolVersion so the MRTR wrapper can populate _negotiatedProtocolVersion, making IsMrtrSupported return true when the client sends the experimental protocol version header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 80417c0 commit 5845866

File tree

4 files changed

+79
-14
lines changed

4 files changed

+79
-14
lines changed

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -480,12 +480,25 @@ internal static string MakeNewSessionId()
480480
// Implementation for reading a JSON-RPC message from the request body
481481
var message = await context.Request.ReadFromJsonAsync(s_messageTypeInfo, context.RequestAborted);
482482

483-
if (context.User?.Identity?.IsAuthenticated == true && message is not null)
483+
if (message is not null)
484484
{
485-
message.Context = new()
485+
var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
486+
var isAuthenticated = context.User?.Identity?.IsAuthenticated == true;
487+
488+
if (isAuthenticated || !string.IsNullOrEmpty(protocolVersion))
486489
{
487-
User = context.User,
488-
};
490+
message.Context ??= new();
491+
492+
if (isAuthenticated)
493+
{
494+
message.Context.User = context.User;
495+
}
496+
497+
if (!string.IsNullOrEmpty(protocolVersion))
498+
{
499+
message.Context.ProtocolVersion = protocolVersion;
500+
}
501+
}
489502
}
490503

491504
return message;

src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,15 @@ public sealed class JsonRpcMessageContext
7474
/// </para>
7575
/// </remarks>
7676
public IDictionary<string, object?>? Items { get; set; }
77+
78+
/// <summary>
79+
/// Gets or sets the protocol version from the transport-level header (e.g. <c>Mcp-Protocol-Version</c>)
80+
/// that accompanied this JSON-RPC message.
81+
/// </summary>
82+
/// <remarks>
83+
/// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the <c>initialize</c>
84+
/// handshake because each request creates a new server instance. This property allows the transport layer
85+
/// to flow the protocol version header so the server can determine client capabilities.
86+
/// </remarks>
87+
public string? ProtocolVersion { get; set; }
7788
}

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,11 +1143,9 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
11431143
private partial void ReadResourceCompleted(string resourceUri);
11441144

11451145
/// <summary>
1146-
/// Checks whether the negotiated protocol version enables MRTR and the server
1147-
/// operates in a mode where MRTR continuations can be stored (i.e., not stateless).
1146+
/// Checks whether the negotiated protocol version enables MRTR.
11481147
/// </summary>
11491148
internal bool ClientSupportsMrtr() =>
1150-
_sessionTransport is not StreamableHttpServerTransport { Stateless: true } &&
11511149
_negotiatedProtocolVersion is not null &&
11521150
_negotiatedProtocolVersion == ServerOptions.ExperimentalProtocolVersion;
11531151

@@ -1177,6 +1175,15 @@ private void WrapHandlerWithMrtr(string method)
11771175

11781176
_requestHandlers[method] = async (request, cancellationToken) =>
11791177
{
1178+
// In stateless mode, each request creates a new server instance that never saw the
1179+
// initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the
1180+
// Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext.
1181+
if (_negotiatedProtocolVersion is null &&
1182+
request.Context?.ProtocolVersion is { } headerProtocolVersion)
1183+
{
1184+
_negotiatedProtocolVersion = headerProtocolVersion;
1185+
}
1186+
11801187
// Check for MRTR retry: if requestState is present, look up the continuation.
11811188
if (request.Params is JsonObject paramsObj &&
11821189
paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) &&
@@ -1217,8 +1224,9 @@ private void WrapHandlerWithMrtr(string method)
12171224
// high-level handlers that call ElicitAsync/SampleAsync.
12181225
}
12191226

1220-
// Not a retry, or a retry without a continuation - check if the client supports MRTR.
1221-
if (!ClientSupportsMrtr())
1227+
// Not a retry, or a retry without a continuation - check if the client supports MRTR
1228+
// and the server is stateful (the high-level await path requires storing continuations).
1229+
if (!ClientSupportsMrtr() || _sessionTransport is StreamableHttpServerTransport { Stateless: true })
12221230
{
12231231
return await InvokeWithIncompleteResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false);
12241232
}
@@ -1262,10 +1270,10 @@ private void WrapHandlerWithMrtr(string method)
12621270
}
12631271
catch (IncompleteResultException ex)
12641272
{
1265-
// In stateless mode, the server has no persistent session or negotiated protocol
1266-
// version, so it cannot determine client MRTR support. The tool handler has
1267-
// explicitly chosen to return an IncompleteResult, so we trust that decision.
1268-
if (_sessionTransport is not StreamableHttpServerTransport { Stateless: true } && !ClientSupportsMrtr())
1273+
// Allow the IncompleteResult if the client supports MRTR or the server is stateless
1274+
// (in stateless mode, the tool handler has explicitly chosen to return an IncompleteResult
1275+
// via the low-level API, so we trust that decision regardless of negotiated version).
1276+
if (!ClientSupportsMrtr() && _sessionTransport is not StreamableHttpServerTransport { Stateless: true })
12691277
{
12701278
throw new McpException(
12711279
"A tool handler returned an incomplete result, but the client does not support Multi Round-Trip Requests (MRTR). " +

tests/ModelContextProtocol.AspNetCore.Tests/StatelessMrtrTests.cs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ public class StatelessMrtrTests(ITestOutputHelper outputHelper) : KestrelInMemor
2525
TransportMode = HttpTransportMode.StreamableHttp,
2626
};
2727

28-
private async Task StartAsync()
28+
private Task StartAsync() => StartAsync(configureOptions: null);
29+
30+
private async Task StartAsync(Action<McpServerOptions>? configureOptions, params McpServerTool[] additionalTools)
2931
{
3032
Builder.Services.AddMcpServer(options =>
3133
{
@@ -34,6 +36,7 @@ private async Task StartAsync()
3436
Name = nameof(StatelessMrtrTests),
3537
Version = "1",
3638
};
39+
configureOptions?.Invoke(options);
3740
})
3841
.WithHttpTransport(httpOptions =>
3942
{
@@ -228,6 +231,7 @@ static string (RequestContext<CallToolRequestParams> context) =>
228231
Name = "stateless-multi-roundtrip",
229232
Description = "Stateless tool with multiple MRTR round-trips"
230233
}),
234+
..additionalTools,
231235
]);
232236

233237
_app = Builder.Build();
@@ -434,4 +438,33 @@ public async Task Stateless_MultiRoundTrip_CompletesAcrossMultipleRetries()
434438
Assert.Equal(1, samplingCalls);
435439
Assert.Equal(1, elicitCalls);
436440
}
441+
442+
[Fact]
443+
public async Task Stateless_IsMrtrSupported_ReturnsTrue_WhenExperimentalProtocolNegotiated()
444+
{
445+
// Regression test: In stateless mode, each request creates a new McpServerImpl that never
446+
// sees the initialize handshake. The Mcp-Protocol-Version header is flowed via
447+
// JsonRpcMessageContext.ProtocolVersion so the server can determine MRTR support.
448+
var isMrtrSupportedTool = McpServerTool.Create(
449+
static string (McpServer server) => server.IsMrtrSupported.ToString(),
450+
new McpServerToolCreateOptions
451+
{
452+
Name = "check-mrtr",
453+
Description = "Returns IsMrtrSupported"
454+
});
455+
456+
await StartAsync(
457+
options => options.ExperimentalProtocolVersion = "2026-06-XX",
458+
isMrtrSupportedTool);
459+
460+
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
461+
462+
await using var client = await ConnectAsync(clientOptions);
463+
464+
var result = await client.CallToolAsync("check-mrtr",
465+
cancellationToken: TestContext.Current.CancellationToken);
466+
467+
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
468+
Assert.Equal("True", text);
469+
}
437470
}

0 commit comments

Comments
 (0)