using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Text.Json; namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): // the handler refuses a request when the server opted into sessions (SessionMode = HttpServerSessionMode.Stateful), so a client pinned // to that revision downgrades to legacy instead of negotiating 2026-07-28. These MRTR tests therefore can't // run on the stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures. private const string July2026StatefulStreamableHttpSkipReason = "Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567); stateful Streamable HTTP refuses it. Covered by the stateless and SSE fixtures."; private ServerMessageTracker ConfigureServer(params Delegate[] tools) { var messageTracker = new ServerMessageTracker(); Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" }; // Do not pin a protocol version - let it be negotiated based on what the client requests. // 2026-07-28 is in SupportedProtocolVersions, so an opt-in client gets it; others get // the latest legacy version. messageTracker.AddFilters(options.Filters.Message); }) .WithHttpTransport(ConfigureStateless) .WithTools(tools.Select(t => McpServerTool.Create(t))); return messageTracker; } private Task ConnectExperimentalAsync() => ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; }); // The default client now negotiates the 2026-07-28 protocol revision. The legacy // JSON-RPC MRTR back-compat resolver only applies to legacy clients, so pin these to the latest legacy version. private Task ConnectLegacyAsync() => ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }); /// Configures elicitation, sampling, and roots handlers on client options. private static void ConfigureMrtrHandlers(McpClientOptions options) { options.Handlers.ElicitationHandler = (request, ct) => { var message = request?.Message ?? ""; var answer = message.Contains("name", StringComparison.OrdinalIgnoreCase) ? "Alice" : message.Contains("greet", StringComparison.OrdinalIgnoreCase) ? "Hello" : "yes"; return new ValueTask(new ElicitResult { Action = "accept", Content = new Dictionary { ["answer"] = JsonDocument.Parse($"\"{answer}\"").RootElement.Clone() } }); }; options.Handlers.SamplingHandler = (request, progress, ct) => { var prompt = request?.Messages?.LastOrDefault()?.Content .OfType().FirstOrDefault()?.Text ?? ""; return new ValueTask(new CreateMessageResult { Content = [new TextContentBlock { Text = $"LLM:{prompt}" }], Model = "test-model" }); }; options.Handlers.RootsHandler = (request, ct) => { return new ValueTask(new ListRootsResult { Roots = [ new Root { Uri = "file:///project", Name = "Project" }, new Root { Uri = "file:///data", Name = "Data" } ] }); }; } // ===================================================================== // MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases. // Each test creates its own server with 2026-07-28 enabled. // ===================================================================== [McpServerTool(Name = "mrtr-mixed")] private static async Task MrtrMixed(McpServer server, RequestContext context, CancellationToken ct) { var state = context.Params!.RequestState; var responses = context.Params!.InputResponses; // Round 3 entry: confirmation from round 2 available. Transition to await API. if (state == "round-2" && responses?.TryGetValue("confirm", out var confirmResponse) == true) { var confirmation = confirmResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action ?? "unknown"; // Await API: sequential sampling then elicitation var sampleResult = await server.SampleAsync(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Write greeting" }] }], MaxTokens = 100 }, ct); var greeting = sampleResult.Content.OfType().FirstOrDefault()?.Text ?? ""; var signoffResult = await server.ElicitAsync(new ElicitRequestParams { Message = "Sign off as?", RequestedSchema = new() }, ct); var signoff = signoffResult.Action; return $"{confirmation}|{greeting}|{signoff}"; } // Round 2 entry: parallel results from round 1 available. if (state == "round-1" && responses is not null) { var name = responses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; var weather = responses["weather"].Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content .OfType().FirstOrDefault()?.Text ?? ""; var root = responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots?.FirstOrDefault()?.Name ?? ""; // Exception API: single elicitation with requestState throw new InputRequiredException( inputRequests: new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = $"Confirm {name} in {weather} near {root}?", RequestedSchema = new() }) }, requestState: "round-2"); } // Round 1: Exception API with 3 PARALLEL input requests throw new InputRequiredException( inputRequests: new Dictionary { ["name"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "What is your name?", RequestedSchema = new() }), ["weather"] = InputRequest.ForSampling(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Describe the weather" }] }], MaxTokens = 100 }), ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) }, requestState: "round-1"); } [Theory] [InlineData(false)] [InlineData(true)] public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) { // the await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3, // which requires server-to-client requests - only available in stateful sessions. Starting with // the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP is implicitly stateless, so the // experimental-client + HTTP combination cannot resolve the await-style portion. Stdio // coverage for this scenario lives in July2026ProtocolBackcompatTests. Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; starting with the 2026-07-28 protocol revision (SEP-2567) Streamable HTTP no longer supports sessions. See July2026ProtocolBackcompatTests for stdio coverage."); // The server always supports 2026-07-28 (it's in SupportedProtocolVersions). The // client opts in by pinning ProtocolVersion = "2026-07-28"; otherwise it negotiates // the latest legacy version and the server falls back to the exception path with // legacy JSON-RPC resolution. var messageTracker = ConfigureServer(MrtrMixed); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. // In stateless mode, those calls succeed only when the request is still open on the same // SSE stream - which it is - so the tool runs end-to-end as long as the input requests // themselves can be resolved (MRTR client) or replayed via legacy JSON-RPC (stateful + legacy). if (Stateless && !experimentalClient) { // Stateless + legacy client: InputRequiredException cannot be resolved (no MRTR wire // and no persistent server instance for the backcompat retry loop). The server returns // a JSON-RPC error. await using var client = await ConnectAsync(configureClient: configureClient); var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-mixed", cancellationToken: TestContext.Current.CancellationToken).AsTask()); Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("MRTR", ex.Message); return; } if (Stateless && experimentalClient) { // Stateless + MRTR client: the await-style portion (server.SampleAsync on round 3) // requires handler suspension across requests, which only works in stateful mode. // Skip this combination - the await API is documented as stateful-only. Assert.SkipWhen(true, "Await-style API requires handler suspension (stateful only)."); return; } // Stateful path - both client modes complete all 3 rounds. await using var statefulClient = await ConnectAsync(configureClient: configureClient); Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", statefulClient.NegotiatedProtocolVersion); var result = await statefulClient.CallToolAsync("mrtr-mixed", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.True(result.IsError is not true); var parts = text.Split('|'); Assert.Equal(3, parts.Length); Assert.Equal("accept", parts[0]); Assert.StartsWith("LLM:", parts[1]); Assert.Equal("accept", parts[2]); if (experimentalClient) { // Rounds 1-2 use wire-format MRTR (InputRequiredResult), but round 3's await calls // still issue legacy elicitation/create + sampling/createMessage requests, so this // configuration is mixed-mode. messageTracker.AssertMrtrUsedAtLeastOnce(); } else { messageTracker.AssertMrtrNotUsed(); } } [McpServerTool(Name = "mrtr-parallel-await")] private static async Task MrtrParallelAwait(McpServer server, CancellationToken ct) { var elicitTask = server.ElicitAsync(new ElicitRequestParams { Message = "Parallel elicit", RequestedSchema = new() }, ct); // Start the second await - with MRTR, this throws InvalidOperationException // because MrtrContext only supports one pending exchange at a time. try { var sampleTask = server.SampleAsync(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Parallel sample" }] }], MaxTokens = 100 }, ct); // If we get here, both calls succeeded (non-MRTR path) var sampleResult = await sampleTask; var elicitResult = await elicitTask; return $"parallel-ok:{elicitResult.Action}:{sampleResult.Content.OfType().First().Text}"; } catch (InvalidOperationException ex) { return ex.Message; } } [Theory] [InlineData(false)] [InlineData(true)] public async Task Mrtr_ParallelAwaits(bool experimentalClient) { // Parallel awaits work with regular JSON-RPC but fail with MRTR because // MrtrContext only supports one exchange at a time (TrySetResult gate). Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); // Starting with the 2026-07-28 protocol revision (SEP-2567), the server is implicitly stateless for // clients on that revision, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client // case for the same reason as Mrtr_MixedExceptionAndAwaitStyle. Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; starting with the 2026-07-28 protocol revision (SEP-2567) Streamable HTTP no longer supports sessions."); ConfigureServer(MrtrParallelAwait); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); if (experimentalClient) { // MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second // call throws InvalidOperationException, which the tool catches and returns as text. Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Contains("Concurrent server-to-client requests are not supported", text); Assert.True(result.IsError is not true); } else { // Non-MRTR: awaits go through regular JSON-RPC - concurrent calls work. Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.StartsWith("parallel-ok:", text); Assert.True(result.IsError is not true); } } [McpServerTool(Name = "mrtr-elicit")] private static string MrtrElicit(RequestContext context) { if (context.Params!.InputResponses is { } responses && responses.TryGetValue("user_input", out var response)) { return $"elicit-ok:{response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action}"; } throw new InputRequiredException( inputRequests: new Dictionary { ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "Please confirm", RequestedSchema = new() }) }, requestState: "elicit-state"); } [Fact] public async Task Mrtr_Roots_CompletesViaMrtr() { Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => { if (context.Params!.InputResponses is { } responses && responses.TryGetValue("roots", out var response)) { var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; return $"roots-ok:{string.Join(",", roots?.Select(r => r.Uri) ?? [])}"; } throw new InputRequiredException( inputRequests: new Dictionary { ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) }, requestState: "roots-state"); }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Equal("roots-ok:file:///project,file:///data", text); Assert.True(result.IsError is not true); messageTracker.AssertMrtrUsed(); } [McpServerTool(Name = "mrtr-multi")] private static string MrtrMulti(RequestContext context) { var requestState = context.Params!.RequestState; var inputResponses = context.Params!.InputResponses; if (requestState == "round-2" && inputResponses is not null) { var greeting = inputResponses["greeting"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; return $"multi-done:greeting={greeting}"; } if (requestState == "round-1" && inputResponses is not null) { var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; throw new InputRequiredException( inputRequests: new Dictionary { ["greeting"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = $"How should I greet {name}?", RequestedSchema = new() }) }, requestState: "round-2"); } throw new InputRequiredException( inputRequests: new Dictionary { ["name"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "What is your name?", RequestedSchema = new() }) }, requestState: "round-1"); } [Theory] [InlineData(true)] [InlineData(false)] public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) { Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer(MrtrMulti); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); if (!experimentalClient && Stateless) { // Stateless without MRTR: InputRequiredException can't be resolved // (no MRTR negotiated and no stateful backcompat path). var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-multi", cancellationToken: TestContext.Current.CancellationToken).AsTask()); Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); return; } var result = await client.CallToolAsync("mrtr-multi", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Equal("multi-done:greeting=accept", text); Assert.True(result.IsError is not true); if (experimentalClient) { Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); messageTracker.AssertMrtrUsed(); } else { Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); messageTracker.AssertMrtrNotUsed(); } } [Theory] [InlineData(true)] [InlineData(false)] public async Task Mrtr_IsMrtrSupported(bool experimentalClient) { Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-check", cancellationToken: TestContext.Current.CancellationToken); // IsMrtrSupported is false only when stateless AND client didn't negotiate MRTR // (no backcompat path available). All other combos have MRTR or backcompat support. var expected = Stateless && !experimentalClient ? "False" : "True"; var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Equal(expected, text); } [McpServerTool(Name = "mrtr-concurrent-three")] private static string MrtrConcurrentThree(RequestContext context) { if (context.Params!.InputResponses is { Count: 3 } responses && responses.ContainsKey("elicit") && responses.ContainsKey("sample") && responses.ContainsKey("roots")) { var elicitAction = responses["elicit"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; var sampleText = responses["sample"].Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)? .Content.OfType().FirstOrDefault()?.Text; var rootUris = string.Join(",", responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots.Select(r => r.Uri) ?? []); return $"all-ok:elicit={elicitAction},sample={sampleText},roots={rootUris}"; } throw new InputRequiredException( inputRequests: new Dictionary { ["elicit"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "Confirm action", RequestedSchema = new() }), ["sample"] = InputRequest.ForSampling(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Generate summary" }] }], MaxTokens = 50 }), ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) }, requestState: "concurrent-state"); } [Fact] public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() { Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer(MrtrConcurrentThree); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); var elicitCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var samplingCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var rootsCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var client = await ConnectAsync(configureClient: options => { options.ProtocolVersion = "2026-07-28"; options.Handlers.ElicitationHandler = async (request, ct) => { elicitCalled.TrySetResult(); await Task.WhenAll(samplingCalled.Task.WaitAsync(ct), rootsCalled.Task.WaitAsync(ct)); return new ElicitResult { Action = "accept" }; }; options.Handlers.SamplingHandler = async (request, progress, ct) => { samplingCalled.TrySetResult(); await Task.WhenAll(elicitCalled.Task.WaitAsync(ct), rootsCalled.Task.WaitAsync(ct)); return new CreateMessageResult { Content = [new TextContentBlock { Text = "AI-summary" }], Model = "test-model" }; }; options.Handlers.RootsHandler = async (request, ct) => { rootsCalled.TrySetResult(); await Task.WhenAll(elicitCalled.Task.WaitAsync(ct), samplingCalled.Task.WaitAsync(ct)); return new ListRootsResult { Roots = [new Root { Uri = "file:///workspace", Name = "Workspace" }] }; }; }); Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-concurrent-three", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Equal("all-ok:elicit=accept,sample=AI-summary,roots=file:///workspace", text); Assert.True(result.IsError is not true); messageTracker.AssertMrtrUsed(); } [Fact] public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() { Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => { if (context.Params!.RequestState is { } state) { return $"resumed:{state}"; } // requestState-only InputRequiredException (no inputRequests) throw new InputRequiredException(requestState: "deferred-work"); }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-loadshed", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Equal("resumed:deferred-work", text); Assert.True(result.IsError is not true); messageTracker.AssertMrtrUsed(); } [Fact] public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots-backcompat")] (RequestContext context) => { if (context.Params!.InputResponses is { } responses && responses.TryGetValue("roots", out var response)) { var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; } throw new InputRequiredException( inputRequests: new Dictionary { ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) }, requestState: "roots-state"); }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots-backcompat", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Equal("roots-ok:Project", text); Assert.True(result.IsError is not true); messageTracker.AssertMrtrNotUsed(); } [Fact] public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-multi-input")] (RequestContext context) => { if (context.Params!.InputResponses is { } responses && responses.TryGetValue("confirm", out var elicitResponse) && responses.TryGetValue("summarize", out var sampleResponse)) { var action = elicitResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; var text = sampleResponse.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content.OfType().FirstOrDefault()?.Text; return $"both:{action}:{text}"; } throw new InputRequiredException( inputRequests: new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "Please confirm", RequestedSchema = new() }), ["summarize"] = InputRequest.ForSampling(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Summarize" }] }], MaxTokens = 100 }) }, requestState: "multi-input-state"); }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-multi-input", cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Content)).Text; Assert.Equal("both:accept:LLM:Summarize", text); Assert.True(result.IsError is not true); messageTracker.AssertMrtrNotUsed(); } [Fact] public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); int elicitCallCount = 0; ConfigureServer( [McpServerTool(Name = "mrtr-always-incomplete")] (RequestContext context) => { // Always throw - never complete throw new InputRequiredException( inputRequests: new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "Confirm again", RequestedSchema = new() }) }, requestState: "infinite"); }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; var originalHandler = options.Handlers.ElicitationHandler!; options.Handlers.ElicitationHandler = (request, ct) => { Interlocked.Increment(ref elicitCallCount); return originalHandler(request, ct); }; }); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-always-incomplete", cancellationToken: TestContext.Current.CancellationToken).AsTask()); Assert.Contains("exceeded", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("10", ex.Message); Assert.Equal(10, elicitCallCount); } [Fact] public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); ConfigureServer( [McpServerTool(Name = "mrtr-empty-inputs")] (RequestContext context) => { throw new InputRequiredException( inputRequests: new Dictionary(), requestState: "empty"); }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-empty-inputs", cancellationToken: TestContext.Current.CancellationToken).AsTask()); Assert.Contains("without input requests", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); } [Fact] public async Task Mrtr_Backcompat_ClientHandlerThrows_PropagatesError() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); ConfigureServer(MrtrElicit); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; options.Handlers.ElicitationHandler = (request, ct) => { throw new InvalidOperationException("Client-side elicitation failure"); }; }); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); // Handler exception propagates through the backcompat JSON-RPC round-trip. // The original exception message gets wrapped in "Request failed (remote)" during backcompat. var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-elicit", cancellationToken: TestContext.Current.CancellationToken).AsTask()); Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); } }