using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.Tests.Server; /// /// Tests for the legacy MRTR backcompat resolver in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync. /// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful, where /// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler /// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks /// a legacy version during initialize negotiation. /// public class MrtrServerBackcompatTests : ClientServerTestBase { private readonly List _observedRequestStates = []; private int _attempt; public MrtrServerBackcompatTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { } protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { mcpServerBuilder.WithTools([ McpServerTool.Create( (RequestContext context) => { var attempt = Interlocked.Increment(ref _attempt); _observedRequestStates.Add(context.Params?.RequestState); return attempt switch { // Round 1: caller has no state; emit one and request elicitation. 1 => throw new InputRequiredException( inputRequests: new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "round1", RequestedSchema = new() }) }, requestState: "round1"), // Round 2: deliberately clear the state by passing requestState: null while still // asking for another elicitation. This exercises the params clone path that // previously preserved the stale "round1" carry-over from round 1's deep clone. 2 => throw new InputRequiredException( inputRequests: new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "round2", RequestedSchema = new() }) }, requestState: null), // Round 3 (final): report what the handler observed so the test can assert it. _ => $"final-state:{context.Params?.RequestState ?? ""}", }; }, new McpServerToolCreateOptions { Name = "requeststate-transition", Description = "Tool that transitions requestState from set to null across MRTR rounds." }), ]); } [Fact] public async Task InputRequiredException_TransitioningRequestStateToNull_DoesNotLeakStaleState() { StartServer(); // Non-MRTR client → server falls into the legacy backcompat resolver path on InputRequiredException. var clientOptions = new McpClientOptions { ProtocolVersion = "2025-06-18", Capabilities = new ClientCapabilities { Elicitation = new() }, }; clientOptions.Handlers.ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { Action = "accept", Content = new Dictionary { ["answer"] = JsonDocument.Parse("\"ok\"").RootElement, }, }); await using var client = await CreateMcpClientForServer(clientOptions); var result = await client.CallToolAsync( "requeststate-transition", cancellationToken: TestContext.Current.CancellationToken); // Three attempts: round 1 (no state) → round 2 (state="round1") → round 3 (state=null after fix). // Without the fix, the third observed state would erroneously remain "round1" because the deep-clone // of the prior request params carried it forward when InputRequiredException.RequestState was null. Assert.Equal(3, _observedRequestStates.Count); Assert.Null(_observedRequestStates[0]); Assert.Equal("round1", _observedRequestStates[1]); Assert.Null(_observedRequestStates[2]); var content = Assert.Single(result.Content); var text = Assert.IsType(content).Text; Assert.Equal("final-state:", text); } } /// /// Companion to covering the other way a handler can surface an /// input-required result to a non-MRTR client: by RETURNING an through the /// alternate result path () instead of throwing /// . The legacy backcompat resolver must normalize both forms so a non-MRTR /// stateful client gets the same server-side resolution either way. /// public class MrtrReturnedInputRequiredResultBackcompatTests : ClientServerTestBase { private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo = (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult)); private int _attempt; public MrtrReturnedInputRequiredResultBackcompatTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { } #pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { mcpServerBuilder.Services.Configure(options => { options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { Interlocked.Increment(ref _attempt); // Retry round: the backcompat resolver re-invoked us with the client's responses. if (context.Params?.RequestState is not null) { return new ValueTask>(new CallToolResult { Content = [new TextContentBlock { Text = "resolved" }], }); } // First round: RETURN an InputRequiredResult through the alternate path rather than throwing. var inputRequired = new InputRequiredResult { InputRequests = new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "need-input", RequestedSchema = new(), }), }, RequestState = "round1", }; return new ValueTask>( ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo)); }; }); } #pragma warning restore MCPEXP002 [Fact] public async Task ReturnedInputRequiredResult_NonMrtrStatefulClient_ResolvedServerSide() { StartServer(); // Non-MRTR client → server falls into the legacy backcompat resolver path, which must handle a // RETURNED InputRequiredResult exactly like a thrown InputRequiredException. var clientOptions = new McpClientOptions { ProtocolVersion = "2025-06-18", Capabilities = new ClientCapabilities { Elicitation = new() }, }; clientOptions.Handlers.ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { Action = "accept" }); await using var client = await CreateMcpClientForServer(clientOptions); var result = await client.CallToolAsync( "return-form", cancellationToken: TestContext.Current.CancellationToken); // Two handler invocations: initial (returned InputRequiredResult) + retry (final result). Assert.Equal(2, _attempt); var content = Assert.Single(result.Content); Assert.Equal("resolved", Assert.IsType(content).Text); } }