Skip to content

Commit ef6d853

Browse files
halter73Copilot
andcommitted
Strip stale requestState across MRTR retry rounds (#1458 review)
When an MRTR round emits an InputRequiredResult whose RequestState is null, the next round's request must not carry a stale requestState forwarded from a prior round's params clone. Both the client retry loop (McpClientImpl.ResolveInputRequestsAsync) and the server backcompat resolver (McpServerImpl.InvokeWithInputRequiredResultHandlingAsync) deep-cloned the prior request params, overwrote requestState only when the new result supplied one, and left any prior value untouched otherwise. Fixed by adding the symmetric else branch that explicitly removes the requestState key whenever the latest InputRequiredResult clears it. This mirrors the existing paramsObj.Remove("inputResponses") in the state-only-retry sibling branch and matches the wire-format intent that requestState is round-scoped, not session-scoped. Regression tests: * MrtrIntegrationTests.IncompleteResultRetry_OmittingRequestState_StripsStaleStateFromRetryParams (client) — uses the fake-stream harness to send InputRequiredResult with requestState on round 1, then without it on round 2, and asserts the third retry params do not contain a "requestState" key. * MrtrServerBackcompatTests.InputRequiredException_TransitioningRequestStateToNull_DoesNotLeakStaleState (server) — uses a non-MRTR client so the server falls into the backcompat resolver path; a tool throws InputRequiredException with requestState then with null, and the third handler invocation asserts context.Params.RequestState is null. Both tests were verified to fail with their respective fixes reverted. Also removed a duplicate XML inheritdoc tag above McpClientImpl.AddKnownTools per review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 17a4f08 commit ef6d853

4 files changed

Lines changed: 287 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,6 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions)
760760
LogClientSessionResumed(_endpointName);
761761
}
762762

763-
/// <inheritdoc/>
764763
/// <inheritdoc/>
765764
public override void AddKnownTools(IEnumerable<Tool> tools)
766765
{
@@ -877,6 +876,12 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders &&
877876
{
878877
paramsObj["requestState"] = requestState;
879878
}
879+
else
880+
{
881+
// Strip any stale requestState carried over from the previous round's clone so
882+
// the server doesn't see a continuation token the current round is not using.
883+
paramsObj.Remove("requestState");
884+
}
880885

881886
request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context };
882887
}

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,12 @@ internal bool IsStatefulSession() =>
12621262
{
12631263
paramsObj["requestState"] = requestState;
12641264
}
1265+
else
1266+
{
1267+
// Strip any stale requestState carried over from the previous round's clone so
1268+
// the next tool invocation doesn't see a continuation token the current round is not using.
1269+
paramsObj.Remove("requestState");
1270+
}
12651271

12661272
request = new JsonRpcRequest
12671273
{

tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,168 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning()
609609
serverToClient.Writer.Complete();
610610
}
611611

612+
[Fact]
613+
public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFromRetryParams()
614+
{
615+
// Regression test for #1458 review feedback: when the server returns InputRequiredResult
616+
// with requestState on round 1 and then InputRequiredResult WITHOUT requestState on round 2,
617+
// the client's third retry must NOT carry the stale round-1 requestState forward via the
618+
// params deep clone. Without the fix, the third retry's params contain {"requestState": "round1-state"}
619+
// even though the round-2 InputRequiredResult cleared it.
620+
StartServer(); // base-class disposal hook
621+
var clientToServer = new Pipe();
622+
var serverToClient = new Pipe();
623+
624+
var clientOptions = new McpClientOptions();
625+
clientOptions.Handlers.ElicitationHandler = (_, _) =>
626+
new ValueTask<ElicitResult>(new ElicitResult
627+
{
628+
Action = "accept",
629+
Content = new Dictionary<string, JsonElement>
630+
{
631+
["confirmed"] = JsonDocument.Parse("\"yes\"").RootElement.Clone()
632+
}
633+
});
634+
635+
var clientTask = McpClient.CreateAsync(
636+
new StreamClientTransport(
637+
clientToServer.Writer.AsStream(),
638+
serverToClient.Reader.AsStream(),
639+
LoggerFactory),
640+
clientOptions,
641+
loggerFactory: LoggerFactory,
642+
cancellationToken: TestContext.Current.CancellationToken);
643+
644+
var serverReader = new StreamReader(clientToServer.Reader.AsStream());
645+
var serverWriter = serverToClient.Writer.AsStream();
646+
647+
// Initialize handshake — negotiate DRAFT-2026-v1 so the client treats InputRequiredResult as MRTR.
648+
var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
649+
Assert.NotNull(initLine);
650+
var initRequest = JsonSerializer.Deserialize<JsonRpcRequest>(initLine, McpJsonUtilities.DefaultOptions);
651+
Assert.NotNull(initRequest);
652+
Assert.Equal("initialize", initRequest.Method);
653+
654+
var initResponse = new JsonRpcResponse
655+
{
656+
Id = initRequest.Id,
657+
Result = JsonSerializer.SerializeToNode(new InitializeResult
658+
{
659+
ProtocolVersion = "DRAFT-2026-v1",
660+
Capabilities = new ServerCapabilities { Tools = new() },
661+
ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" }
662+
}, McpJsonUtilities.DefaultOptions),
663+
};
664+
await WriteJsonRpcAsync(serverWriter, initResponse);
665+
666+
var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
667+
Assert.NotNull(initializedLine);
668+
669+
await using var client = await clientTask;
670+
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
671+
672+
var cancellationToken = TestContext.Current.CancellationToken;
673+
674+
// Capture the retry payloads sent by the client so we can inspect them after the call completes.
675+
JsonObject? retry1Params = null;
676+
JsonObject? retry2Params = null;
677+
678+
var serverLoop = Task.Run(async () =>
679+
{
680+
// --- Round 1: receive original tools/call, respond with InputRequiredResult + requestState="round1-state".
681+
var call1Line = await serverReader.ReadLineAsync(cancellationToken);
682+
Assert.NotNull(call1Line);
683+
var call1 = JsonSerializer.Deserialize<JsonRpcRequest>(call1Line, McpJsonUtilities.DefaultOptions);
684+
Assert.NotNull(call1);
685+
Assert.Equal("tools/call", call1.Method);
686+
687+
var round1Result = new JsonObject
688+
{
689+
["resultType"] = "input_required",
690+
["inputRequests"] = new JsonObject
691+
{
692+
["q1"] = JsonSerializer.SerializeToNode(
693+
InputRequest.ForElicitation(new ElicitRequestParams { Message = "round1", RequestedSchema = new() }),
694+
McpJsonUtilities.DefaultOptions),
695+
},
696+
["requestState"] = "round1-state",
697+
};
698+
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse { Id = call1.Id, Result = round1Result });
699+
700+
// --- Round 2: receive first retry (should include requestState="round1-state" + inputResponses).
701+
var call2Line = await serverReader.ReadLineAsync(cancellationToken);
702+
Assert.NotNull(call2Line);
703+
var call2 = JsonSerializer.Deserialize<JsonRpcRequest>(call2Line, McpJsonUtilities.DefaultOptions);
704+
Assert.NotNull(call2);
705+
retry1Params = call2.Params as JsonObject;
706+
707+
// Respond with another InputRequiredResult — this time WITHOUT requestState — to force the
708+
// client to clear any stale state on the next retry params clone.
709+
var round2Result = new JsonObject
710+
{
711+
["resultType"] = "input_required",
712+
["inputRequests"] = new JsonObject
713+
{
714+
["q2"] = JsonSerializer.SerializeToNode(
715+
InputRequest.ForElicitation(new ElicitRequestParams { Message = "round2", RequestedSchema = new() }),
716+
McpJsonUtilities.DefaultOptions),
717+
},
718+
// Intentionally NO "requestState" key.
719+
};
720+
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse { Id = call2.Id, Result = round2Result });
721+
722+
// --- Round 3: receive second retry — assertion target. Must NOT contain "requestState".
723+
var call3Line = await serverReader.ReadLineAsync(cancellationToken);
724+
Assert.NotNull(call3Line);
725+
var call3 = JsonSerializer.Deserialize<JsonRpcRequest>(call3Line, McpJsonUtilities.DefaultOptions);
726+
Assert.NotNull(call3);
727+
retry2Params = call3.Params as JsonObject;
728+
729+
// Final success response so the client's call completes cleanly.
730+
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse
731+
{
732+
Id = call3.Id,
733+
Result = JsonSerializer.SerializeToNode(new CallToolResult
734+
{
735+
Content = [new TextContentBlock { Text = "done" }]
736+
}, McpJsonUtilities.DefaultOptions),
737+
});
738+
}, cancellationToken);
739+
740+
var response = await client.SendRequestAsync(
741+
new JsonRpcRequest
742+
{
743+
Method = "tools/call",
744+
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "any-tool" }, McpJsonUtilities.DefaultOptions),
745+
},
746+
cancellationToken);
747+
748+
await serverLoop;
749+
750+
// Sanity check the final result reached us.
751+
Assert.NotNull(response.Result);
752+
var result = JsonSerializer.Deserialize<CallToolResult>(response.Result, McpJsonUtilities.DefaultOptions);
753+
Assert.NotNull(result);
754+
Assert.Equal("done", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
755+
756+
// The first retry must carry requestState="round1-state".
757+
Assert.NotNull(retry1Params);
758+
Assert.NotNull(retry1Params!["inputResponses"]);
759+
Assert.Equal("round1-state", retry1Params["requestState"]?.GetValue<string>());
760+
761+
// The second retry must NOT carry a stale requestState. Pre-fix, the deep clone of the
762+
// round-1 request kept "round1-state" in paramsObj because the client only OVERWROTE it
763+
// when InputRequiredResult.RequestState was non-null. With the fix, it explicitly removes
764+
// the key whenever the server's new InputRequiredResult clears it.
765+
Assert.NotNull(retry2Params);
766+
Assert.NotNull(retry2Params!["inputResponses"]);
767+
Assert.False(retry2Params.ContainsKey("requestState"),
768+
"Retry params must not carry a stale requestState from the previous round.");
769+
770+
clientToServer.Writer.Complete();
771+
serverToClient.Writer.Complete();
772+
}
773+
612774
private static async Task WriteJsonRpcAsync(Stream writer, JsonRpcMessage message)
613775
{
614776
var bytes = JsonSerializer.SerializeToUtf8Bytes<JsonRpcMessage>(message, McpJsonUtilities.DefaultOptions);
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System.Text.Json;
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.Server;
9+
10+
/// <summary>
11+
/// Tests for the legacy MRTR backcompat resolver in <c>McpServerImpl.InvokeWithInputRequiredResultHandlingAsync</c>.
12+
/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful —
13+
/// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler
14+
/// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks
15+
/// a non-draft version during initialize negotiation.
16+
/// </summary>
17+
public class MrtrServerBackcompatTests : ClientServerTestBase
18+
{
19+
private readonly List<string?> _observedRequestStates = [];
20+
private int _attempt;
21+
22+
public MrtrServerBackcompatTests(ITestOutputHelper testOutputHelper)
23+
: base(testOutputHelper, startServer: false)
24+
{
25+
}
26+
27+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
28+
{
29+
mcpServerBuilder.WithTools([
30+
McpServerTool.Create(
31+
(RequestContext<CallToolRequestParams> context) =>
32+
{
33+
var attempt = Interlocked.Increment(ref _attempt);
34+
_observedRequestStates.Add(context.Params?.RequestState);
35+
36+
return attempt switch
37+
{
38+
// Round 1: caller has no state; emit one and request elicitation.
39+
1 => throw new InputRequiredException(
40+
inputRequests: new Dictionary<string, InputRequest>
41+
{
42+
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
43+
{
44+
Message = "round1",
45+
RequestedSchema = new()
46+
})
47+
},
48+
requestState: "round1"),
49+
// Round 2: deliberately clear the state by passing requestState: null while still
50+
// asking for another elicitation. This exercises the params clone path that
51+
// previously preserved the stale "round1" carry-over from round 1's deep clone.
52+
2 => throw new InputRequiredException(
53+
inputRequests: new Dictionary<string, InputRequest>
54+
{
55+
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
56+
{
57+
Message = "round2",
58+
RequestedSchema = new()
59+
})
60+
},
61+
requestState: null),
62+
// Round 3 (final): report what the handler observed so the test can assert it.
63+
_ => $"final-state:{context.Params?.RequestState ?? "<null>"}",
64+
};
65+
},
66+
new McpServerToolCreateOptions
67+
{
68+
Name = "requeststate-transition",
69+
Description = "Tool that transitions requestState from set to null across MRTR rounds."
70+
}),
71+
]);
72+
}
73+
74+
[Fact]
75+
public async Task InputRequiredException_TransitioningRequestStateToNull_DoesNotLeakStaleState()
76+
{
77+
StartServer();
78+
79+
// Non-MRTR client → server falls into the legacy backcompat resolver path on InputRequiredException.
80+
var clientOptions = new McpClientOptions
81+
{
82+
ProtocolVersion = "2025-06-18",
83+
Capabilities = new ClientCapabilities { Elicitation = new() },
84+
};
85+
clientOptions.Handlers.ElicitationHandler = (_, _) =>
86+
new ValueTask<ElicitResult>(new ElicitResult
87+
{
88+
Action = "accept",
89+
Content = new Dictionary<string, JsonElement>
90+
{
91+
["answer"] = JsonDocument.Parse("\"ok\"").RootElement,
92+
},
93+
});
94+
95+
await using var client = await CreateMcpClientForServer(clientOptions);
96+
97+
var result = await client.CallToolAsync(
98+
"requeststate-transition",
99+
cancellationToken: TestContext.Current.CancellationToken);
100+
101+
// Three attempts: round 1 (no state) → round 2 (state="round1") → round 3 (state=null after fix).
102+
// Without the fix, the third observed state would erroneously remain "round1" because the deep-clone
103+
// of the prior request params carried it forward when InputRequiredException.RequestState was null.
104+
Assert.Equal(3, _observedRequestStates.Count);
105+
Assert.Null(_observedRequestStates[0]);
106+
Assert.Equal("round1", _observedRequestStates[1]);
107+
Assert.Null(_observedRequestStates[2]);
108+
109+
var content = Assert.Single(result.Content);
110+
var text = Assert.IsType<TextContentBlock>(content).Text;
111+
Assert.Equal("final-state:<null>", text);
112+
}
113+
}

0 commit comments

Comments
 (0)