using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Tests.Server;
///
/// Tests for the MRTR server API - IsMrtrSupported, InputRequiredException,
/// and client auto-retry of incomplete results.
///
public class MrtrInputRequiredExceptionTests : ClientServerTestBase
{
private readonly ServerMessageTracker _messageTracker = new();
public MrtrInputRequiredExceptionTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper, startServer: false)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
services.Configure(options =>
{
options.ProtocolVersion = "2026-07-28";
_messageTracker.AddFilters(options.Filters.Message);
});
mcpServerBuilder.WithTools([
McpServerTool.Create(
static string (McpServer server) =>
{
throw new InputRequiredException(requestState: "should-not-work");
},
new McpServerToolCreateOptions
{
Name = "always-incomplete",
Description = "Tool that always throws InputRequiredException"
}),
]);
}
[Fact]
public async Task InputRequiredException_WithoutInputRequests_ExhaustsRetries()
{
StartServer();
var clientOptions = new McpClientOptions();
await using var client = await CreateMcpClientForServer(clientOptions);
// The always-incomplete tool throws InputRequiredException with only requestState
// and no inputRequests. The client has nothing to dispatch, so it keeps retrying
// with the same requestState until the retry budget is exhausted.
var exception = await Assert.ThrowsAsync(() =>
client.CallToolAsync("always-incomplete",
cancellationToken: TestContext.Current.CancellationToken).AsTask());
Assert.Contains("more than", exception.Message);
}
}
///
/// Companion to covering a native (MRTR-capable) round-trip where the
/// server RETURNS an through the alternate result path
/// () rather than throwing . The MRTR
/// client drives the round-trip and receives the final result.
///
public class MrtrReturnedInputRequiredResultNativeTests : ClientServerTestBase
{
private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo =
(JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult));
private int _attempt;
public MrtrReturnedInputRequiredResultNativeTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper, startServer: false)
{
}
#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
services.Configure(options =>
{
options.ProtocolVersion = "2026-07-28";
options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
{
Interlocked.Increment(ref _attempt);
// Retry round: the MRTR client re-sent the request with its responses and our requestState.
if (context.Params?.RequestState is not null)
{
return new ValueTask>(new CallToolResult
{
Content = [new TextContentBlock { Text = "resolved" }],
});
}
// First round: RETURN an InputRequiredResult through the alternate path. An MRTR client
// understands it natively and drives the round-trip.
var inputRequired = new InputRequiredResult
{
InputRequests = new Dictionary
{
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "need-input",
RequestedSchema = new(),
}),
},
RequestState = "round1",
};
return new ValueTask>(
ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo));
};
});
}
#pragma warning restore MCPEXP002
[Fact]
public async Task ReturnedInputRequiredResult_MrtrClient_RoundTripsToFinalResult()
{
StartServer();
var clientOptions = new McpClientOptions
{
Capabilities = new ClientCapabilities { Elicitation = new() },
};
clientOptions.Handlers.ElicitationHandler = (_, _) =>
new ValueTask(new ElicitResult { Action = "accept" });
await using var client = await CreateMcpClientForServer(clientOptions);
var result = await client.CallToolAsync(
"return-form",
cancellationToken: TestContext.Current.CancellationToken);
// Two handler invocations: initial (returned InputRequiredResult) + client-driven retry (final result).
Assert.Equal(2, _attempt);
var content = Assert.Single(result.Content);
Assert.Equal("resolved", Assert.IsType(content).Text);
}
}