Skip to content

Commit 560d064

Browse files
halter73Copilot
andcommitted
Collapse AspNetCore MRTR test matrix to single ProtocolVersion axis
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5477a57 commit 560d064

2 files changed

Lines changed: 98 additions & 112 deletions

File tree

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

Lines changed: 67 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,15 @@ namespace ModelContextProtocol.AspNetCore.Tests;
1010

1111
public abstract partial class MapMcpTests
1212
{
13-
private ServerMessageTracker ConfigureExperimentalServer(params Delegate[] tools)
13+
private ServerMessageTracker ConfigureServer(params Delegate[] tools)
1414
{
1515
var messageTracker = new ServerMessageTracker();
1616
Builder.Services.AddMcpServer(options =>
1717
{
18-
options.ServerInfo = new Implementation { Name = "ExperimentalServer", Version = "1" };
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.
21-
messageTracker.AddFilters(options.Filters.Message);
22-
})
23-
.WithHttpTransport(ConfigureStateless)
24-
.WithTools(tools.Select(t => McpServerTool.Create(t)));
25-
return messageTracker;
26-
}
27-
28-
private ServerMessageTracker ConfigureDefaultServer(params Delegate[] tools)
29-
{
30-
var messageTracker = new ServerMessageTracker();
31-
Builder.Services.AddMcpServer(options =>
32-
{
33-
options.ServerInfo = new Implementation { Name = "DefaultServer", Version = "1" };
18+
options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" };
19+
// Do not pin a protocol version — let it be negotiated based on what the client requests.
20+
// DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it; others get
21+
// the latest non-draft.
3422
messageTracker.AddFilters(options.Filters.Message);
3523
})
3624
.WithHttpTransport(ConfigureStateless)
@@ -164,101 +152,76 @@ private static async Task<string> MrtrMixed(McpServer server, RequestContext<Cal
164152
}
165153

166154
[Theory]
167-
[InlineData(true, true)]
168-
[InlineData(true, false)]
169-
[InlineData(false, true)]
170-
[InlineData(false, false)]
171-
public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool experimentalClient)
155+
[InlineData(true)]
156+
[InlineData(false)]
157+
public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient)
172158
{
173-
// Configure server — experimental or default based on parameter.
174-
var messageTracker = experimentalServer
175-
? ConfigureExperimentalServer(MrtrMixed)
176-
: ConfigureDefaultServer(MrtrMixed);
159+
// The server always supports DRAFT-2026-v1 (it's in SupportedProtocolVersions). The
160+
// client opts in by pinning ProtocolVersion = "DRAFT-2026-v1"; otherwise it negotiates
161+
// the latest non-draft version and the server falls back to the exception path with
162+
// legacy JSON-RPC resolution.
163+
var messageTracker = ConfigureServer(MrtrMixed);
177164

178165
await using var app = Builder.Build();
179166
app.MapMcp();
180167
await app.StartAsync(TestContext.Current.CancellationToken);
181168

182-
// Configure client — experimental or default based on parameter.
183169
Action<McpClientOptions> configureClient = experimentalClient
184170
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
185171
: ConfigureMrtrHandlers;
186172

187-
if (experimentalServer)
188-
{
189-
// Success cases: both exception and await APIs complete.
190-
// Skip stateless — await API requires handler suspension (stateful only).
191-
Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only).");
192-
193-
await using var client = await ConnectAsync(configureClient: configureClient);
194-
195-
if (experimentalClient)
196-
{
197-
// Both experimental — MRTR end-to-end.
198-
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
199-
}
200-
else
201-
{
202-
// Backcompat — server experimental, client default. Legacy JSON-RPC.
203-
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
204-
}
205-
206-
var result = await client.CallToolAsync("mrtr-mixed",
207-
cancellationToken: TestContext.Current.CancellationToken);
208-
209-
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
210-
Assert.True(result.IsError is not true);
211-
var parts = text.Split('|');
212-
Assert.Equal(3, parts.Length);
213-
214-
// confirmation from round 2 elicitation
215-
Assert.Equal("accept", parts[0]);
216-
// greeting from await SampleAsync — our test handler returns "LLM:{prompt}"
217-
Assert.StartsWith("LLM:", parts[1]);
218-
// signoff from await ElicitAsync
219-
Assert.Equal("accept", parts[2]);
220-
221-
if (experimentalClient)
222-
{
223-
messageTracker.AssertMrtrUsed();
224-
}
225-
else
226-
{
227-
messageTracker.AssertMrtrNotUsed();
228-
}
229-
}
230-
else if (Stateless)
173+
// The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3.
174+
// In stateless mode, those calls succeed only when the request is still open on the same
175+
// SSE stream — which it is — so the tool runs end-to-end as long as the input requests
176+
// themselves can be resolved (MRTR client) or replayed via legacy JSON-RPC (stateful + legacy).
177+
if (Stateless && !experimentalClient)
231178
{
232-
// Stateless + non-experimental: InputRequiredException cannot be resolved
233-
// (no MRTR and no stateful backcompat). The server returns an error.
179+
// Stateless + legacy client: InputRequiredException cannot be resolved (no MRTR wire
180+
// and no persistent server instance for the backcompat retry loop). The server returns
181+
// a JSON-RPC error.
234182
await using var client = await ConnectAsync(configureClient: configureClient);
235-
236183
var ex = await Assert.ThrowsAsync<McpProtocolException>(() =>
237184
client.CallToolAsync("mrtr-mixed",
238185
cancellationToken: TestContext.Current.CancellationToken).AsTask());
239186

240187
Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode);
241188
Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase);
242189
Assert.Contains("MRTR", ex.Message);
190+
return;
243191
}
244-
else
192+
193+
if (Stateless && experimentalClient)
245194
{
246-
// Stateful + non-experimental: backcompat resolves InputRequiredException
247-
// via legacy JSON-RPC requests. The tool completes all 3 rounds.
248-
await using var client = await ConnectAsync(configureClient: configureClient);
195+
// Stateless + MRTR client: the await-style portion (server.SampleAsync on round 3)
196+
// requires handler suspension across requests, which only works in stateful mode.
197+
// Skip this combination — the await API is documented as stateful-only.
198+
Assert.SkipWhen(true, "Await-style API requires handler suspension (stateful only).");
199+
return;
200+
}
249201

250-
var result = await client.CallToolAsync("mrtr-mixed",
251-
cancellationToken: TestContext.Current.CancellationToken);
202+
// Stateful path — both client modes complete all 3 rounds.
203+
await using var statefulClient = await ConnectAsync(configureClient: configureClient);
252204

253-
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
254-
Assert.True(result.IsError is not true);
255-
var parts = text.Split('|');
256-
Assert.Equal(3, parts.Length);
205+
Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25",
206+
statefulClient.NegotiatedProtocolVersion);
257207

258-
Assert.Equal("accept", parts[0]);
259-
Assert.StartsWith("LLM:", parts[1]);
260-
Assert.Equal("accept", parts[2]);
208+
var result = await statefulClient.CallToolAsync("mrtr-mixed",
209+
cancellationToken: TestContext.Current.CancellationToken);
261210

211+
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
212+
Assert.True(result.IsError is not true);
213+
var parts = text.Split('|');
214+
Assert.Equal(3, parts.Length);
215+
Assert.Equal("accept", parts[0]);
216+
Assert.StartsWith("LLM:", parts[1]);
217+
Assert.Equal("accept", parts[2]);
218+
219+
if (experimentalClient)
220+
{
221+
messageTracker.AssertMrtrUsed();
222+
}
223+
else
224+
{
262225
messageTracker.AssertMrtrNotUsed();
263226
}
264227
}
@@ -295,34 +258,28 @@ private static async Task<string> MrtrParallelAwait(McpServer server, Cancellati
295258
}
296259

297260
[Theory]
298-
[InlineData(true, true)]
299-
[InlineData(true, false)]
300-
[InlineData(false, true)]
301-
[InlineData(false, false)]
302-
public async Task Mrtr_ParallelAwaits(bool experimentalServer, bool experimentalClient)
261+
[InlineData(true)]
262+
[InlineData(false)]
263+
public async Task Mrtr_ParallelAwaits(bool experimentalClient)
303264
{
304265
// Parallel awaits work with regular JSON-RPC but fail with MRTR because
305266
// MrtrContext only supports one exchange at a time (TrySetResult gate).
306267
Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only).");
307268

308-
var messageTracker = experimentalServer
309-
? ConfigureExperimentalServer(MrtrParallelAwait)
310-
: ConfigureDefaultServer(MrtrParallelAwait);
269+
var messageTracker = ConfigureServer(MrtrParallelAwait);
311270
await using var app = Builder.Build();
312271
app.MapMcp();
313272
await app.StartAsync(TestContext.Current.CancellationToken);
314273

315-
// Configure client — experimental or default based on parameter.
316274
Action<McpClientOptions> configureClient = experimentalClient
317275
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
318276
: ConfigureMrtrHandlers;
319277
await using var client = await ConnectAsync(configureClient: configureClient);
320278

321-
if (experimentalServer && experimentalClient)
279+
if (experimentalClient)
322280
{
323-
// Both experimental — MRTR active. Parallel awaits hit the MrtrContext
324-
// concurrency gate and the second call throws InvalidOperationException,
325-
// which the tool catches and returns as text.
281+
// MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second
282+
// call throws InvalidOperationException, which the tool catches and returns as text.
326283
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
327284

328285
var result = await client.CallToolAsync("mrtr-parallel-await",
@@ -370,7 +327,7 @@ private static string MrtrElicit(RequestContext<CallToolRequestParams> context)
370327
[Fact]
371328
public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr()
372329
{
373-
var messageTracker = ConfigureExperimentalServer(
330+
var messageTracker = ConfigureServer(
374331
[McpServerTool(Name = "mrtr-roots")] (RequestContext<CallToolRequestParams> context) =>
375332
{
376333
if (context.Params!.InputResponses is { } responses &&
@@ -446,7 +403,7 @@ private static string MrtrMulti(RequestContext<CallToolRequestParams> context)
446403
[InlineData(false)]
447404
public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient)
448405
{
449-
var messageTracker = ConfigureExperimentalServer(MrtrMulti);
406+
var messageTracker = ConfigureServer(MrtrMulti);
450407
await using var app = Builder.Build();
451408
app.MapMcp();
452409
await app.StartAsync(TestContext.Current.CancellationToken);
@@ -492,7 +449,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient)
492449
[InlineData(false)]
493450
public async Task Mrtr_IsMrtrSupported(bool experimentalClient)
494451
{
495-
ConfigureExperimentalServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString());
452+
ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString());
496453
await using var app = Builder.Build();
497454
app.MapMcp();
498455
await app.StartAsync(TestContext.Current.CancellationToken);
@@ -555,7 +512,7 @@ private static string MrtrConcurrentThree(RequestContext<CallToolRequestParams>
555512
[Fact]
556513
public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously()
557514
{
558-
var messageTracker = ConfigureExperimentalServer(MrtrConcurrentThree);
515+
var messageTracker = ConfigureServer(MrtrConcurrentThree);
559516
await using var app = Builder.Build();
560517
app.MapMcp();
561518
await app.StartAsync(TestContext.Current.CancellationToken);
@@ -607,7 +564,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously()
607564
[Fact]
608565
public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMrtr()
609566
{
610-
var messageTracker = ConfigureExperimentalServer(
567+
var messageTracker = ConfigureServer(
611568
[McpServerTool(Name = "mrtr-loadshed")] (RequestContext<CallToolRequestParams> context) =>
612569
{
613570
if (context.Params!.RequestState is { } state)
@@ -637,7 +594,7 @@ public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMr
637594
public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc()
638595
{
639596
Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC.");
640-
var messageTracker = ConfigureExperimentalServer(
597+
var messageTracker = ConfigureServer(
641598
[McpServerTool(Name = "mrtr-roots-backcompat")] (RequestContext<CallToolRequestParams> context) =>
642599
{
643600
if (context.Params!.InputResponses is { } responses &&
@@ -673,7 +630,7 @@ public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc()
673630
public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc()
674631
{
675632
Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC.");
676-
var messageTracker = ConfigureExperimentalServer(
633+
var messageTracker = ConfigureServer(
677634
[McpServerTool(Name = "mrtr-multi-input")] (RequestContext<CallToolRequestParams> context) =>
678635
{
679636
if (context.Params!.InputResponses is { } responses &&
@@ -726,7 +683,7 @@ public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries()
726683
Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC.");
727684
int elicitCallCount = 0;
728685

729-
ConfigureExperimentalServer(
686+
ConfigureServer(
730687
[McpServerTool(Name = "mrtr-always-incomplete")] (RequestContext<CallToolRequestParams> context) =>
731688
{
732689
// Always throw — never complete
@@ -769,7 +726,7 @@ public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries()
769726
public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError()
770727
{
771728
Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC.");
772-
ConfigureExperimentalServer(
729+
ConfigureServer(
773730
[McpServerTool(Name = "mrtr-empty-inputs")] (RequestContext<CallToolRequestParams> context) =>
774731
{
775732
throw new InputRequiredException(
@@ -795,7 +752,7 @@ public async Task Mrtr_Backcompat_ClientHandlerThrows_PropagatesError()
795752
{
796753
Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC.");
797754

798-
ConfigureExperimentalServer(MrtrElicit);
755+
ConfigureServer(MrtrElicit);
799756
await using var app = Builder.Build();
800757
app.MapMcp();
801758
await app.StartAsync(TestContext.Current.CancellationToken);

tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,37 @@ private static async Task<JsonRpcResponse> AssertSingleSseResponseAsync(HttpResp
240240
return jsonRpcResponse;
241241
}
242242

243-
private Task<HttpResponseMessage> PostJsonRpcAsync(string json) =>
244-
HttpClient.PostAsync("", JsonContent(json), TestContext.Current.CancellationToken);
243+
private Task<HttpResponseMessage> PostJsonRpcAsync(string json)
244+
{
245+
var content = JsonContent(json);
246+
247+
// DRAFT-2026-v1 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243.
248+
// Parse the body to derive them and attach to this request only.
249+
var bodyNode = JsonNode.Parse(json);
250+
if (bodyNode is JsonObject obj)
251+
{
252+
if (obj["method"]?.GetValue<string>() is { } method)
253+
{
254+
content.Headers.Add("Mcp-Method", method);
255+
256+
if (obj["params"] is JsonObject paramsObj)
257+
{
258+
string? mcpName = method switch
259+
{
260+
"tools/call" or "prompts/get" => paramsObj["name"]?.GetValue<string>(),
261+
"resources/read" => paramsObj["uri"]?.GetValue<string>(),
262+
_ => null,
263+
};
264+
if (mcpName is not null)
265+
{
266+
content.Headers.Add("Mcp-Name", mcpName);
267+
}
268+
}
269+
}
270+
}
271+
272+
return HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
273+
}
245274

246275
private long _lastRequestId = 1;
247276

0 commit comments

Comments
 (0)