Skip to content

Commit e81a4ec

Browse files
halter73Copilot
andcommitted
Add ExperimentalProtocolVersion opt-in for MRTR
Gate MRTR on a draft protocol version ("2026-06-XX") instead of the experimental["mrtr"] capability. This matches how the real protocol will work when MRTR is ratified — the protocol version IS the signal. Changes: - Add ExperimentalProtocolVersion property to McpClientOptions and McpServerOptions, marked [Experimental(MCPEXP001)] - Add ExperimentalProtocolVersion constant to McpSessionHandler - Client: request experimental version when option is set; accept it in server response validation - Server: accept experimental version from client when option matches; ClientSupportsMrtr() checks negotiated version instead of capability - StreamableHttpHandler: accept experimental version in header validation - Remove experimental["mrtr"] capability advertisement and MrtrContext.ExperimentalCapabilityKey Compatibility matrix (no failures): - Both experimental: MRTR via IncompleteResult + retry - Server exp, client not: Legacy JSON-RPC requests - Client exp, server not: Negotiates to stable, retry loop is no-op - Neither: Standard behavior Tests: - Update all existing MRTR tests to set ExperimentalProtocolVersion - Add 5 new compatibility tests covering all matrix combinations - All 1886 core + 324 AspNetCore tests pass on net10.0 and net9.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3c30338 commit e81a4ec

12 files changed

Lines changed: 298 additions & 25 deletions

File tree

src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<Description>ASP.NET Core extensions for the C# Model Context Protocol (MCP) SDK.</Description>
1111
<PackageReadmeFile>README.md</PackageReadmeFile>
1212
<IsAotCompatible>true</IsAotCompatible>
13+
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
1314
</PropertyGroup>
1415

1516
<ItemGroup>

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -520,11 +520,12 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
520520
/// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
521521
/// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec.
522522
/// </summary>
523-
private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage)
523+
private bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage)
524524
{
525525
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
526526
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
527-
!s_supportedProtocolVersions.Contains(protocolVersionHeader))
527+
!s_supportedProtocolVersions.Contains(protocolVersionHeader) &&
528+
!(mcpServerOptionsSnapshot.Value.ExperimentalProtocolVersion is { } experimentalVersion && protocolVersionHeader == experimentalVersion))
528529
{
529530
errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.";
530531
return false;

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -489,10 +489,6 @@ private void RegisterTaskHandlers(RequestHandlers requestHandlers, IMcpTaskStore
489489
// Advertise task capabilities
490490
_options.Capabilities ??= new();
491491

492-
// Advertise MRTR support so servers can return IncompleteResult to request input inline
493-
// instead of sending separate server-to-client JSON-RPC requests.
494-
var experimental = _options.Capabilities.Experimental ??= new Dictionary<string, object>();
495-
experimental[MrtrContext.ExperimentalCapabilityKey] = new JsonObject();
496492
var tasksCapability = _options.Capabilities.Tasks ??= new McpTasksCapability();
497493
tasksCapability.List ??= new ListMcpTasksCapability();
498494
tasksCapability.Cancel ??= new CancelMcpTasksCapability();
@@ -620,7 +616,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
620616
try
621617
{
622618
// Send initialize request
623-
string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion;
619+
string requestProtocol = _options.ProtocolVersion ?? _options.ExperimentalProtocolVersion ?? McpSessionHandler.LatestProtocolVersion;
624620
var initializeResponse = await SendRequestAsync(
625621
RequestMethods.Initialize,
626622
new InitializeRequestParams
@@ -648,7 +644,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
648644
// Validate protocol version
649645
bool isResponseProtocolValid =
650646
_options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion :
651-
McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion);
647+
McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion) ||
648+
(_options.ExperimentalProtocolVersion is not null && _options.ExperimentalProtocolVersion == initializeResponse.ProtocolVersion);
652649
if (!isResponseProtocolValid)
653650
{
654651
LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion);

src/ModelContextProtocol.Core/Client/McpClientOptions.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,24 @@ public McpClientHandlers Handlers
111111
/// </remarks>
112112
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
113113
public bool SendTaskStatusNotifications { get; set; } = true;
114+
115+
/// <summary>
116+
/// Gets or sets an experimental protocol version that enables draft protocol features such as
117+
/// Multi Round-Trip Requests (MRTR).
118+
/// </summary>
119+
/// <remarks>
120+
/// <para>
121+
/// When set, this version is used as the requested protocol version during initialization instead of
122+
/// the latest stable version. The server must also have a matching <c>ExperimentalProtocolVersion</c>
123+
/// configured for the experimental features to activate. If the server does not recognize the
124+
/// experimental version, it will negotiate to the latest stable version and the client will work
125+
/// normally without experimental features.
126+
/// </para>
127+
/// <para>
128+
/// This property is intended for proof-of-concept and testing of draft MCP specification features
129+
/// that have not yet been ratified.
130+
/// </para>
131+
/// </remarks>
132+
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
133+
public string? ExperimentalProtocolVersion { get; set; }
114134
}

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
3131
/// <summary>The latest version of the protocol supported by this implementation.</summary>
3232
internal const string LatestProtocolVersion = "2025-11-25";
3333

34+
/// <summary>
35+
/// The experimental protocol version that enables MRTR (Multi Round-Trip Requests).
36+
/// This version is not in <see cref="SupportedProtocolVersions"/> and is only accepted
37+
/// when <see cref="McpServerOptions.ExperimentalProtocolVersion"/> or
38+
/// <see cref="McpClientOptions.ExperimentalProtocolVersion"/> is set to this value.
39+
/// </summary>
40+
internal const string ExperimentalProtocolVersion = "2026-06-XX";
41+
3442
/// <summary>
3543
/// All protocol versions supported by this implementation.
3644
/// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler.

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,11 @@ private void ConfigureInitialize(McpServerOptions options)
234234
// Negotiate a protocol version. If the server options provide one, use that.
235235
// Otherwise, try to use whatever the client requested as long as it's supported.
236236
// If it's not supported, fall back to the latest supported version.
237+
// Also accept the experimental protocol version when the server has it configured.
237238
string? protocolVersion = options.ProtocolVersion;
238-
protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ?
239+
protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion &&
240+
(McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ||
241+
(options.ExperimentalProtocolVersion is not null && clientProtocolVersion == options.ExperimentalProtocolVersion)) ?
239242
clientProtocolVersion :
240243
McpSessionHandler.LatestProtocolVersion;
241244

@@ -1140,13 +1143,13 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
11401143
private partial void ReadResourceCompleted(string resourceUri);
11411144

11421145
/// <summary>
1143-
/// Checks whether the connected client has advertised support for MRTR and the server
1146+
/// Checks whether the negotiated protocol version enables MRTR and the server
11441147
/// operates in a mode where MRTR continuations can be stored (i.e., not stateless).
11451148
/// </summary>
11461149
private bool ClientSupportsMrtr() =>
11471150
_sessionTransport is not StreamableHttpServerTransport { Stateless: true } &&
1148-
_clientCapabilities?.Experimental is { } experimental &&
1149-
experimental.ContainsKey(MrtrContext.ExperimentalCapabilityKey);
1151+
_negotiatedProtocolVersion is not null &&
1152+
_negotiatedProtocolVersion == ServerOptions.ExperimentalProtocolVersion;
11501153

11511154
/// <summary>
11521155
/// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync,

src/ModelContextProtocol.Core/Server/McpServerOptions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,23 @@ public McpServerFilters Filters
238238
/// </remarks>
239239
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
240240
public bool SendTaskStatusNotifications { get; set; }
241+
242+
/// <summary>
243+
/// Gets or sets an experimental protocol version that enables draft protocol features such as
244+
/// Multi Round-Trip Requests (MRTR).
245+
/// </summary>
246+
/// <remarks>
247+
/// <para>
248+
/// When set, this version is accepted from clients during protocol version negotiation, and MRTR
249+
/// is activated when the negotiated version matches. If a client does not request this version,
250+
/// the server negotiates to the latest stable version and uses standard server-to-client JSON-RPC
251+
/// requests for sampling and elicitation.
252+
/// </para>
253+
/// <para>
254+
/// This property is intended for proof-of-concept and testing of draft MCP specification features
255+
/// that have not yet been ratified.
256+
/// </para>
257+
/// </remarks>
258+
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
259+
public string? ExperimentalProtocolVersion { get; set; }
241260
}

src/ModelContextProtocol.Core/Server/MrtrContext.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@ namespace ModelContextProtocol.Server;
1313
/// </summary>
1414
internal sealed class MrtrContext
1515
{
16-
/// <summary>
17-
/// The experimental capability key used by clients to signal MRTR support during initialization.
18-
/// </summary>
19-
internal const string ExperimentalCapabilityKey = "mrtr";
20-
2116
private TaskCompletionSource<MrtrExchange> _exchangeTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
2217

2318
private int _nextInputRequestId;

tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ private async Task StartAsync()
3030
Name = nameof(MrtrProtocolTests),
3131
Version = "1",
3232
};
33+
options.ExperimentalProtocolVersion = "2026-06-XX";
3334
}).WithTools([
3435
McpServerTool.Create(
3536
async (string message, McpServer server, CancellationToken ct) =>
@@ -549,28 +550,36 @@ private string CallTool(string toolName, string arguments = "{}") =>
549550
""");
550551

551552
/// <summary>
552-
/// Initialize a session with MRTR capability advertised.
553+
/// Initialize a session requesting the experimental protocol version that enables MRTR.
553554
/// </summary>
554555
private async Task InitializeWithMrtrAsync()
555556
{
556557
var initJson = """
557-
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"sampling":{},"elicitation":{},"roots":{},"experimental":{"mrtr":{}}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}}
558+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-06-XX","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}}
558559
""";
559560

560561
using var response = await PostJsonRpcAsync(initJson);
561562
var rpcResponse = await AssertSingleSseResponseAsync(response);
562563
Assert.NotNull(rpcResponse.Result);
563564

565+
// Verify the server negotiated to the experimental version
566+
var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue<string>();
567+
Assert.Equal("2026-06-XX", protocolVersion);
568+
564569
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
565570
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
566571
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
567572

573+
// Set the MCP-Protocol-Version header for subsequent requests
574+
HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version");
575+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2026-06-XX");
576+
568577
// Reset request ID counter since initialize used ID 1
569578
_lastRequestId = 1;
570579
}
571580

572581
/// <summary>
573-
/// Initialize a session WITHOUT MRTR capability.
582+
/// Initialize a session requesting a standard protocol version (no MRTR).
574583
/// </summary>
575584
private async Task InitializeWithoutMrtrAsync()
576585
{
@@ -582,6 +591,10 @@ private async Task InitializeWithoutMrtrAsync()
582591
var rpcResponse = await AssertSingleSseResponseAsync(response);
583592
Assert.NotNull(rpcResponse.Result);
584593

594+
// Verify the server negotiated to the standard version, not the experimental one
595+
var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue<string>();
596+
Assert.Equal("2025-03-26", protocolVersion);
597+
585598
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
586599
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
587600
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.Client;
4+
using ModelContextProtocol.Protocol;
5+
using ModelContextProtocol.Server;
6+
using ModelContextProtocol.Tests.Utils;
7+
8+
namespace ModelContextProtocol.Tests.Client;
9+
10+
/// <summary>
11+
/// Tests for MRTR compatibility across different experimental/non-experimental combinations.
12+
/// This test class configures the server WITHOUT ExperimentalProtocolVersion to test scenarios
13+
/// where the server is not opted-in to the experimental protocol.
14+
/// </summary>
15+
public class McpClientMrtrCompatTests : ClientServerTestBase
16+
{
17+
public McpClientMrtrCompatTests(ITestOutputHelper testOutputHelper)
18+
: base(testOutputHelper, startServer: false)
19+
{
20+
}
21+
22+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
23+
{
24+
// Deliberately NOT setting ExperimentalProtocolVersion on the server.
25+
mcpServerBuilder.WithTools([
26+
McpServerTool.Create(
27+
async (string prompt, McpServer server, CancellationToken ct) =>
28+
{
29+
var result = await server.SampleAsync(new CreateMessageRequestParams
30+
{
31+
Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }],
32+
MaxTokens = 100
33+
}, ct);
34+
35+
return result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "No response";
36+
},
37+
new McpServerToolCreateOptions
38+
{
39+
Name = "sampling-tool",
40+
Description = "A tool that requests sampling from the client"
41+
}),
42+
McpServerTool.Create(
43+
async (string message, McpServer server, CancellationToken ct) =>
44+
{
45+
var result = await server.ElicitAsync(new ElicitRequestParams
46+
{
47+
Message = message,
48+
RequestedSchema = new()
49+
}, ct);
50+
51+
return $"{result.Action}:{result.Content?.FirstOrDefault().Value}";
52+
},
53+
new McpServerToolCreateOptions
54+
{
55+
Name = "elicitation-tool",
56+
Description = "A tool that requests elicitation from the client"
57+
}),
58+
]);
59+
}
60+
61+
[Fact]
62+
public async Task CallToolAsync_NeitherExperimental_UsesLegacyRequests()
63+
{
64+
// Neither client nor server sets ExperimentalProtocolVersion.
65+
// Server sends standard JSON-RPC sampling/elicitation requests.
66+
StartServer();
67+
var clientOptions = new McpClientOptions();
68+
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
69+
{
70+
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
71+
return new ValueTask<CreateMessageResult>(new CreateMessageResult
72+
{
73+
Content = [new TextContentBlock { Text = $"Legacy: {text}" }],
74+
Model = "test-model"
75+
});
76+
};
77+
78+
await using var client = await CreateMcpClientForServer(clientOptions);
79+
80+
// Verify the negotiated version is a standard stable version
81+
Assert.NotEqual("2026-06-XX", client.NegotiatedProtocolVersion);
82+
83+
var result = await client.CallToolAsync("sampling-tool",
84+
new Dictionary<string, object?> { ["prompt"] = "Hello" },
85+
cancellationToken: TestContext.Current.CancellationToken);
86+
87+
var content = Assert.Single(result.Content);
88+
Assert.Equal("Legacy: Hello", Assert.IsType<TextContentBlock>(content).Text);
89+
}
90+
91+
[Fact]
92+
public async Task CallToolAsync_ClientExperimentalServerNot_FallsBackToLegacy()
93+
{
94+
// Client requests experimental version, server doesn't recognize it,
95+
// negotiates to stable. Everything works via legacy path.
96+
StartServer();
97+
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
98+
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
99+
{
100+
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
101+
return new ValueTask<CreateMessageResult>(new CreateMessageResult
102+
{
103+
Content = [new TextContentBlock { Text = $"Legacy: {text}" }],
104+
Model = "test-model"
105+
});
106+
};
107+
108+
await using var client = await CreateMcpClientForServer(clientOptions);
109+
110+
// Verify the server did NOT negotiate to the experimental version
111+
Assert.NotEqual("2026-06-XX", client.NegotiatedProtocolVersion);
112+
113+
var result = await client.CallToolAsync("sampling-tool",
114+
new Dictionary<string, object?> { ["prompt"] = "From exp client" },
115+
cancellationToken: TestContext.Current.CancellationToken);
116+
117+
var content = Assert.Single(result.Content);
118+
Assert.Equal("Legacy: From exp client", Assert.IsType<TextContentBlock>(content).Text);
119+
}
120+
121+
[Fact]
122+
public async Task CallToolAsync_NeitherExperimental_ElicitationUsesLegacyRequests()
123+
{
124+
StartServer();
125+
var clientOptions = new McpClientOptions();
126+
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
127+
{
128+
return new ValueTask<ElicitResult>(new ElicitResult
129+
{
130+
Action = "confirm",
131+
Content = new Dictionary<string, System.Text.Json.JsonElement>
132+
{
133+
["response"] = System.Text.Json.JsonDocument.Parse("\"yes\"").RootElement.Clone()
134+
}
135+
});
136+
};
137+
138+
await using var client = await CreateMcpClientForServer(clientOptions);
139+
140+
var result = await client.CallToolAsync("elicitation-tool",
141+
new Dictionary<string, object?> { ["message"] = "Agree?" },
142+
cancellationToken: TestContext.Current.CancellationToken);
143+
144+
var content = Assert.Single(result.Content);
145+
Assert.Equal("confirm:yes", Assert.IsType<TextContentBlock>(content).Text);
146+
}
147+
}

0 commit comments

Comments
 (0)