Skip to content

Commit 5bf84d2

Browse files
halter73Copilot
andcommitted
Add raw-HTTP regression test for MRTR backcompat resolver routing
MrtrProtocolTests.BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream deliberately never opens a GET stream, so it deterministically fails if the server's backcompat resolver routes its outgoing roots/list request through the session-level transport instead of the POST's RelatedTransport. Verified the test hangs/fails with the fix reverted and passes with it applied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 17dc39c commit 5bf84d2

1 file changed

Lines changed: 151 additions & 0 deletions

File tree

tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,157 @@ public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult()
9393
Assert.Contains("Tool validation failed", error.Error.Message);
9494
}
9595

96+
/// <summary>
97+
/// Regression test for a CI hang where the server-side MRTR backcompat resolver routed its
98+
/// outgoing <c>roots/list</c> request through the session-level transport, which silently
99+
/// dropped the message when the client's GET stream had not been established yet. The
100+
/// outgoing request must instead go through the POST's response stream (the request's
101+
/// <see cref="ModelContextProtocol.Protocol.JsonRpcMessageContext.RelatedTransport"/>) so it
102+
/// reaches the client without depending on the GET stream at all.
103+
///
104+
/// This test deliberately never opens a GET stream — it only POSTs the initialize, the
105+
/// initialized notification, the <c>tools/call</c>, and the <c>roots/list</c> response. If the
106+
/// server falls back to <c>_transport.SendMessageAsync</c>, the test times out instead of
107+
/// reading the expected <c>roots/list</c> SSE event off the <c>tools/call</c> POST response.
108+
/// </summary>
109+
[Fact]
110+
public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream()
111+
{
112+
// Configure a server that does NOT pin DRAFT-2026-v1 so it can negotiate the current
113+
// protocol with a legacy client. The backcompat resolver path only runs when the
114+
// negotiated version is not DRAFT-2026-v1.
115+
Builder.Services.AddMcpServer(options =>
116+
{
117+
options.ServerInfo = new Implementation
118+
{
119+
Name = nameof(MrtrProtocolTests),
120+
Version = "1",
121+
};
122+
}).WithTools([
123+
McpServerTool.Create(
124+
static string (RequestContext<CallToolRequestParams> context) =>
125+
{
126+
if (context.Params!.InputResponses is { } responses &&
127+
responses.TryGetValue("roots", out var response))
128+
{
129+
var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots;
130+
return $"roots-ok:{roots?.FirstOrDefault()?.Name}";
131+
}
132+
133+
throw new InputRequiredException(
134+
inputRequests: new Dictionary<string, InputRequest>
135+
{
136+
["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams())
137+
},
138+
requestState: "roots-state");
139+
},
140+
new McpServerToolCreateOptions
141+
{
142+
Name = "backcompat-roots-tool",
143+
Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list",
144+
}),
145+
]).WithHttpTransport();
146+
147+
_app = Builder.Build();
148+
_app.MapMcp();
149+
await _app.StartAsync(TestContext.Current.CancellationToken);
150+
151+
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
152+
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
153+
154+
// Initialize with the current (non-draft) protocol so the server's backcompat resolver runs.
155+
var initJson = """
156+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}}
157+
""";
158+
159+
string sessionId;
160+
using (var initResponse = await PostJsonRpcAsync(initJson))
161+
{
162+
var initRpcResponse = await AssertSingleSseResponseAsync(initResponse);
163+
Assert.NotNull(initRpcResponse.Result);
164+
Assert.Equal("2025-11-25", initRpcResponse.Result["protocolVersion"]?.GetValue<string>());
165+
166+
sessionId = Assert.Single(initResponse.Headers.GetValues("mcp-session-id"));
167+
}
168+
169+
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
170+
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
171+
HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version");
172+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-11-25");
173+
174+
// Send the initialized notification.
175+
using (var initializedResponse = await PostJsonRpcAsync(
176+
"""{"jsonrpc":"2.0","method":"notifications/initialized"}"""))
177+
{
178+
Assert.True(initializedResponse.IsSuccessStatusCode);
179+
}
180+
181+
_lastRequestId = 1;
182+
183+
// POST the tools/call and start reading the response SSE stream. We deliberately do NOT
184+
// open a GET stream — the server-to-client roots/list must be delivered on this POST's
185+
// response. Use HttpCompletionOption.ResponseHeadersRead so the POST returns as soon as
186+
// the response headers arrive instead of waiting for the SSE stream to close.
187+
var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null)
188+
{
189+
Content = JsonContent(CallTool("backcompat-roots-tool")),
190+
};
191+
callRequest.Content.Headers.Add("Mcp-Method", "tools/call");
192+
callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool");
193+
194+
using var callResponse = await HttpClient.SendAsync(
195+
callRequest,
196+
HttpCompletionOption.ResponseHeadersRead,
197+
TestContext.Current.CancellationToken);
198+
199+
Assert.Equal(HttpStatusCode.OK, callResponse.StatusCode);
200+
Assert.Equal("text/event-stream", callResponse.Content.Headers.ContentType?.MediaType);
201+
202+
var sseEvents = ReadSseAsync(callResponse.Content)
203+
.GetAsyncEnumerator(TestContext.Current.CancellationToken);
204+
205+
try
206+
{
207+
// First SSE event on this POST should be the server-initiated roots/list request.
208+
Assert.True(await sseEvents.MoveNextAsync(),
209+
"Server did not send a roots/list request on the tools/call POST response stream. " +
210+
"If this hangs/times out, the MRTR backcompat resolver is routing the outgoing request " +
211+
"through the session-level transport instead of the POST's RelatedTransport.");
212+
213+
var rootsRequestNode = JsonNode.Parse(sseEvents.Current) as JsonObject;
214+
Assert.NotNull(rootsRequestNode);
215+
Assert.Equal("roots/list", rootsRequestNode["method"]?.GetValue<string>());
216+
var rootsRequestId = rootsRequestNode["id"];
217+
Assert.NotNull(rootsRequestId);
218+
219+
// POST the roots/list response on a separate connection. The server's pending
220+
// RequestRootsAsync await will complete and the backcompat resolver will retry the tool.
221+
var rootsIdLiteral = rootsRequestId.ToJsonString();
222+
var rootsResponseJson =
223+
"{\"jsonrpc\":\"2.0\",\"id\":" + rootsIdLiteral +
224+
",\"result\":{\"roots\":[{\"uri\":\"file:///workspace\",\"name\":\"Workspace\"}]}}";
225+
using (var rootsResponseHttp = await PostJsonRpcAsync(rootsResponseJson))
226+
{
227+
Assert.True(rootsResponseHttp.IsSuccessStatusCode);
228+
}
229+
230+
// Next SSE event on the original POST should be the final tools/call response.
231+
Assert.True(await sseEvents.MoveNextAsync(), "Server did not return the final tools/call response.");
232+
var finalResponse = JsonSerializer.Deserialize(sseEvents.Current, GetJsonTypeInfo<JsonRpcResponse>());
233+
Assert.NotNull(finalResponse);
234+
Assert.NotNull(finalResponse.Result);
235+
236+
var content = finalResponse.Result["content"]?.AsArray();
237+
Assert.NotNull(content);
238+
var firstContent = Assert.Single(content);
239+
Assert.Equal("roots-ok:Workspace", firstContent?["text"]?.GetValue<string>());
240+
}
241+
finally
242+
{
243+
await sseEvents.DisposeAsync();
244+
}
245+
}
246+
96247
// --- Helpers ---
97248

98249
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");

0 commit comments

Comments
 (0)