using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; /// /// Verifies that when both and /// are configured and the handler returns /// (IsTask = true), the store's pre-created task is failed with a /// clear error rather than being orphaned in forever. /// public class TaskStoreOrphanedTaskTests : ClientServerTestBase { #pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); #endif } protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { mcpServerBuilder.WithTasks(new InMemoryMcpTaskStore()); mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); // Returning IsTask = true here while the tasks extension is also configured is the // misconfiguration the server must guard against. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => new ValueTask>( ResultOrAlternate.FromAlternate( new CreateTaskResult { TaskId = "user-task", Status = McpTaskStatus.Working, CreatedAt = DateTimeOffset.UtcNow, LastUpdatedAt = DateTimeOffset.UtcNow, }, s_createTaskResultTypeInfo)); }); } [Fact] public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearError() { await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; // The store's task is created synchronously and its taskId returned to the client. var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "anything" }, ct); Assert.True(augmented.IsTask); var taskId = augmented.TaskCreated!.TaskId; // Poll until the background runner observes the handler's IsTask=true and fails the // store's task. Without the fix this would loop forever in Working. GetTaskResult? taskResult = null; for (int i = 0; i < 40; i++) { await Task.Delay(50, ct); taskResult = await client.GetTaskAsync(taskId, ct); if (taskResult is FailedTaskResult) { break; } } var failed = Assert.IsType(taskResult); Assert.Equal(JsonValueKind.Object, failed.Error.ValueKind); Assert.Equal((int)McpErrorCode.InternalError, failed.Error.GetProperty("code").GetInt32()); var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains(nameof(IMcpTaskStore), message); Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } #pragma warning restore MCPEXP002 }