@@ -219,6 +219,67 @@ static string (RequestContext<CallToolRequestParams> context) =>
219219 Name = "native-roots" ,
220220 Description = "MRTR-native tool requesting roots/list"
221221 } ) ,
222+ McpServerTool . Create (
223+ static string ( RequestContext < CallToolRequestParams > context ) =>
224+ {
225+ // Always throws IncompleteResultException, never completes.
226+ throw new IncompleteResultException (
227+ inputRequests : new Dictionary < string , InputRequest >
228+ {
229+ [ "input" ] = InputRequest . ForElicitation ( new ElicitRequestParams
230+ {
231+ Message = "Infinite loop" ,
232+ RequestedSchema = new ( )
233+ } )
234+ } ,
235+ requestState : $ "attempt-{ context . Params ! . RequestState ?? "0" } ") ;
236+ } ,
237+ new McpServerToolCreateOptions
238+ {
239+ Name = "native-always-incomplete" ,
240+ Description = "MRTR-native tool that never completes"
241+ } ) ,
242+ McpServerTool . Create (
243+ static string ( RequestContext < CallToolRequestParams > context ) =>
244+ {
245+ // Throws IncompleteResultException with empty inputRequests dict.
246+ throw new IncompleteResultException ( new IncompleteResult
247+ {
248+ InputRequests = new Dictionary < string , InputRequest > ( ) ,
249+ RequestState = "some-state" ,
250+ } ) ;
251+ } ,
252+ new McpServerToolCreateOptions
253+ {
254+ Name = "native-empty-inputs" ,
255+ Description = "MRTR-native tool with empty inputRequests"
256+ } ) ,
257+ McpServerTool . Create (
258+ static string ( RequestContext < CallToolRequestParams > context ) =>
259+ {
260+ var inputResponses = context . Params ! . InputResponses ;
261+
262+ if ( inputResponses is not null )
263+ {
264+ return "should-not-reach" ;
265+ }
266+
267+ throw new IncompleteResultException (
268+ inputRequests : new Dictionary < string , InputRequest >
269+ {
270+ [ "user_input" ] = InputRequest . ForElicitation ( new ElicitRequestParams
271+ {
272+ Message = "Will fail" ,
273+ RequestedSchema = new ( )
274+ } )
275+ } ,
276+ requestState : "error-test" ) ;
277+ } ,
278+ new McpServerToolCreateOptions
279+ {
280+ Name = "native-elicit-for-error" ,
281+ Description = "MRTR-native tool for testing error propagation"
282+ } ) ,
222283 ] ) ;
223284 }
224285
@@ -457,4 +518,79 @@ public async Task CallToolAsync_MrtrNativeRootsList_ResolvedViaLegacyJsonRpc()
457518 var content = Assert . Single ( result . Content ) ;
458519 Assert . Equal ( "roots:MyProject" , Assert . IsType < TextContentBlock > ( content ) . Text ) ;
459520 }
521+
522+ [ Fact ]
523+ public async Task CallToolAsync_MrtrNativeAlwaysIncomplete_FailsAfterMaxRetries ( )
524+ {
525+ // Tool always throws IncompleteResultException. The backcompat layer should
526+ // give up after 10 retry rounds and throw McpException.
527+ int elicitCallCount = 0 ;
528+ StartServer ( ) ;
529+ var clientOptions = new McpClientOptions ( ) ;
530+ clientOptions . Handlers . ElicitationHandler = ( request , ct ) =>
531+ {
532+ elicitCallCount ++ ;
533+ return new ValueTask < ElicitResult > ( new ElicitResult
534+ {
535+ Action = "accept" ,
536+ Content = new Dictionary < string , JsonElement >
537+ {
538+ [ "value" ] = JsonDocument . Parse ( $ "\" { elicitCallCount } \" ") . RootElement . Clone ( )
539+ }
540+ } ) ;
541+ } ;
542+
543+ await using var client = await CreateMcpClientForServer ( clientOptions ) ;
544+ Assert . NotEqual ( "2026-06-XX" , client . NegotiatedProtocolVersion ) ;
545+
546+ var ex = await Assert . ThrowsAsync < McpProtocolException > ( async ( ) =>
547+ await client . CallToolAsync ( "native-always-incomplete" ,
548+ cancellationToken : TestContext . Current . CancellationToken ) ) ;
549+
550+ Assert . Contains ( "exceeded" , ex . Message , StringComparison . OrdinalIgnoreCase ) ;
551+ Assert . Contains ( "10" , ex . Message ) ;
552+ Assert . Equal ( 10 , elicitCallCount ) ;
553+ }
554+
555+ [ Fact ]
556+ public async Task CallToolAsync_MrtrNativeEmptyInputRequests_FailsWithMcpException ( )
557+ {
558+ // Tool throws IncompleteResultException with an empty inputRequests dictionary.
559+ // The backcompat layer should detect this and throw McpException immediately.
560+ StartServer ( ) ;
561+ var clientOptions = new McpClientOptions ( ) ;
562+
563+ await using var client = await CreateMcpClientForServer ( clientOptions ) ;
564+ Assert . NotEqual ( "2026-06-XX" , client . NegotiatedProtocolVersion ) ;
565+
566+ var ex = await Assert . ThrowsAsync < McpProtocolException > ( async ( ) =>
567+ await client . CallToolAsync ( "native-empty-inputs" ,
568+ cancellationToken : TestContext . Current . CancellationToken ) ) ;
569+
570+ Assert . Contains ( "without input requests" , ex . Message , StringComparison . OrdinalIgnoreCase ) ;
571+ }
572+
573+ [ Fact ]
574+ public async Task CallToolAsync_MrtrNativeElicitation_ClientHandlerThrows_PropagatesError ( )
575+ {
576+ // Client's elicitation handler throws. The error should propagate through
577+ // ResolveInputRequestAsync and surface as an McpException on the client.
578+ StartServer ( ) ;
579+ var clientOptions = new McpClientOptions ( ) ;
580+ clientOptions . Handlers . ElicitationHandler = ( request , ct ) =>
581+ {
582+ throw new InvalidOperationException ( "Client-side elicitation failure" ) ;
583+ } ;
584+
585+ await using var client = await CreateMcpClientForServer ( clientOptions ) ;
586+ Assert . NotEqual ( "2026-06-XX" , client . NegotiatedProtocolVersion ) ;
587+
588+ // The client handler's exception message doesn't survive the JSON-RPC round-trip.
589+ // The server sends elicitation → client handler throws → client returns JSON-RPC error
590+ // → server receives it as McpProtocolException → server re-throws → becomes JSON-RPC
591+ // error to the original call → client sees a double-wrapped error.
592+ var ex = await Assert . ThrowsAsync < McpProtocolException > ( async ( ) =>
593+ await client . CallToolAsync ( "native-elicit-for-error" ,
594+ cancellationToken : TestContext . Current . CancellationToken ) ) ;
595+ }
460596}
0 commit comments