Skip to content

Commit 34f974b

Browse files
halter73Copilot
andcommitted
Add edge-case tests for MRTR backward compatibility and experimental mode
Backcompat tests (McpClientMrtrCompatTests): - 10-retry limit enforcement (tool that never completes) - Empty inputRequests dictionary triggers immediate error - Error propagation when client handler throws during resolve Experimental mode test (McpClientMrtrTests): - Client handler throws during MRTR input resolution: exception surfaces to caller, server logs cancelled MRTR continuation on disposal. This exercises a fundamental MRTR limitation where the client has no channel to communicate input resolution failures back to the server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 33d0db4 commit 34f974b

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

tests/ModelContextProtocol.Tests/Client/McpClientMrtrCompatTests.cs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

tests/ModelContextProtocol.Tests/Client/McpClientMrtrTests.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public McpClientMrtrTests(ITestOutputHelper testOutputHelper)
2929

3030
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
3131
{
32+
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
3233
services.Configure<McpServerOptions>(options =>
3334
{
3435
options.ExperimentalProtocolVersion = "2026-06-XX";
@@ -851,4 +852,44 @@ public async Task CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEn
851852
m.LogLevel == LogLevel.Error &&
852853
m.Exception is IncompleteResultException);
853854
}
855+
856+
[Fact]
857+
public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCaller()
858+
{
859+
// When the CLIENT's elicitation handler throws during MRTR input resolution,
860+
// the retry never reaches the server — the server's handler remains suspended
861+
// on ElicitAsync(). The exception should surface to the CallToolAsync caller,
862+
// and the server's orphaned handler should be cleaned up on disposal.
863+
// This is a fundamental MRTR limitation: the client has no channel to communicate
864+
// input resolution failures back to the server.
865+
StartServer();
866+
867+
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
868+
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
869+
{
870+
throw new InvalidOperationException("Client-side elicitation failure");
871+
};
872+
873+
await using var client = await CreateMcpClientForServer(clientOptions);
874+
Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion);
875+
876+
// The client handler throws during input resolution, so the exception
877+
// escapes ResolveInputRequestAsync and surfaces directly to the caller.
878+
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
879+
await client.CallToolAsync("elicitation-tool",
880+
new Dictionary<string, object?> { ["message"] = "Will fail" },
881+
cancellationToken: TestContext.Current.CancellationToken));
882+
883+
Assert.Equal("Client-side elicitation failure", ex.Message);
884+
885+
// Dispose the server to trigger cleanup of the orphaned MRTR continuation.
886+
// The server should cancel the handler suspended on ElicitAsync() and log
887+
// the cancelled continuation at Debug level.
888+
await Server.DisposeAsync();
889+
890+
Assert.Contains(MockLoggerProvider.LogMessages, m =>
891+
m.LogLevel == LogLevel.Debug &&
892+
m.Message.Contains("Cancelled") &&
893+
m.Message.Contains("MRTR continuation"));
894+
}
854895
}

0 commit comments

Comments
 (0)