using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.IO.Pipelines; namespace ModelContextProtocol.Tests.Server; /// /// Tests for automatic InputRequired status tracking when server-to-client /// requests (SampleAsync, ElicitAsync) are made during task-augmented tool execution. /// public class AutomaticInputRequiredStatusTests : LoggedTest { public AutomaticInputRequiredStatusTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } #pragma warning disable MCPEXP001 // Tasks feature is experimental [Fact] public async Task TaskStatus_TransitionsToInputRequired_DuringSampleAsync() { // Arrange var taskStore = new InMemoryMcpTaskStore(); var statusesDuringSampling = new List(); var samplingRequestReceived = new TaskCompletionSource(); var continueSampling = new TaskCompletionSource(); await using var fixture = new InputRequiredTestFixture( LoggerFactory, configureServer: (services, builder) => { services.AddSingleton(taskStore); services.Configure(options => { options.TaskStore = taskStore; options.SendTaskStatusNotifications = true; // Enable notifications }); // Tool that calls SampleAsync during execution builder.WithTools([McpServerTool.Create( async (string prompt, McpServer server, CancellationToken ct) => { // Call SampleAsync - this should trigger InputRequired status var result = await server.SampleAsync(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], MaxTokens = 100 }, ct); var textContent = result.Content.OfType().FirstOrDefault(); return textContent?.Text ?? "No response"; }, new McpServerToolCreateOptions { Name = "sampling-tool", Description = "A tool that uses sampling" })]); }, configureClient: clientOptions => { clientOptions.Handlers = new McpClientHandlers { SamplingHandler = async (request, progress, ct) => { // Signal that we received the sampling request samplingRequestReceived.TrySetResult(true); // Wait for permission to continue (so we can check status) await continueSampling.Task.WaitAsync(ct); return new CreateMessageResult { Content = [new TextContentBlock { Text = "Sampled response" }], Model = "test-model" }; } }; }); // Act - Call the tool as a task var mcpTask = await fixture.Client.CallToolAsTaskAsync( "sampling-tool", arguments: new Dictionary { ["prompt"] = "Hello" }, taskMetadata: new McpTaskMetadata(), progress: null, cancellationToken: TestContext.Current.CancellationToken); // Wait for the sampling request to be received by the client await samplingRequestReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // Check the task status while sampling is in progress var statusDuringSampling = await taskStore.GetTaskAsync( mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); if (statusDuringSampling is not null) { statusesDuringSampling.Add(statusDuringSampling.Status); } // Allow sampling to complete continueSampling.TrySetResult(true); // Wait for task to complete McpTask? finalStatus = null; int maxAttempts = 50; do { await Task.Delay(100, TestContext.Current.CancellationToken); finalStatus = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); maxAttempts--; } while (finalStatus?.Status == McpTaskStatus.Working && maxAttempts > 0); // Assert - Status should have been InputRequired during sampling Assert.Contains(McpTaskStatus.InputRequired, statusesDuringSampling); // Final status should be Completed Assert.NotNull(finalStatus); Assert.Equal(McpTaskStatus.Completed, finalStatus.Status); } [Fact] public async Task TaskStatus_TransitionsToInputRequired_DuringElicitAsync() { // Arrange var taskStore = new InMemoryMcpTaskStore(); var statusesDuringElicitation = new List(); var elicitationRequestReceived = new TaskCompletionSource(); var continueElicitation = new TaskCompletionSource(); await using var fixture = new InputRequiredTestFixture( LoggerFactory, configureServer: (services, builder) => { services.AddSingleton(taskStore); services.Configure(options => { options.TaskStore = taskStore; options.SendTaskStatusNotifications = true; }); // Tool that calls ElicitAsync during execution builder.WithTools([McpServerTool.Create( async (string message, McpServer server, CancellationToken ct) => { // Call ElicitAsync - this should trigger InputRequired status var result = await server.ElicitAsync(new ElicitRequestParams { Message = message, RequestedSchema = new() }, ct); return result.Action == "confirm" ? "Confirmed" : "Declined"; }, new McpServerToolCreateOptions { Name = "elicitation-tool", Description = "A tool that uses elicitation" })]); }, configureClient: clientOptions => { clientOptions.Handlers = new McpClientHandlers { ElicitationHandler = async (request, ct) => { // Signal that we received the elicitation request elicitationRequestReceived.TrySetResult(true); // Wait for permission to continue await continueElicitation.Task.WaitAsync(ct); return new ElicitResult { Action = "confirm" }; } }; }); // Act - Call the tool as a task var mcpTask = await fixture.Client.CallToolAsTaskAsync( "elicitation-tool", arguments: new Dictionary { ["message"] = "Please confirm" }, taskMetadata: new McpTaskMetadata(), progress: null, cancellationToken: TestContext.Current.CancellationToken); // Wait for the elicitation request to be received await elicitationRequestReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // Check the task status while elicitation is in progress var statusDuringElicitation = await taskStore.GetTaskAsync( mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); if (statusDuringElicitation is not null) { statusesDuringElicitation.Add(statusDuringElicitation.Status); } // Allow elicitation to complete continueElicitation.TrySetResult(true); // Wait for task to complete McpTask? finalStatus = null; int maxAttempts = 50; do { await Task.Delay(100, TestContext.Current.CancellationToken); finalStatus = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); maxAttempts--; } while (finalStatus?.Status == McpTaskStatus.Working && maxAttempts > 0); // Assert - Status should have been InputRequired during elicitation Assert.Contains(McpTaskStatus.InputRequired, statusesDuringElicitation); // Final status should be Completed Assert.NotNull(finalStatus); Assert.Equal(McpTaskStatus.Completed, finalStatus.Status); } [Fact] public async Task TaskStatus_ReturnsToWorking_AfterSamplingCompletes() { // Arrange var taskStore = new InMemoryMcpTaskStore(); var samplingCompleted = new TaskCompletionSource(); var checkStatusAfterSampling = new TaskCompletionSource(); await using var fixture = new InputRequiredTestFixture( LoggerFactory, configureServer: (services, builder) => { services.AddSingleton(taskStore); services.Configure(options => { options.TaskStore = taskStore; }); // Tool that calls SampleAsync and then waits builder.WithTools([McpServerTool.Create( async (string prompt, McpServer server, CancellationToken ct) => { // Call SampleAsync var result = await server.SampleAsync(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], MaxTokens = 100 }, ct); // Signal that sampling completed samplingCompleted.TrySetResult(true); // Wait so test can check status await checkStatusAfterSampling.Task.WaitAsync(ct); var textContent = result.Content.OfType().FirstOrDefault(); return textContent?.Text ?? "No response"; }, new McpServerToolCreateOptions { Name = "sampling-tool", Description = "A tool that uses sampling" })]); }, configureClient: clientOptions => { clientOptions.Handlers = new McpClientHandlers { SamplingHandler = (request, progress, ct) => { // Return immediately to let sampling complete return new ValueTask(new CreateMessageResult { Content = [new TextContentBlock { Text = "Response" }], Model = "test-model" }); } }; }); // Act - Call the tool as a task var mcpTask = await fixture.Client.CallToolAsTaskAsync( "sampling-tool", arguments: new Dictionary { ["prompt"] = "Hello" }, taskMetadata: new McpTaskMetadata(), progress: null, cancellationToken: TestContext.Current.CancellationToken); // Wait for sampling to complete inside the tool await samplingCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // Small delay to ensure status update is processed await Task.Delay(50, TestContext.Current.CancellationToken); // Check status after sampling completed (should be back to Working) var taskAfterSampling = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); // Allow tool to complete checkStatusAfterSampling.TrySetResult(true); // Assert - Status should be Working after sampling completes (before tool completes) Assert.NotNull(taskAfterSampling); Assert.Equal(McpTaskStatus.Working, taskAfterSampling.Status); } [Fact] public async Task TaskStatus_DoesNotChangeToInputRequired_ForNonTaskExecution() { // Arrange - When a tool is NOT executed as a task, SampleAsync should not change any task status var taskStore = new InMemoryMcpTaskStore(); var samplingCompleted = new TaskCompletionSource(); await using var fixture = new InputRequiredTestFixture( LoggerFactory, configureServer: (services, builder) => { services.AddSingleton(taskStore); services.Configure(options => { options.TaskStore = taskStore; }); // Tool that calls SampleAsync - note it doesn't have TaskSupport.Required so can be called directly builder.WithTools([McpServerTool.Create( async (string prompt, McpServer server, CancellationToken ct) => { var result = await server.SampleAsync(new CreateMessageRequestParams { Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], MaxTokens = 100 }, ct); samplingCompleted.TrySetResult(true); var textContent = result.Content.OfType().FirstOrDefault(); return textContent?.Text ?? "No response"; }, new McpServerToolCreateOptions { Name = "sampling-tool", Description = "A tool that uses sampling", Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } })]); }, configureClient: clientOptions => { clientOptions.Handlers = new McpClientHandlers { SamplingHandler = (request, progress, ct) => { return new ValueTask(new CreateMessageResult { Content = [new TextContentBlock { Text = "Response" }], Model = "test-model" }); } }; }); // Act - Call the tool DIRECTLY (not as a task) var result = await fixture.Client.CallToolAsync( "sampling-tool", arguments: new Dictionary { ["prompt"] = "Hello" }, cancellationToken: TestContext.Current.CancellationToken); await samplingCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // Assert - No task should exist (tool was not called as a task) var tasks = await taskStore.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Empty(tasks.Tasks); // And the result should still work Assert.NotNull(result); } #pragma warning restore MCPEXP001 /// /// Test fixture that supports both server and client configuration for InputRequired status tests. /// private sealed class InputRequiredTestFixture : IAsyncDisposable { private readonly Pipe _clientToServerPipe = new(); private readonly Pipe _serverToClientPipe = new(); private readonly IServiceProvider _serviceProvider; private readonly McpServer _server; private readonly Task _serverTask; private readonly CancellationTokenSource _cts; public McpClient Client { get; } public McpServer Server => _server; public InputRequiredTestFixture( ILoggerFactory loggerFactory, Action? configureServer = null, Action? configureClient = null) { _cts = new CancellationTokenSource(); // Configure server var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(loggerFactory); var builder = services .AddMcpServer() .WithStreamServerTransport( _clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream()); configureServer?.Invoke(services, builder); _serviceProvider = services.BuildServiceProvider(validateScopes: true); _server = _serviceProvider.GetRequiredService(); _serverTask = _server.RunAsync(_cts.Token); // Configure client var clientOptions = new McpClientOptions(); configureClient?.Invoke(clientOptions); // Create client synchronously (test code) Client = McpClient.CreateAsync( new StreamClientTransport( serverInput: _clientToServerPipe.Writer.AsStream(), _serverToClientPipe.Reader.AsStream(), loggerFactory), clientOptions: clientOptions, loggerFactory: loggerFactory, cancellationToken: TestContext.Current.CancellationToken).GetAwaiter().GetResult(); } public async ValueTask DisposeAsync() { await Client.DisposeAsync(); await _cts.CancelAsync(); _clientToServerPipe.Writer.Complete(); _serverToClientPipe.Writer.Complete(); try { await _serverTask; } catch (OperationCanceledException) { // Expected } if (_serviceProvider is IAsyncDisposable asyncDisposable) { await asyncDisposable.DisposeAsync(); } else if (_serviceProvider is IDisposable disposable) { disposable.Dispose(); } _cts.Dispose(); } } }