@@ -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