Skip to content

Commit 5477a57

Browse files
halter73Copilot
andcommitted
Refine MRTR gating and align tests with new protocol model
- Implicit MRTR (handler suspension via ElicitAsync) requires both client support (DRAFT-2026-v1) and a stateful session. All other cases fall through to the exception-based path, which transparently resolves InputRequiredException via legacy JSON-RPC requests for clients that don't speak MRTR. - Drop the now-redundant ProtocolVersion pin from ConfigureExperimentalServer in MapMcpTests.Mrtr; server uses the negotiated version like any other server. - Rewrite the obsolete WithoutExperimental low-level test now that the experimental flag is gone; it now verifies retry exhaustion when no input requests are supplied. - Update other test assertions to use the literal DRAFT-2026-v1 string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f3a00be commit 5477a57

7 files changed

Lines changed: 38 additions & 35 deletions

File tree

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,14 +1197,14 @@ internal bool ClientSupportsMrtr() =>
11971197
_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion;
11981198

11991199
/// <summary>
1200-
/// Returns <see langword="true"/> when the session has negotiated a pre-DRAFT-2026-v1 protocol
1201-
/// version on a stateful transport (i.e., a transport that supports Mcp-Session-Id). These sessions
1202-
/// keep the implicit MRTR behavior where a handler can call <c>ElicitAsync</c>/<c>SampleAsync</c>
1203-
/// and the SDK suspends/resumes the handler across an <see cref="InputRequiredResult"/> round trip.
1200+
/// Returns <see langword="true"/> when the session is stateful (i.e., the same server instance
1201+
/// will handle subsequent requests). The implicit MRTR path — where a handler can call
1202+
/// <c>ElicitAsync</c>/<c>SampleAsync</c> and the SDK suspends/resumes the handler across an
1203+
/// <see cref="InputRequiredResult"/> round trip — requires the continuation map to outlive the
1204+
/// initial response, so it is only available on stateful sessions. Stateless transports always
1205+
/// go through the exception-based path.
12041206
/// </summary>
1205-
internal bool IsLegacyStatefulSession() =>
1206-
_negotiatedProtocolVersion is not null &&
1207-
_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion &&
1207+
internal bool IsStatefulSession() =>
12081208
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
12091209

12101210
/// <summary>
@@ -1303,10 +1303,13 @@ private void WrapHandlerWithMrtr(string method)
13031303
// high-level handlers that call ElicitAsync/SampleAsync.
13041304
}
13051305

1306-
// Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) is reserved for
1307-
// legacy stateful sessions only. DRAFT-2026-v1 sessions always go through the exception
1308-
// path (the client drives the retry loop). Stateless sessions also use the exception path.
1309-
if (!IsLegacyStatefulSession())
1306+
// Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits
1307+
// InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand,
1308+
// and requires the same server instance to handle the retry (stateful session).
1309+
// For all other cases — legacy clients, stateless sessions — fall through to the
1310+
// exception-based path, which transparently resolves InputRequiredException via
1311+
// legacy JSON-RPC requests when the client doesn't speak MRTR.
1312+
if (!ClientSupportsMrtr() || !IsStatefulSession())
13101313
{
13111314
return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false);
13121315
}

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ private ServerMessageTracker ConfigureExperimentalServer(params Delegate[] tools
1616
Builder.Services.AddMcpServer(options =>
1717
{
1818
options.ServerInfo = new Implementation { Name = "ExperimentalServer", Version = "1" };
19-
options.ProtocolVersion = "DRAFT-2026-v1";
19+
// Don't pin a protocol version here — let it be negotiated based on what the client
20+
// requests. DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it.
2021
messageTracker.AddFilters(options.Filters.Message);
2122
})
2223
.WithHttpTransport(ConfigureStateless)
@@ -194,7 +195,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool
194195
if (experimentalClient)
195196
{
196197
// Both experimental — MRTR end-to-end.
197-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
198+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
198199
}
199200
else
200201
{
@@ -322,7 +323,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalServer, bool experimental
322323
// Both experimental — MRTR active. Parallel awaits hit the MrtrContext
323324
// concurrency gate and the second call throws InvalidOperationException,
324325
// which the tool catches and returns as text.
325-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
326+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
326327

327328
var result = await client.CallToolAsync("mrtr-parallel-await",
328329
cancellationToken: TestContext.Current.CancellationToken);
@@ -390,7 +391,7 @@ public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr()
390391
app.MapMcp();
391392
await app.StartAsync(TestContext.Current.CancellationToken);
392393
await using var client = await ConnectExperimentalAsync();
393-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
394+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
394395

395396
var result = await client.CallToolAsync("mrtr-roots",
396397
cancellationToken: TestContext.Current.CancellationToken);
@@ -476,7 +477,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient)
476477

477478
if (experimentalClient)
478479
{
479-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
480+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
480481
messageTracker.AssertMrtrUsed();
481482
}
482483
else
@@ -501,7 +502,7 @@ public async Task Mrtr_IsMrtrSupported(bool experimentalClient)
501502
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
502503
: ConfigureMrtrHandlers;
503504
await using var client = await ConnectAsync(configureClient: configureClient);
504-
Assert.Equal(experimentalClient ? "2026-06-XX" : "2025-11-25", client.NegotiatedProtocolVersion);
505+
Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", client.NegotiatedProtocolVersion);
505506

506507
var result = await client.CallToolAsync("mrtr-check",
507508
cancellationToken: TestContext.Current.CancellationToken);
@@ -592,7 +593,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously()
592593
};
593594
};
594595
});
595-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
596+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
596597

597598
var result = await client.CallToolAsync("mrtr-concurrent-three",
598599
cancellationToken: TestContext.Current.CancellationToken);
@@ -621,7 +622,7 @@ public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMr
621622
app.MapMcp();
622623
await app.StartAsync(TestContext.Current.CancellationToken);
623624
await using var client = await ConnectExperimentalAsync();
624-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
625+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
625626

626627
var result = await client.CallToolAsync("mrtr-loadshed",
627628
cancellationToken: TestContext.Current.CancellationToken);

tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ private string CallTool(string toolName, string arguments = "{}") =>
264264
private async Task InitializeWithMrtrAsync()
265265
{
266266
var initJson = """
267-
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-06-XX","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}}
267+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}}
268268
""";
269269

270270
using var response = await PostJsonRpcAsync(initJson);
@@ -273,15 +273,15 @@ private async Task InitializeWithMrtrAsync()
273273

274274
// Verify the server negotiated to the experimental version
275275
var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue<string>();
276-
Assert.Equal("2026-06-XX", protocolVersion);
276+
Assert.Equal("DRAFT-2026-v1", protocolVersion);
277277

278278
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
279279
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
280280
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
281281

282282
// Set the MCP-Protocol-Version header for subsequent requests
283283
HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version");
284-
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2026-06-XX");
284+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
285285

286286
// Reset request ID counter since initialize used ID 1
287287
_lastRequestId = 1;

tests/ModelContextProtocol.Tests/Client/McpClientTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,7 @@ public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol()
592592
{
593593
Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1";
594594
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" });
595-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
595+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
596596
}
597597

598598
[Fact]

tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ public async Task CallToolAsync_BothExperimental_ElicitCompletesViaMrtr()
189189
});
190190

191191
await using var client = await CreateMcpClientForServer(clientOptions);
192-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
192+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
193193

194194
var result = await client.CallToolAsync("elicitation-tool",
195195
new Dictionary<string, object?> { ["message"] = "What is your name?" },
@@ -315,7 +315,7 @@ public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCal
315315
};
316316

317317
await using var client = await CreateMcpClientForServer(clientOptions);
318-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
318+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
319319

320320
// The client handler throws during input resolution, so the exception
321321
// escapes ResolveInputRequestAsync and surfaces directly to the caller.
@@ -405,7 +405,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning()
405405
Id = initRequest.Id,
406406
Result = JsonSerializer.SerializeToNode(new InitializeResult
407407
{
408-
ProtocolVersion = "2026-06-XX",
408+
ProtocolVersion = "DRAFT-2026-v1",
409409
Capabilities = new ServerCapabilities(),
410410
ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }
411411
}, McpJsonUtilities.DefaultOptions),
@@ -418,7 +418,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning()
418418

419419
// Client is now connected with MRTR negotiated
420420
await using var client = await clientTask;
421-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
421+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
422422

423423
// Now simulate the non-compliant server sending a legacy elicitation/create request
424424
var legacyRequest = new JsonRpcRequest

tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,21 +42,20 @@ static string (McpServer server) =>
4242
}
4343

4444
[Fact]
45-
public async Task LowLevel_IncompleteResultException_WithoutExperimental_ReturnsError()
45+
public async Task LowLevel_InputRequiredException_WithoutInputRequests_ExhaustsRetries()
4646
{
4747
StartServer();
48-
// Client does NOT set DRAFT-2026-v1
4948
var clientOptions = new McpClientOptions();
5049

5150
await using var client = await CreateMcpClientForServer(clientOptions);
5251

5352
// The always-incomplete tool throws InputRequiredException with only requestState
54-
// and no inputRequests. Without MRTR negotiated, the backcompat layer can't resolve
55-
// the request (no inputRequests to dispatch), so it wraps it in an error.
56-
var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
53+
// and no inputRequests. The client has nothing to dispatch, so it keeps retrying
54+
// with the same requestState until the retry budget is exhausted.
55+
var exception = await Assert.ThrowsAsync<McpException>(() =>
5756
client.CallToolAsync("always-incomplete",
5857
cancellationToken: TestContext.Current.CancellationToken).AsTask());
5958

60-
Assert.Contains("without input requests", exception.Message);
59+
Assert.Contains("more than", exception.Message);
6160
}
6261
}

tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire()
8080
};
8181

8282
await using var client = await CreateMcpClientForServer(clientOptions);
83-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
83+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
8484

8585
var result = await client.CallToolAsync("elicit-tool",
8686
new Dictionary<string, object?> { ["message"] = "test" },
@@ -107,7 +107,7 @@ public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire()
107107
};
108108

109109
await using var client = await CreateMcpClientForServer(clientOptions);
110-
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
110+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
111111

112112
var result = await client.CallToolAsync("sample-tool",
113113
new Dictionary<string, object?> { ["prompt"] = "test" },

0 commit comments

Comments
 (0)