Implement SEP-2663 Tasks Extension (#1579)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Pranav Senthilnathan 2026-06-11 13:10:45 -07:00 • committed by GitHub
commit dbb7a20176
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 8190 additions and 11405 deletions

View file

@ -1,478 +0,0 @@
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;
/// <summary>
/// Tests for automatic InputRequired status tracking when server-to-client
/// requests (SampleAsync, ElicitAsync) are made during task-augmented tool execution.
/// </summary>
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<McpTaskStatus>();
var samplingRequestReceived = new TaskCompletionSource<bool>();
var continueSampling = new TaskCompletionSource<bool>();
await using var fixture = new InputRequiredTestFixture(
LoggerFactory,
configureServer: (services, builder) =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(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<TextContentBlock>().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<string, object?> { ["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(TestConstants.DefaultTimeout, 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 is not McpTaskStatus.Completed && 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<McpTaskStatus>();
var elicitationRequestReceived = new TaskCompletionSource<bool>();
var continueElicitation = new TaskCompletionSource<bool>();
await using var fixture = new InputRequiredTestFixture(
LoggerFactory,
configureServer: (services, builder) =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(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<string, object?> { ["message"] = "Please confirm" },
taskMetadata: new McpTaskMetadata(),
progress: null,
cancellationToken: TestContext.Current.CancellationToken);
// Wait for the elicitation request to be received
await elicitationRequestReceived.Task.WaitAsync(TestConstants.DefaultTimeout, 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 is not McpTaskStatus.Completed && 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<bool>();
var checkStatusAfterSampling = new TaskCompletionSource<bool>();
await using var fixture = new InputRequiredTestFixture(
LoggerFactory,
configureServer: (services, builder) =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(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<TextContentBlock>().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<CreateMessageResult>(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<string, object?> { ["prompt"] = "Hello" },
taskMetadata: new McpTaskMetadata(),
progress: null,
cancellationToken: TestContext.Current.CancellationToken);
// Wait for sampling to complete inside the tool
await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, 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<bool>();
await using var fixture = new InputRequiredTestFixture(
LoggerFactory,
configureServer: (services, builder) =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(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<TextContentBlock>().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<CreateMessageResult>(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<string, object?> { ["prompt"] = "Hello" },
cancellationToken: TestContext.Current.CancellationToken);
await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, 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
/// <summary>
/// Test fixture that supports both server and client configuration for InputRequired status tests.
/// </summary>
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<ServiceCollection, IMcpServerBuilder>? configureServer = null,
Action<McpClientOptions>? 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<McpServer>();
_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();
}
}
}

View file

@ -1,762 +0,0 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for McpServer methods that query tasks on the client (Phase 4 implementation).
/// </summary>
public class McpServerTaskMethodsTests : LoggedTest
{
private readonly McpServerOptions _options;
public McpServerTaskMethodsTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
#if !NET
Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587");
#endif
_options = CreateOptions();
}
private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = null)
{
return new McpServerOptions
{
ProtocolVersion = "2024",
InitializationTimeout = TimeSpan.FromSeconds(30),
Capabilities = capabilities,
};
}
#region SampleAsTaskAsync Tests
[Fact]
public async Task SampleAsTaskAsync_ThrowsException_WhenClientDoesNotSupportSampling()
{
// Arrange
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.SampleAsTaskAsync(
new CreateMessageRequestParams { Messages = [], MaxTokens = 1000 },
new McpTaskMetadata(),
CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task SampleAsTaskAsync_ThrowsException_WhenClientDoesNotSupportTaskAugmentedSampling()
{
// Arrange - Client supports sampling but NOT task-augmented sampling
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Sampling = new SamplingCapability(),
// Note: No Tasks capability
}, TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.SampleAsTaskAsync(
new CreateMessageRequestParams { Messages = [], MaxTokens = 1000 },
new McpTaskMetadata(),
CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task SampleAsTaskAsync_SendsRequest_WhenClientSupportsTaskAugmentedSampling()
{
// Arrange
await using var transport = new TestServerTransport();
// Configure transport to return a task result for sampling
transport.MockTask = new McpTask
{
TaskId = "sample-task-123",
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Sampling = new SamplingCapability(),
Tasks = new McpTasksCapability
{
Requests = new RequestMcpTasksCapability
{
Sampling = new SamplingMcpTasksCapability
{
CreateMessage = new CreateMessageMcpTasksCapability()
}
}
}
}, TestContext.Current.CancellationToken);
// Act
var task = await server.SampleAsTaskAsync(
new CreateMessageRequestParams { Messages = [], MaxTokens = 1000 },
new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(5) },
TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(task);
Assert.Equal("sample-task-123", task.TaskId);
Assert.Equal(McpTaskStatus.Working, task.Status);
// Verify the request was sent with task metadata
var samplingRequest = transport.SentMessages.OfType<JsonRpcRequest>()
.FirstOrDefault(r => r.Method == RequestMethods.SamplingCreateMessage);
Assert.NotNull(samplingRequest);
var requestParams = JsonSerializer.Deserialize<CreateMessageRequestParams>(
samplingRequest.Params, McpJsonUtilities.DefaultOptions);
Assert.NotNull(requestParams?.Task);
await transport.DisposeAsync();
await runTask;
}
#endregion
#region ElicitAsTaskAsync Tests
[Fact]
public async Task ElicitAsTaskAsync_ThrowsException_WhenClientDoesNotSupportElicitation()
{
// Arrange
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.ElicitAsTaskAsync(
new ElicitRequestParams { Message = "test", RequestedSchema = new() },
new McpTaskMetadata(),
CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task ElicitAsTaskAsync_ThrowsException_WhenClientDoesNotSupportTaskAugmentedElicitation()
{
// Arrange - Client supports elicitation but NOT task-augmented elicitation
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Elicitation = new ElicitationCapability { Form = new() },
// Note: No Tasks capability
}, TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.ElicitAsTaskAsync(
new ElicitRequestParams { Message = "test", RequestedSchema = new() },
new McpTaskMetadata(),
CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task ElicitAsTaskAsync_SendsRequest_WhenClientSupportsTaskAugmentedElicitation()
{
// Arrange
await using var transport = new TestServerTransport();
// Configure transport to return a task result for elicitation
transport.MockTask = new McpTask
{
TaskId = "elicit-task-456",
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Elicitation = new ElicitationCapability { Form = new() },
Tasks = new McpTasksCapability
{
Requests = new RequestMcpTasksCapability
{
Elicitation = new ElicitationMcpTasksCapability
{
Create = new CreateElicitationMcpTasksCapability()
}
}
}
}, TestContext.Current.CancellationToken);
// Act
var task = await server.ElicitAsTaskAsync(
new ElicitRequestParams { Message = "Please provide input", RequestedSchema = new() },
new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) },
TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(task);
Assert.Equal("elicit-task-456", task.TaskId);
Assert.Equal(McpTaskStatus.Working, task.Status);
await transport.DisposeAsync();
await runTask;
}
#endregion
#region GetTaskAsync Tests
[Fact]
public async Task GetTaskAsync_ThrowsException_WhenClientDoesNotSupportTasks()
{
// Arrange
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.GetTaskAsync("task-id", CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task GetTaskAsync_SendsRequest_AndReturnsTask()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTask = new McpTask
{
TaskId = "client-task-789",
Status = McpTaskStatus.Completed,
StatusMessage = "Task completed successfully",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act
var task = await server.GetTaskAsync("client-task-789", TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(task);
Assert.Equal("client-task-789", task.TaskId);
Assert.Equal(McpTaskStatus.Completed, task.Status);
// Verify the request was sent
var taskRequest = transport.SentMessages.OfType<JsonRpcRequest>()
.FirstOrDefault(r => r.Method == RequestMethods.TasksGet);
Assert.NotNull(taskRequest);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task GetTaskAsync_ThrowsArgumentException_WhenTaskIdIsEmpty()
{
// Arrange
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
await server.GetTaskAsync("", CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
#endregion
#region GetTaskResultAsync Tests
[Fact]
public async Task GetTaskResultAsync_ThrowsException_WhenClientDoesNotSupportTasks()
{
// Arrange
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.GetTaskResultAsync<CreateMessageResult>("task-id", cancellationToken: CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task GetTaskResultAsync_ReturnsDeserializedResult()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTaskResult = new CreateMessageResult
{
Content = [new TextContentBlock { Text = "Hello from task result!" }],
Model = "gpt-4"
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act
var result = await server.GetTaskResultAsync<CreateMessageResult>(
"task-id", cancellationToken: TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
Assert.Equal("gpt-4", result.Model);
Assert.Single(result.Content);
var textContent = Assert.IsType<TextContentBlock>(result.Content[0]);
Assert.Equal("Hello from task result!", textContent.Text);
await transport.DisposeAsync();
await runTask;
}
#endregion
#region ListTasksAsync Tests
[Fact]
public async Task ListTasksAsync_ThrowsException_WhenClientDoesNotSupportTasks()
{
// Arrange
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.ListTasksAsync(CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task ListTasksAsync_ThrowsException_WhenClientDoesNotSupportTaskListing()
{
// Arrange - Client supports tasks but NOT task listing
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability
{
// Note: No List capability
}
}, TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.ListTasksAsync(CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task ListTasksAsync_ReturnsTaskList()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTaskList =
[
new McpTask
{
TaskId = "task-a",
Status = McpTaskStatus.Completed,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
LastUpdatedAt = DateTimeOffset.UtcNow,
},
new McpTask
{
TaskId = "task-b",
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastUpdatedAt = DateTimeOffset.UtcNow,
},
new McpTask
{
TaskId = "task-c",
Status = McpTaskStatus.Failed,
StatusMessage = "Task failed",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-2),
LastUpdatedAt = DateTimeOffset.UtcNow,
}
];
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability
{
List = new ListMcpTasksCapability()
}
}, TestContext.Current.CancellationToken);
// Act
var tasks = await server.ListTasksAsync(TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(tasks);
Assert.Equal(3, tasks.Count);
Assert.Equal("task-a", tasks[0].TaskId);
Assert.Equal("task-b", tasks[1].TaskId);
Assert.Equal("task-c", tasks[2].TaskId);
await transport.DisposeAsync();
await runTask;
}
#endregion
#region CancelTaskAsync Tests
[Fact]
public async Task CancelTaskAsync_ThrowsException_WhenClientDoesNotSupportTasks()
{
// Arrange
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.CancelTaskAsync("task-id", CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task CancelTaskAsync_ThrowsException_WhenClientDoesNotSupportTaskCancellation()
{
// Arrange - Client supports tasks but NOT task cancellation
await using var transport = new TestServerTransport();
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability
{
// Note: No Cancel capability
}
}, TestContext.Current.CancellationToken);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await server.CancelTaskAsync("task-id", CancellationToken.None));
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task CancelTaskAsync_SendsRequest_AndReturnsCancelledTask()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTask = new McpTask
{
TaskId = "task-to-cancel",
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-3),
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability
{
Cancel = new CancelMcpTasksCapability()
}
}, TestContext.Current.CancellationToken);
// Act
var task = await server.CancelTaskAsync("task-to-cancel", TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(task);
Assert.Equal("task-to-cancel", task.TaskId);
Assert.Equal(McpTaskStatus.Cancelled, task.Status);
// Verify the request was sent
var cancelRequest = transport.SentMessages.OfType<JsonRpcRequest>()
.FirstOrDefault(r => r.Method == RequestMethods.TasksCancel);
Assert.NotNull(cancelRequest);
await transport.DisposeAsync();
await runTask;
}
#endregion
#region PollTaskUntilCompleteAsync Tests
[Fact]
public async Task PollTaskUntilCompleteAsync_ReturnsImmediately_WhenTaskIsAlreadyComplete()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTask = new McpTask
{
TaskId = "completed-task",
Status = McpTaskStatus.Completed,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act
var task = await server.PollTaskUntilCompleteAsync("completed-task", TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(task);
Assert.Equal("completed-task", task.TaskId);
Assert.Equal(McpTaskStatus.Completed, task.Status);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task PollTaskUntilCompleteAsync_ReturnsTask_WhenTaskFails()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTask = new McpTask
{
TaskId = "failed-task",
Status = McpTaskStatus.Failed,
StatusMessage = "Task execution failed",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act
var task = await server.PollTaskUntilCompleteAsync("failed-task", TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(task);
Assert.Equal("failed-task", task.TaskId);
Assert.Equal(McpTaskStatus.Failed, task.Status);
Assert.Equal("Task execution failed", task.StatusMessage);
await transport.DisposeAsync();
await runTask;
}
#endregion
#region WaitForTaskResultAsync Tests
[Fact]
public async Task WaitForTaskResultAsync_ReturnsTaskAndResult_WhenTaskCompletes()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTask = new McpTask
{
TaskId = "task-with-result",
Status = McpTaskStatus.Completed,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastUpdatedAt = DateTimeOffset.UtcNow,
};
transport.MockTaskResult = new CreateMessageResult
{
Content = [new TextContentBlock { Text = "Final result from task" }],
Model = "test-model"
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act
var (task, result) = await server.WaitForTaskResultAsync<CreateMessageResult>(
"task-with-result", cancellationToken: TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(task);
Assert.Equal("task-with-result", task.TaskId);
Assert.Equal(McpTaskStatus.Completed, task.Status);
Assert.NotNull(result);
Assert.Equal("test-model", result.Model);
var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
Assert.Equal("Final result from task", textContent.Text);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task WaitForTaskResultAsync_ThrowsException_WhenTaskFails()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTask = new McpTask
{
TaskId = "failed-task",
Status = McpTaskStatus.Failed,
StatusMessage = "Something went wrong",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act & Assert
var ex = await Assert.ThrowsAsync<McpException>(async () =>
await server.WaitForTaskResultAsync<CreateMessageResult>(
"failed-task", cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("failed", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Something went wrong", ex.Message);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task WaitForTaskResultAsync_ThrowsException_WhenTaskIsCancelled()
{
// Arrange
await using var transport = new TestServerTransport();
transport.MockTask = new McpTask
{
TaskId = "cancelled-task",
Status = McpTaskStatus.Cancelled,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastUpdatedAt = DateTimeOffset.UtcNow,
};
await using var server = McpServer.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await InitializeServerAsync(transport, new ClientCapabilities
{
Tasks = new McpTasksCapability()
}, TestContext.Current.CancellationToken);
// Act & Assert
var ex = await Assert.ThrowsAsync<McpException>(async () =>
await server.WaitForTaskResultAsync<CreateMessageResult>(
"cancelled-task", cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("cancelled", ex.Message, StringComparison.OrdinalIgnoreCase);
await transport.DisposeAsync();
await runTask;
}
#endregion
#region Helper Methods
private static async Task InitializeServerAsync(TestServerTransport transport, ClientCapabilities capabilities, CancellationToken cancellationToken = default)
{
var initializeRequest = new JsonRpcRequest
{
Id = new RequestId("init-1"),
Method = RequestMethods.Initialize,
Params = JsonSerializer.SerializeToNode(new InitializeRequestParams
{
ProtocolVersion = "2024-11-05",
Capabilities = capabilities,
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }
}, McpJsonUtilities.DefaultOptions)
};
var tcs = new TaskCompletionSource<bool>();
transport.OnMessageSent = (message) =>
{
if (message is JsonRpcResponse response && response.Id == initializeRequest.Id)
{
tcs.TrySetResult(true);
}
};
await transport.SendClientMessageAsync(initializeRequest, cancellationToken);
// Wait for the initialize response to be sent
await tcs.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
}
#endregion
}

View file

@ -1,152 +0,0 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Collections.Concurrent;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for task status notification functionality in McpServer.
/// </summary>
public class McpServerTaskNotificationTests : ClientServerTestBase
{
public McpServerTaskNotificationTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
}
[Fact]
public async Task NotifyTaskStatusAsync_SendsNotificationWithTaskDetails()
{
// Arrange
var client = await CreateMcpClientForServer();
var tcs = new TaskCompletionSource<McpTaskStatusNotificationParams>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var registration = client.RegisterNotificationHandler(
NotificationMethods.TaskStatusNotification,
(notification, cancellationToken) =>
{
if (notification.Params is { } paramsNode)
{
var notificationParams = JsonSerializer.Deserialize<McpTaskStatusNotificationParams>(paramsNode, McpJsonUtilities.DefaultOptions);
if (notificationParams is not null)
{
tcs.TrySetResult(notificationParams);
}
}
return default;
});
var mcpTask = new McpTask
{
TaskId = "task-123",
Status = McpTaskStatus.Working,
StatusMessage = "Processing request",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
LastUpdatedAt = DateTimeOffset.UtcNow,
TimeToLive = TimeSpan.FromMinutes(10),
PollInterval = TimeSpan.FromSeconds(1)
};
// Act
await Server.NotifyTaskStatusAsync(mcpTask, TestContext.Current.CancellationToken);
var notification = await tcs.Task.WaitAsync(TestContext.Current.CancellationToken);
// Assert
Assert.Equal(mcpTask.TaskId, notification.TaskId);
Assert.Equal(mcpTask.Status, notification.Status);
Assert.Equal(mcpTask.StatusMessage, notification.StatusMessage);
Assert.Equal(mcpTask.CreatedAt, notification.CreatedAt);
Assert.Equal(mcpTask.LastUpdatedAt, notification.LastUpdatedAt);
Assert.Equal(mcpTask.TimeToLive, notification.TimeToLive);
Assert.Equal(mcpTask.PollInterval, notification.PollInterval);
}
[Fact]
public async Task NotifyTaskStatusAsync_ThrowsOnNullTask()
{
// Arrange
await CreateMcpClientForServer();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(
() => Server.NotifyTaskStatusAsync(null!, TestContext.Current.CancellationToken));
}
[Fact]
public async Task NotifyTaskStatusAsync_SendsMultipleNotificationsForDifferentStatuses()
{
// Arrange
var client = await CreateMcpClientForServer();
var receivedNotifications = new ConcurrentBag<McpTaskStatusNotificationParams>();
int expectedCount = 3;
var allReceivedTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var registration = client.RegisterNotificationHandler(
NotificationMethods.TaskStatusNotification,
(notification, cancellationToken) =>
{
if (notification.Params is { } paramsNode)
{
var notificationParams = JsonSerializer.Deserialize<McpTaskStatusNotificationParams>(paramsNode, McpJsonUtilities.DefaultOptions);
if (notificationParams is not null)
{
receivedNotifications.Add(notificationParams);
if (receivedNotifications.Count >= expectedCount)
{
allReceivedTcs.TrySetResult(true);
}
}
}
return default;
});
// Act - Send notifications for different statuses
var task1 = new McpTask
{
TaskId = "task-456",
Status = McpTaskStatus.Working,
StatusMessage = "Starting",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
LastUpdatedAt = DateTimeOffset.UtcNow,
TimeToLive = TimeSpan.FromMinutes(10),
PollInterval = TimeSpan.FromSeconds(1)
};
var task2 = new McpTask
{
TaskId = "task-456",
Status = McpTaskStatus.Working,
StatusMessage = "Processing",
CreatedAt = task1.CreatedAt,
LastUpdatedAt = DateTimeOffset.UtcNow,
TimeToLive = TimeSpan.FromMinutes(10),
PollInterval = TimeSpan.FromSeconds(1)
};
var task3 = new McpTask
{
TaskId = "task-456",
Status = McpTaskStatus.Completed,
StatusMessage = "Done",
CreatedAt = task1.CreatedAt,
LastUpdatedAt = DateTimeOffset.UtcNow,
TimeToLive = TimeSpan.FromMinutes(10),
PollInterval = TimeSpan.FromSeconds(1)
};
await Server.NotifyTaskStatusAsync(task1, TestContext.Current.CancellationToken);
await Server.NotifyTaskStatusAsync(task2, TestContext.Current.CancellationToken);
await Server.NotifyTaskStatusAsync(task3, TestContext.Current.CancellationToken);
// Wait for all notifications to be received
await allReceivedTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
// Assert
Assert.Equal(3, receivedNotifications.Count);
Assert.Contains(receivedNotifications, n => n.Status == McpTaskStatus.Working && n.StatusMessage == "Starting");
Assert.Contains(receivedNotifications, n => n.Status == McpTaskStatus.Working && n.StatusMessage == "Processing");
Assert.Contains(receivedNotifications, n => n.Status == McpTaskStatus.Completed && n.StatusMessage == "Done");
}
}

View file

@ -0,0 +1,655 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for the MCP tasks extension (SEP-2663) end-to-end using a simple in-memory task store.
/// </summary>
public class McpServerTaskTests : ClientServerTestBase
{
private readonly InMemoryTaskStore _taskStore = new();
private JsonObject? _capturedMeta;
public McpServerTaskTests(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)
{
services.AddSingleton(_taskStore);
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
{
options.Capabilities ??= new ServerCapabilities();
options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) =>
{
_capturedMeta = context.Params?.Meta;
var store = context.Server.Services!.GetRequiredService<InMemoryTaskStore>();
var toolName = context.Params!.Name;
if (toolName == "immediate-tool")
{
return new CallToolResult()
{
Content = [new TextContentBlock { Text = "immediate result" }],
};
}
if (toolName == "async-tool")
{
var taskId = store.CreateTask();
return new CreateTaskResult
{
TaskId = taskId,
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
PollIntervalMs = 50,
ResultType = "task",
};
}
if (toolName == "input-required-tool")
{
var taskId = store.CreateTask(McpTaskStatus.InputRequired);
return new CreateTaskResult
{
TaskId = taskId,
Status = McpTaskStatus.InputRequired,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
PollIntervalMs = 50,
ResultType = "task",
};
}
throw new McpException($"Unknown tool: {toolName}");
};
options.Handlers.GetTaskHandler = async (context, cancellationToken) =>
{
var store = context.Server.Services!.GetRequiredService<InMemoryTaskStore>();
var taskId = context.Params!.TaskId;
return store.GetTask(taskId);
};
options.Handlers.UpdateTaskHandler = async (context, cancellationToken) =>
{
var store = context.Server.Services!.GetRequiredService<InMemoryTaskStore>();
var taskId = context.Params!.TaskId;
store.ProvideInput(taskId, context.Params.InputResponses ?? new Dictionary<string, InputResponse>());
return new UpdateTaskResult();
};
options.Handlers.CancelTaskHandler = async (context, cancellationToken) =>
{
var store = context.Server.Services!.GetRequiredService<InMemoryTaskStore>();
var taskId = context.Params!.TaskId;
store.CancelTask(taskId);
return new CancelTaskResult();
};
});
}
[Fact]
public async Task CallToolAsync_ImmediateResult_ReturnsDirectly()
{
await using var client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "immediate-tool" },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Content);
Assert.Equal("immediate result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask()
{
await using var client = await CreateMcpClientForServer();
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "immediate-tool" },
TestContext.Current.CancellationToken);
Assert.False(augmented.IsTask);
Assert.NotNull(augmented.Result);
Assert.Null(augmented.TaskCreated);
Assert.Equal("immediate result", Assert.IsType<TextContentBlock>(augmented.Result.Content[0]).Text);
}
[Fact]
public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated()
{
await using var client = await CreateMcpClientForServer();
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
Assert.True(augmented.IsTask);
Assert.NotNull(augmented.TaskCreated);
Assert.Null(augmented.Result);
Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status);
Assert.Equal("task", augmented.TaskCreated.ResultType);
}
[Fact]
public async Task CallToolAsync_AsyncTool_PollsUntilCompleted()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
// Complete the task after a brief delay so polling finds it.
_ = Task.Run(async () =>
{
await Task.Delay(100, ct);
// The store should have exactly one task by now
var taskId = _taskStore.GetAllTaskIds().Single();
_taskStore.CompleteTask(taskId, new CallToolResult
{
Content = [new TextContentBlock { Text = "async result" }],
});
}, ct);
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "async-tool" },
ct);
Assert.NotNull(result);
Assert.Single(result.Content);
Assert.Equal("async result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
_ = Task.Run(async () =>
{
await Task.Delay(100, ct);
var taskId = _taskStore.GetAllTaskIds().Single();
_taskStore.FailTask(taskId, JsonElement.Parse("""{"code":-32000,"message":"something went wrong"}"""));
}, ct);
await Assert.ThrowsAsync<McpException>(async () =>
await client.CallToolAsync(
new CallToolRequestParams { Name = "async-tool" },
ct));
}
[Fact]
public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
_ = Task.Run(async () =>
{
await Task.Delay(100, ct);
var taskId = _taskStore.GetAllTaskIds().Single();
_taskStore.CancelTask(taskId);
}, ct);
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
await client.CallToolAsync(
new CallToolRequestParams { Name = "async-tool" },
ct));
}
[Fact]
public async Task GetTaskAsync_ReturnsCurrentState()
{
await using var client = await CreateMcpClientForServer();
// Create a task via CallToolRawAsync
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
var taskId = augmented.TaskCreated!.TaskId;
// Should be working
var taskResult = await client.GetTaskAsync(taskId, TestContext.Current.CancellationToken);
Assert.IsType<WorkingTaskResult>(taskResult);
Assert.Equal(taskId, taskResult.TaskId);
Assert.Equal(McpTaskStatus.Working, taskResult.Status);
}
[Fact]
public async Task CancelTaskAsync_CancelsTask()
{
await using var client = await CreateMcpClientForServer();
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
var taskId = augmented.TaskCreated!.TaskId;
// Cancel via client
var cancelResult = await client.CancelTaskAsync(taskId, TestContext.Current.CancellationToken);
Assert.NotNull(cancelResult);
// Verify state changed
var taskResult = await client.GetTaskAsync(taskId, TestContext.Current.CancellationToken);
Assert.IsType<CancelledTaskResult>(taskResult);
}
[Fact]
public async Task ConfigureTasks_AdvertisesExtensionInCapabilities()
{
await using var client = await CreateMcpClientForServer();
// The server advertises the tasks extension during initialize.
// The client should see it in server capabilities after the handshake.
#pragma warning disable MCP_EXTENSIONS
var extensions = client.ServerCapabilities.Extensions;
#pragma warning restore MCP_EXTENSIONS
Assert.NotNull(extensions);
Assert.True(extensions.ContainsKey(McpExtensions.Tasks));
}
[Fact]
public async Task CreateTaskResult_HasResultTypeTask()
{
await using var client = await CreateMcpClientForServer();
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
Assert.True(augmented.IsTask);
Assert.Equal("task", augmented.TaskCreated!.ResultType);
}
[Fact]
public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves()
{
// Strong consistency: tasks/get immediately after CreateTaskResult must resolve.
await using var client = await CreateMcpClientForServer();
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
var taskId = augmented.TaskCreated!.TaskId;
// No delay — immediate get
var taskResult = await client.GetTaskAsync(taskId, TestContext.Current.CancellationToken);
Assert.NotNull(taskResult);
Assert.Equal(taskId, taskResult.TaskId);
}
[Fact]
public async Task GetTaskAsync_UnknownTaskId_ThrowsWithInvalidParams()
{
await using var client = await CreateMcpClientForServer();
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.GetTaskAsync("nonexistent-task-id-12345", TestContext.Current.CancellationToken));
// The server should reject with an error referencing the unknown task
Assert.Contains("Unknown task", ex.Message);
}
[Fact]
public async Task CancelTask_AlreadyTerminal_AcknowledgesIdempotently()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "async-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
// Cancel once
await client.CancelTaskAsync(taskId, ct);
// Cancel again on terminal task — should not throw, returns ack
var ack = await client.CancelTaskAsync(taskId, ct);
Assert.NotNull(ack);
}
[Fact]
public async Task UpdateTaskAsync_TransitionsFromInputRequired()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
// Create an input-required task
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "input-required-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
// Verify it's input_required
var taskResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<InputRequiredTaskResult>(taskResult);
// Provide input
var inputResponses = new Dictionary<string, InputResponse>
{
["resp-1"] = new InputResponse { RawValue = JsonElement.Parse("""{"answer":"yes"}""") }
};
await client.UpdateTaskAsync(new UpdateTaskRequestParams
{
TaskId = taskId,
InputResponses = inputResponses,
}, ct);
// Verify it transitioned back to working
taskResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<WorkingTaskResult>(taskResult);
}
[Fact]
public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta()
{
// Verify the server receives the task extension in _meta by intercepting
// the handler. The CallToolWithTaskHandler already receives the request,
// so we can observe the meta there. We test the client-side injection indirectly
// by confirming the server returns a task result (which requires the capability signal).
await using var client = await CreateMcpClientForServer();
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
// If the capability wasn't injected, the server couldn't have returned a task
Assert.True(augmented.IsTask);
}
[Fact]
public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope()
{
// SEP-2663 §51: the per-request opt-in is the SEP-2575 capabilities envelope:
// _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {}
// This test pins the literal wire path so future refactors can't regress.
await using var client = await CreateMcpClientForServer();
await client.CallToolRawAsync(
new CallToolRequestParams { Name = "immediate-tool" },
TestContext.Current.CancellationToken);
Assert.NotNull(_capturedMeta);
var caps = Assert.IsType<JsonObject>(_capturedMeta!["io.modelcontextprotocol/clientCapabilities"]);
var extensions = Assert.IsType<JsonObject>(caps["extensions"]);
Assert.True(extensions.ContainsKey("io.modelcontextprotocol/tasks"),
"Expected _meta to contain io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks (SEP-2575 envelope).");
// The opt-in value is an empty object per SEP-2575.
Assert.IsType<JsonObject>(extensions["io.modelcontextprotocol/tasks"]);
}
[Fact]
public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings()
{
// User-supplied _meta entries at the root must not be clobbered, and the SEP-2575
// envelope must be added alongside them, not in place of them.
await using var client = await CreateMcpClientForServer();
var userMeta = new JsonObject
{
["customKey"] = "customValue",
["io.modelcontextprotocol/clientCapabilities"] = new JsonObject
{
["extensions"] = new JsonObject
{
["some.other/extension"] = new JsonObject(),
},
},
};
await client.CallToolRawAsync(
new CallToolRequestParams
{
Name = "immediate-tool",
Meta = userMeta,
},
TestContext.Current.CancellationToken);
Assert.NotNull(_capturedMeta);
// User's sibling root entry is preserved.
Assert.Equal("customValue", (string?)_capturedMeta!["customKey"]);
// User's pre-existing nested extension is preserved next to the tasks opt-in.
var caps = Assert.IsType<JsonObject>(_capturedMeta["io.modelcontextprotocol/clientCapabilities"]);
var extensions = Assert.IsType<JsonObject>(caps["extensions"]);
Assert.True(extensions.ContainsKey("some.other/extension"));
Assert.True(extensions.ContainsKey("io.modelcontextprotocol/tasks"));
}
[Fact]
public async Task CallToolRawAsync_PreservesExistingUserMeta()
{
// Verify that user-supplied meta fields are not clobbered
await using var client = await CreateMcpClientForServer();
var userMeta = new JsonObject { ["customKey"] = "customValue" };
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams
{
Name = "immediate-tool",
Meta = userMeta,
},
TestContext.Current.CancellationToken);
// Should still work — the meta was cloned, not destructively modified
Assert.False(augmented.IsTask);
Assert.Equal("immediate result", Assert.IsType<TextContentBlock>(augmented.Result!.Content[0]).Text);
// Original user meta should not be mutated
Assert.Single(userMeta);
Assert.Equal("customValue", (string)userMeta["customKey"]!);
}
[Fact]
public async Task CallToolAsync_RespectsServerPollInterval()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var startTime = DateTime.UtcNow;
// Complete the task after a brief delay
_ = Task.Run(async () =>
{
await Task.Delay(200, ct);
var taskId = _taskStore.GetAllTaskIds().Single();
_taskStore.CompleteTask(taskId, new CallToolResult
{
Content = [new TextContentBlock { Text = "polled" }],
});
}, ct);
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "async-tool" }, ct);
var elapsed = DateTime.UtcNow - startTime;
// The server sets pollIntervalMs=50. The task completes after 200ms.
// So we expect at least 1 poll interval to have passed.
Assert.True(elapsed.TotalMilliseconds >= 50, $"Expected at least 50ms, got {elapsed.TotalMilliseconds}ms");
Assert.Equal("polled", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult()
{
// Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works
// in the handler context — this is already tested by "immediate-tool" working correctly.
await using var client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "immediate-tool" },
TestContext.Current.CancellationToken);
Assert.Equal("immediate result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive()
{
var handlers = new McpServerHandlers();
handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult();
Assert.Throws<InvalidOperationException>(() =>
handlers.CallToolHandler = async (ctx, ct) => new CallToolResult());
handlers = new McpServerHandlers();
handlers.CallToolHandler = async (ctx, ct) => new CallToolResult();
Assert.Throws<InvalidOperationException>(() =>
handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult());
}
[Fact]
public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet()
{
var handlers = new McpServerHandlers();
handlers.CallToolHandler = async (ctx, ct) => new CallToolResult();
handlers.CallToolHandler = null;
// Now setting the other should work
handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult();
Assert.NotNull(handlers.CallToolWithTaskHandler);
}
/// <summary>
/// Simple in-memory task store for testing.
/// </summary>
private sealed class InMemoryTaskStore
{
private readonly ConcurrentDictionary<string, TaskEntry> _tasks = new();
public string CreateTask(McpTaskStatus initialStatus = McpTaskStatus.Working)
{
var taskId = Guid.NewGuid().ToString("N");
_tasks[taskId] = new TaskEntry
{
Status = initialStatus,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
};
return taskId;
}
public IEnumerable<string> GetAllTaskIds() => _tasks.Keys;
public GetTaskResult GetTask(string taskId)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new McpException($"Unknown task: '{taskId}'");
}
return entry.Status switch
{
McpTaskStatus.Working => new WorkingTaskResult
{
TaskId = taskId,
CreatedAt = entry.CreatedAt,
LastUpdatedAt = entry.LastUpdatedAt,
PollIntervalMs = 50,
},
McpTaskStatus.Completed => new CompletedTaskResult
{
TaskId = taskId,
CreatedAt = entry.CreatedAt,
LastUpdatedAt = entry.LastUpdatedAt,
Result = JsonSerializer.SerializeToElement(entry.Result, McpJsonUtilities.DefaultOptions),
},
McpTaskStatus.Failed => new FailedTaskResult
{
TaskId = taskId,
CreatedAt = entry.CreatedAt,
LastUpdatedAt = entry.LastUpdatedAt,
Error = entry.Error!.Value,
},
McpTaskStatus.Cancelled => new CancelledTaskResult
{
TaskId = taskId,
CreatedAt = entry.CreatedAt,
LastUpdatedAt = entry.LastUpdatedAt,
},
McpTaskStatus.InputRequired => new InputRequiredTaskResult
{
TaskId = taskId,
CreatedAt = entry.CreatedAt,
LastUpdatedAt = entry.LastUpdatedAt,
InputRequests = entry.InputRequests ?? new Dictionary<string, InputRequest>(),
},
_ => throw new InvalidOperationException($"Unexpected status: {entry.Status}")
};
}
public void CompleteTask(string taskId, CallToolResult result)
{
if (_tasks.TryGetValue(taskId, out var entry))
{
entry.Status = McpTaskStatus.Completed;
entry.Result = result;
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
}
}
public void FailTask(string taskId, JsonElement error)
{
if (_tasks.TryGetValue(taskId, out var entry))
{
entry.Status = McpTaskStatus.Failed;
entry.Error = error;
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
}
}
public void CancelTask(string taskId)
{
if (_tasks.TryGetValue(taskId, out var entry))
{
entry.Status = McpTaskStatus.Cancelled;
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
}
}
public void ProvideInput(string taskId, IDictionary<string, InputResponse> inputResponses)
{
if (_tasks.TryGetValue(taskId, out var entry))
{
entry.InputResponses = inputResponses;
// Transition back to working after receiving input
entry.Status = McpTaskStatus.Working;
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
}
}
private sealed class TaskEntry
{
public McpTaskStatus Status { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset LastUpdatedAt { get; set; }
public CallToolResult? Result { get; set; }
public JsonElement? Error { get; set; }
public IDictionary<string, InputRequest>? InputRequests { get; set; }
public IDictionary<string, InputResponse>? InputResponses { get; set; }
}
}
}

View file

@ -0,0 +1,70 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
#pragma warning disable MCPEXP001
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Pins the behavior when a client signals the SEP-2575 tasks opt-in via <c>_meta</c> but the server
/// has neither an <see cref="McpServerOptions.TaskStore"/> nor a <see cref="McpServerHandlers.CallToolWithTaskHandler"/>
/// configured. The expected behavior is a silent synchronous fallback: the server returns the normal
/// <see cref="CallToolResult"/> with no <c>Task</c> envelope and no exception.
/// </summary>
public class McpServerTasksNoStoreTests : ClientServerTestBase
{
public McpServerTasksNoStoreTests(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)
{
// Intentionally do NOT configure TaskStore or CallToolWithTaskHandler.
mcpServerBuilder.WithTools<NoStoreTools>();
}
[Fact]
public async Task ClientOptIn_NoTaskStore_FallsBackToSyncResult()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
// CallToolRawAsync always writes the SEP-2575 tasks opt-in into _meta.
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "sync-tool" }, ct);
// With no task store configured, the server must complete synchronously and return
// the standard CallToolResult — not a task envelope.
Assert.False(augmented.IsTask);
Assert.NotNull(augmented.Result);
Assert.Equal("sync result", Assert.IsType<TextContentBlock>(augmented.Result!.Content[0]).Text);
}
[Fact]
public async Task ClientOptIn_NoTaskStore_CallToolAsync_StillReturnsResult()
{
// CallToolAsync (the higher-level convenience) must also work in the no-store case,
// delegating to the underlying sync path without throwing.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "sync-tool" }, ct);
Assert.NotNull(result);
Assert.Equal("sync result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[McpServerToolType]
private sealed class NoStoreTools
{
[McpServerTool(Name = "sync-tool"), System.ComponentModel.Description("A plain sync tool")]
public static string SyncTool() => "sync result";
}
}

View file

@ -343,18 +343,13 @@ public class McpServerTests : LoggedTest
Resources = new ResourcesCapability(),
Tools = new ToolsCapability(),
Completions = new CompletionsCapability(),
Tasks = new McpTasksCapability(),
Extensions = new Dictionary<string, object> { ["io.test"] = new JsonObject() },
};
await Can_Handle_Requests(
serverCapabilities: inputCapabilities,
method: RequestMethods.Initialize,
configureOptions: options =>
{
// Tasks capability requires a TaskStore
options.TaskStore = new InMemoryMcpTaskStore();
},
configureOptions: _ => { },
assertResult: (_, response) =>
{
var result = JsonSerializer.Deserialize<InitializeResult>(response, McpJsonUtilities.DefaultOptions);

View file

@ -1283,82 +1283,6 @@ public partial class McpServerToolTests
Assert.Contains("Streamable HTTP", exception.Message);
}
[Fact]
public void AsyncTool_AutomaticallyMarkedWithTaskSupport()
{
// Async tools should automatically get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningTask);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void AsyncTool_ValueTask_AutomaticallyMarkedWithTaskSupport()
{
// Async tools returning ValueTask should also get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningValueTask);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void AsyncTool_TaskOfT_AutomaticallyMarkedWithTaskSupport()
{
// Async tools returning Task<T> should get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningTaskOfT);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void AsyncTool_ValueTaskOfT_AutomaticallyMarkedWithTaskSupport()
{
// Async tools returning ValueTask<T> should get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningValueTaskOfT);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void SyncTool_NotMarkedWithTaskSupport()
{
// Synchronous tools should not have TaskSupport set
McpServerTool tool = McpServerTool.Create(SyncTool);
Assert.Null(tool.ProtocolTool.Execution);
}
private static async Task AsyncToolReturningTask()
{
await Task.Yield();
}
private static async ValueTask AsyncToolReturningValueTask()
{
await Task.Yield();
}
private static async Task<string> AsyncToolReturningTaskOfT()
{
await Task.Yield();
return "result";
}
private static async ValueTask<string> AsyncToolReturningValueTaskOfT()
{
await Task.Yield();
return "result";
}
private static string SyncTool()
{
return "sync result";
}
[Description("Tool that returns data.")]
[return: Description("The computed result")]
private static string ToolWithReturnDescription() => "result";

View file

@ -0,0 +1,742 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Microsoft.Extensions.DependencyInjection;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading.Channels;
#pragma warning disable MCPEXP001
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for the <see cref="IMcpTaskStore"/>-based auto-wiring of tools/call into tasks.
/// Verifies that setting <see cref="McpServerOptions.TaskStore"/> enables task support
/// for <see cref="McpServerTool"/>-based tools.
/// </summary>
public class McpTaskStoreTests : ClientServerTestBase
{
public McpTaskStoreTests(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.WithTools<TaskStoreTestTools>();
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
{
options.TaskStore = new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
};
});
}
[Fact]
public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult()
{
await using var client = await CreateMcpClientForServer();
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "slow-tool" },
TestContext.Current.CancellationToken);
// Because the client signals task support and a TaskStore is configured,
// the server should wrap the tool execution in a task.
Assert.True(augmented.IsTask);
Assert.NotNull(augmented.TaskCreated);
Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status);
}
[Fact]
public async Task CallToolAsync_WithTaskStore_PollsToCompletion()
{
await using var client = await CreateMcpClientForServer();
// CallToolAsync should poll until the background execution completes.
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "slow-tool" },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Content);
Assert.Equal("slow result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask()
{
await using var client = await CreateMcpClientForServer();
// Even a fast tool should go through the task store when the client signals capability.
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "fast-tool" },
TestContext.Current.CancellationToken);
Assert.True(augmented.IsTask);
}
[Fact]
public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "fast-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
// The fast-tool returns immediately in the background, so poll briefly
GetTaskResult? taskResult = null;
for (int i = 0; i < 20; i++)
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
if (taskResult is CompletedTaskResult)
{
break;
}
}
Assert.IsType<CompletedTaskResult>(taskResult);
}
[Fact]
public async Task CancelTaskAsync_ViaStore_TransitionsToCancelled()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
// Create a slow task that won't complete on its own
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "slow-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
// Cancel it
await client.CancelTaskAsync(taskId, ct);
// Verify state
var taskResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<CancelledTaskResult>(taskResult);
}
[Fact]
public async Task GetTaskAsync_UnknownId_ThrowsWithInvalidParams()
{
await using var client = await CreateMcpClientForServer();
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.GetTaskAsync("nonexistent-id", TestContext.Current.CancellationToken));
Assert.Contains("Unknown task", ex.Message);
}
[Fact]
public async Task ToolExecution_Failure_StoresAsCompletedWithError()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "failing-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
// Poll until completed (tool exceptions are wrapped as isError:true results)
GetTaskResult? taskResult = null;
for (int i = 0; i < 20; i++)
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
if (taskResult is CompletedTaskResult)
{
break;
}
}
var completed = Assert.IsType<CompletedTaskResult>(taskResult);
// The tool result has isError: true
Assert.True(completed.Result.GetProperty("isError").GetBoolean());
}
[Fact]
public async Task McpProtocolException_FromTool_StoresAsFailedWithJsonRpcErrorShape()
{
// SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}.
// When a tool throws McpProtocolException, the task-store wrapper must serialize the error
// payload with the exception's ErrorCode and Message preserved on the wire.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
GetTaskResult? taskResult = null;
for (int i = 0; i < 20; i++)
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
if (taskResult is FailedTaskResult)
{
break;
}
}
var failed = Assert.IsType<FailedTaskResult>(taskResult);
// The error MUST be a JSON-RPC error object with at least 'code' and 'message'.
Assert.Equal(JsonValueKind.Object, failed.Error.ValueKind);
Assert.Equal((int)McpErrorCode.InvalidParams, failed.Error.GetProperty("code").GetInt32());
Assert.Equal("custom-protocol-message", failed.Error.GetProperty("message").GetString());
}
[Fact]
public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage()
{
// [McpServerTool] methods that throw InputRequiredException can't compose with the task-store
// wrapper today: the taskId was already returned synchronously and there's no way to surface
// InputRequiredResult retroactively. The wrapper must fail the task with a clear, actionable
// message instead of leaking the raw exception through the generic catch.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "mrtr-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
GetTaskResult? taskResult = null;
for (int i = 0; i < 20; i++)
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
if (taskResult is FailedTaskResult)
{
break;
}
}
var failed = Assert.IsType<FailedTaskResult>(taskResult);
Assert.Equal(JsonValueKind.Object, failed.Error.ValueKind);
Assert.Equal((int)McpErrorCode.InvalidRequest, failed.Error.GetProperty("code").GetInt32());
var message = failed.Error.GetProperty("message").GetString();
Assert.NotNull(message);
Assert.Contains("MRTR", message);
Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message);
}
[Fact]
public async Task ElicitTool_ViaTask_RedirectsThroughStore()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (request, ct) =>
{
// Client responds to the elicitation
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
}
}
});
var ct = TestContext.Current.CancellationToken;
// CallToolAsync will poll and resolve input requests automatically.
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "elicit-tool" }, ct);
Assert.NotNull(result);
Assert.Equal("accepted", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task SampleTool_ViaTask_RedirectsThroughStore()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
Handlers = new McpClientHandlers
{
SamplingHandler = (request, progress, ct) =>
{
return new ValueTask<CreateMessageResult>(new CreateMessageResult
{
Content = [new TextContentBlock { Text = "sampled response" }],
Model = "test-model",
});
}
}
});
var ct = TestContext.Current.CancellationToken;
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "sample-tool" }, ct);
Assert.NotNull(result);
Assert.Equal("sampled response", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task RootsTool_ViaTask_RedirectsThroughStore()
{
// Verifies that server-initiated roots/list calls issued from inside a [McpServerTool]
// running under the task wrapper are redirected through the task store as input requests
// (rather than being sent as direct JSON-RPC requests to the client).
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
Capabilities = new ClientCapabilities
{
Roots = new RootsCapability(),
},
Handlers = new McpClientHandlers
{
RootsHandler = (request, ct) =>
new ValueTask<ListRootsResult>(new ListRootsResult
{
Roots = [new Root { Uri = "file:///workspace" }, new Root { Uri = "file:///other" }],
}),
},
});
var ct = TestContext.Current.CancellationToken;
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "roots-tool" }, ct);
Assert.NotNull(result);
Assert.Equal("file:///workspace,file:///other", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotificationE2E()
{
// E2E coverage for SendTaskStatusNotificationAsync: the tool emits a Working then a
// Completed notification with a fixed test taskId, and the client receives them via
// its notifications/tasks subscription, deserialized to the right concrete subtype.
var notifications = Channel.CreateUnbounded<TaskStatusNotificationParams>();
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
await using var registration = client.RegisterNotificationHandler(
NotificationMethods.TaskStatusNotification,
(notification, _) =>
{
var typed = JsonSerializer.Deserialize<TaskStatusNotificationParams>(
notification.Params,
McpJsonUtilities.DefaultOptions);
if (typed is not null)
{
notifications.Writer.TryWrite(typed);
}
return default;
});
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "notifying-tool" }, ct);
Assert.Equal("notified", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
// Read both notifications. The server emits them in strict order (each
// SendTaskStatusNotificationAsync awaits the transport write before the next), but client-side
// dispatch (McpSessionHandler.ProcessMessageAsync) is fire-and-forget per message, so user
// handlers may observe them out of order. Reconstruct send order via the server-set
// LastUpdatedAt timestamp.
var first = await notifications.Reader.ReadAsync(ct);
var second = await notifications.Reader.ReadAsync(ct);
var ordered = new[] { first, second }.OrderBy(n => n.LastUpdatedAt).ToArray();
var workingTyped = Assert.IsType<WorkingTaskNotificationParams>(ordered[0]);
Assert.Equal("notify-test-task-id", workingTyped.TaskId);
Assert.Equal(McpTaskStatus.Working, workingTyped.Status);
var completedTyped = Assert.IsType<CompletedTaskNotificationParams>(ordered[1]);
Assert.Equal("notify-test-task-id", completedTyped.TaskId);
Assert.Equal(McpTaskStatus.Completed, completedTyped.Status);
Assert.Equal("notify-result", completedTyped.Result.GetString());
}
[Fact]
public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificationE2E()
{
// Companion to the Working/Completed test above, covering the Failed branch which
// carries the required JsonElement Error payload.
var notifications = Channel.CreateUnbounded<TaskStatusNotificationParams>();
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
await using var registration = client.RegisterNotificationHandler(
NotificationMethods.TaskStatusNotification,
(notification, _) =>
{
var typed = JsonSerializer.Deserialize<TaskStatusNotificationParams>(
notification.Params,
McpJsonUtilities.DefaultOptions);
if (typed is FailedTaskNotificationParams)
{
notifications.Writer.TryWrite(typed);
}
return default;
});
// The tool emits a Failed notification then returns a normal result, so we isolate the
// notification round-trip from the task-store's own failure handling.
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "failing-notify-tool" }, ct);
Assert.Equal("emitted-failed", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
var failed = await notifications.Reader.ReadAsync(ct);
var typed = Assert.IsType<FailedTaskNotificationParams>(failed);
Assert.Equal("failing-notify-task-id", typed.TaskId);
Assert.Equal(McpTaskStatus.Failed, typed.Status);
Assert.Equal(-32000, typed.Error.GetProperty("code").GetInt32());
Assert.Equal("boom", typed.Error.GetProperty("message").GetString());
}
[Fact]
public async Task ElicitTool_ViaTask_ClientDedups_InputRequests()
{
// This test verifies that the client doesn't re-resolve an input request
// that it has already responded to in a previous poll cycle.
int elicitCallCount = 0;
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (request, ct) =>
{
Interlocked.Increment(ref elicitCallCount);
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
}
}
});
var ct = TestContext.Current.CancellationToken;
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "elicit-tool" }, ct);
// The handler should be called exactly once despite potential multiple polls
Assert.Equal(1, elicitCallCount);
Assert.Equal("accepted", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (request, ct) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" })
}
});
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "elicit-tool" }, ct);
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Poll — eventually the task should be input_required (elicit-tool calls ElicitAsync)
GetTaskResult? taskResult = null;
for (int i = 0; i < 40; i++)
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
if (taskResult is InputRequiredTaskResult)
{
break;
}
}
Assert.IsType<InputRequiredTaskResult>(taskResult);
}
[Fact]
public async Task CancelTaskAsync_AlreadyCompleted_AcknowledgesIdempotently_AndDoesNotResurrect()
{
// Exercises the SDK's default tasks/cancel handler against the real InMemoryMcpTaskStore.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
// Run a fast tool to completion via the task store.
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "fast-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
GetTaskResult? taskResult = null;
for (int i = 0; i < 40 && taskResult is not CompletedTaskResult; i++)
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
}
Assert.IsType<CompletedTaskResult>(taskResult);
// SEP-2663: tasks/cancel must be acknowledged idempotently even after the task has completed.
var cancelResult = await client.CancelTaskAsync(taskId, ct);
Assert.NotNull(cancelResult);
// The task must remain Completed and the result must not be lost.
var verifyResult = await client.GetTaskAsync(taskId, ct);
var stillCompleted = Assert.IsType<CompletedTaskResult>(verifyResult);
Assert.NotEqual(default(JsonElement), stillCompleted.Result);
}
[Fact]
public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveClientStuck()
{
// Verifies bug fix: when the client-side input handler throws while resolving an
// InputRequired task, the exception propagates promptly (instead of the poll loop
// hanging) and the client issues a best-effort tasks/cancel to release the server.
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (request, ct) =>
throw new InvalidOperationException("handler-failed"),
}
});
var ct = TestContext.Current.CancellationToken;
var sw = System.Diagnostics.Stopwatch.StartNew();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct));
sw.Stop();
Assert.Equal("handler-failed", ex.Message);
// Must fail fast: without the fix this would keep polling until the test cancellation token fires.
// Allow generous slack for CI but well under the test timeout.
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10),
$"CallToolAsync should propagate input handler exceptions promptly but took {sw.Elapsed}.");
}
[Fact]
public async Task CallTool_WithoutTaskExtensionMeta_ReturnsCallToolResultImmediately()
{
// SEP-2663: "A server MUST NOT return a CreateTaskResult to a client that did not include the
// extension capability." We bypass CallToolRawAsync (which injects the marker) and send a raw
// tools/call request without the io.modelcontextprotocol/tasks key in _meta.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var result = await client.SendRequestAsync<CallToolRequestParams, CallToolResult>(
RequestMethods.ToolsCall,
new CallToolRequestParams { Name = "fast-tool" },
serializerOptions: McpJsonUtilities.DefaultOptions,
cancellationToken: ct);
// Server should return a regular CallToolResult, never escalate to a task.
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.Equal("fast result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
// resultType is reserved and must not be the "task" discriminator for plain results.
Assert.NotEqual("task", result.ResultType);
}
[Fact]
public async Task ToolReturnsCallToolResultWithIsError_AsTask_StoresAsCompleted_NotFailed()
{
// SEP-2663: "An MCP server MUST NOT use [Failed] for errors that would have been signaled
// by setting `CallToolResult.isError` to true ... Such errors are domain-level errors, and
// their result MUST be returned by the server in the same way that any standard call-tool
// result is returned." So a tool that returns isError:true MUST end up as a Completed task.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "iserror-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
GetTaskResult? taskResult = null;
for (int i = 0; i < 40 && taskResult is not CompletedTaskResult; i++)
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
}
var completed = Assert.IsType<CompletedTaskResult>(taskResult);
Assert.Equal(McpTaskStatus.Completed, completed.Status);
Assert.True(completed.Result.GetProperty("isError").GetBoolean());
}
[Fact]
public async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_AcrossPolls()
{
// SEP-2663: "Each entry [in inputRequests] MUST be treated as if it were an equivalent
// standalone server-to-client request" and "clients SHOULD use [keys] to deduplicate".
// A tool that fires two concurrent server->client requests produces two unique keys; the
// client must dispatch the handler exactly twice in total, even across multiple polls.
int elicitCount = 0;
var observedMessages = new System.Collections.Concurrent.ConcurrentBag<string>();
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (request, ct) =>
{
Interlocked.Increment(ref elicitCount);
observedMessages.Add(request?.Message ?? string.Empty);
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
}
}
});
var ct = TestContext.Current.CancellationToken;
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "multi-elicit-tool" }, ct);
// Exactly two handler invocations — one per unique input request key.
Assert.Equal(2, elicitCount);
Assert.Contains("first", observedMessages);
Assert.Contains("second", observedMessages);
Assert.Equal("accept|accept", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[McpServerToolType]
private sealed class TaskStoreTestTools
{
[McpServerTool(Name = "slow-tool"), System.ComponentModel.Description("A tool that takes time")]
public static async Task<string> SlowTool(CancellationToken cancellationToken)
{
await Task.Delay(200, cancellationToken);
return "slow result";
}
[McpServerTool(Name = "fast-tool"), System.ComponentModel.Description("A fast tool")]
public static string FastTool() => "fast result";
[McpServerTool(Name = "failing-tool"), System.ComponentModel.Description("A tool that fails")]
public static string FailingTool() => throw new InvalidOperationException("intentional failure");
[McpServerTool(Name = "throws-mcp-protocol"), System.ComponentModel.Description("A tool that throws McpProtocolException")]
public static string ThrowsMcpProtocol() =>
throw new McpProtocolException("custom-protocol-message", McpErrorCode.InvalidParams);
[McpServerTool(Name = "mrtr-tool"), System.ComponentModel.Description("A tool that throws InputRequiredException (MRTR)")]
public static string MrtrTool() =>
throw new InputRequiredException(requestState: "test-state");
[McpServerTool(Name = "elicit-tool"), System.ComponentModel.Description("A tool that elicits")]
public static async Task<string> ElicitTool(McpServer server, CancellationToken cancellationToken)
{
var result = await server.ElicitAsync(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new(),
}, cancellationToken);
return result.Action == "accept" ? "accepted" : "declined";
}
[McpServerTool(Name = "sample-tool"), System.ComponentModel.Description("A tool that samples")]
public static async Task<string> SampleTool(McpServer server, CancellationToken cancellationToken)
{
var result = await server.SampleAsync(new CreateMessageRequestParams
{
Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "hello" }] }],
MaxTokens = 100,
}, cancellationToken);
return result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "no response";
}
[McpServerTool(Name = "roots-tool"), System.ComponentModel.Description("A tool that lists roots")]
public static async Task<string> RootsTool(McpServer server, CancellationToken cancellationToken)
{
var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken);
return string.Join(",", result.Roots.Select(r => r.Uri));
}
[McpServerTool(Name = "notifying-tool"), System.ComponentModel.Description("A tool that emits SendTaskStatusNotificationAsync from inside the task wrapper")]
public static async Task<string> NotifyingTool(McpServer server, CancellationToken cancellationToken)
{
var createdAt = DateTimeOffset.UtcNow;
// Emit working then completed notifications using the public SendTaskStatusNotificationAsync API,
// so the test asserts the wire round-trip end-to-end (server → transport → client handler).
// Use distinct LastUpdatedAt values so the test can reconstruct send order on the receive side
// (client-side dispatch via McpSessionHandler.ProcessMessageAsync is fire-and-forget per message
// and may surface notifications to user handlers out of receipt order).
await server.SendTaskStatusNotificationAsync(new WorkingTaskNotificationParams
{
TaskId = "notify-test-task-id",
CreatedAt = createdAt,
LastUpdatedAt = createdAt,
}, cancellationToken);
await server.SendTaskStatusNotificationAsync(new CompletedTaskNotificationParams
{
TaskId = "notify-test-task-id",
CreatedAt = createdAt,
LastUpdatedAt = createdAt.AddTicks(1),
Result = JsonElement.Parse("\"notify-result\""),
}, cancellationToken);
return "notified";
}
[McpServerTool(Name = "failing-notify-tool"), System.ComponentModel.Description("A tool that emits a FailedTaskNotificationParams via SendTaskStatusNotificationAsync")]
public static async Task<string> FailingNotifyTool(McpServer server, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var errorJson = JsonElement.Parse("""{"code":-32000,"message":"boom"}""");
await server.SendTaskStatusNotificationAsync(new FailedTaskNotificationParams
{
TaskId = "failing-notify-task-id",
CreatedAt = now,
LastUpdatedAt = now,
Error = errorJson,
}, cancellationToken);
return "emitted-failed";
}
[McpServerTool(Name = "iserror-tool"), System.ComponentModel.Description("A tool that returns IsError=true without throwing")]
public static CallToolResult IsErrorTool() => new()
{
IsError = true,
Content = [new TextContentBlock { Text = "domain-error" }],
};
[McpServerTool(Name = "multi-elicit-tool"), System.ComponentModel.Description("A tool that issues two parallel elicitations")]
public static async Task<string> MultiElicitTool(McpServer server, CancellationToken cancellationToken)
{
var first = server.ElicitAsync(new ElicitRequestParams
{
Message = "first",
RequestedSchema = new(),
}, cancellationToken);
var second = server.ElicitAsync(new ElicitRequestParams
{
Message = "second",
RequestedSchema = new(),
}, cancellationToken);
await Task.WhenAll(first.AsTask(), second.AsTask());
return $"{first.Result.Action}|{second.Result.Action}";
}
}
}

View file

@ -3,13 +3,16 @@ using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Runtime.InteropServices;
using System.Text.Json;
#pragma warning disable MCPEXP001
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Integration tests for task cancellation behavior, including TTL-based automatic
/// cancellation and explicit cancellation via tasks/cancel.
/// Integration tests for task cancellation behavior, including explicit cancellation
/// via tasks/cancel and TTL-based automatic cancellation.
/// </summary>
public class TaskCancellationIntegrationTests : ClientServerTestBase
{
@ -19,27 +22,28 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
public TaskCancellationIntegrationTests(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)
{
// Add task store for server-side task support
var taskStore = new InMemoryMcpTaskStore();
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options =>
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
{
options.TaskStore = taskStore;
options.TaskStore = new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
DefaultTtlMs = 5000,
};
});
// Add a long-running tool that captures cancellation
mcpServerBuilder.WithTools([McpServerTool.Create(
async (CancellationToken ct) =>
{
_toolStarted.TrySetResult(true);
try
{
// Wait indefinitely until cancelled
await Task.Delay(Timeout.Infinite, ct);
return "completed";
}
@ -56,127 +60,54 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
})]);
}
private static IDictionary<string, JsonElement> EmptyArguments() => new Dictionary<string, JsonElement>();
[Fact]
public async Task TaskTool_CancellationToken_FiresWhenTtlExpires()
{
// Arrange
await using McpClient client = await CreateMcpClientForServer();
// Act - Call tool with short TTL (200ms)
var callResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "long-running-tool",
Arguments = EmptyArguments(),
// Use a TTL long enough that thread pool scheduling delays on loaded CI machines
// don't cause the CTS to fire before the tool lambda begins executing.
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) }
},
cancellationToken: TestContext.Current.CancellationToken);
// Verify task was created
Assert.NotNull(callResult.Task);
// Wait for the tool to start executing
await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Assert - Wait for the cancellation to fire (should happen when TTL expires)
var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.True(cancelled, "Tool's CancellationToken should have been triggered when TTL expired");
// Note: TTL-based expiration does not explicitly set task status to Cancelled.
// Instead, expired tasks are considered "dead" and will be cleaned up by the task store.
// The task may still be in Working status or may throw "not found" if already cleaned up.
}
[Fact]
public async Task TaskTool_CancellationToken_FiresWhenExplicitlyCancelled()
{
// Arrange
await using McpClient client = await CreateMcpClientForServer();
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
// Start a long-running task with a long TTL
var callResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "long-running-tool",
Arguments = EmptyArguments(),
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }
},
cancellationToken: TestContext.Current.CancellationToken);
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "long-running-tool" }, ct);
Assert.NotNull(callResult.Task);
string taskId = callResult.Task.TaskId;
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Wait for the tool to start executing
await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
// Act - Explicitly cancel the task
var cancelledTask = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
// Explicitly cancel the task
await client.CancelTaskAsync(taskId, ct);
// Assert - Wait for the cancellation to propagate to the tool
var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.True(cancelled, "Tool's CancellationToken should have been triggered by explicit cancellation");
// Wait for the cancellation to propagate to the tool
var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
Assert.True(cancelled);
// Verify task status
Assert.Equal(McpTaskStatus.Cancelled, cancelledTask.Status);
// Verify task status shows cancelled
var taskResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<CancelledTaskResult>(taskResult);
}
[Fact]
public async Task TaskTool_CompletesSuccessfully_WhenNotCancelled()
public async Task TaskTool_CancellationToken_GetTaskShowsWorkingBeforeCancel()
{
// Arrange - Create a new test with a quick-completing tool
var quickToolCompleted = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var services = new ServiceCollection();
services.AddLogging();
var taskStore = new InMemoryMcpTaskStore();
services.AddSingleton<IMcpTaskStore>(taskStore);
var builder = services
.AddMcpServer()
.WithStreamServerTransport(
new System.IO.Pipelines.Pipe().Reader.AsStream(),
new System.IO.Pipelines.Pipe().Writer.AsStream());
builder.WithTools([McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(50, ct); // Quick operation
var result = $"Result: {input}";
quickToolCompleted.TrySetResult(result);
return result;
},
new McpServerToolCreateOptions
{
Name = "quick-tool",
Description = "A tool that completes quickly"
})]);
services.Configure<McpServerOptions>(options =>
{
options.TaskStore = taskStore;
});
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
// Act - Call tool with long TTL
var callResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "long-running-tool", // Use the base class tool which will block
Arguments = EmptyArguments(),
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(5) }
},
cancellationToken: TestContext.Current.CancellationToken);
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "long-running-tool" }, ct);
Assert.NotNull(callResult.Task);
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Verify task is in working state initially
var task = await client.GetTaskAsync(callResult.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(McpTaskStatus.Working, task.Status);
// Wait for the tool to start
await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
// Check status while still running
var taskResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<WorkingTaskResult>(taskResult);
// Cleanup
await client.CancelTaskAsync(taskId, ct);
}
}
@ -192,19 +123,21 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
public TaskCancellationConcurrencyTests(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)
{
var taskStore = new InMemoryMcpTaskStore();
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options =>
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
{
options.TaskStore = taskStore;
options.TaskStore = new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
};
});
// Tool that tracks cancellation per-invocation using a marker argument
mcpServerBuilder.WithTools([McpServerTool.Create(
async (string marker, CancellationToken ct) =>
{
@ -279,102 +212,47 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
[Fact]
public async Task CancelTask_OnlyCancelsTargetTask_NotOtherTasks()
{
// Arrange
await using McpClient client = await CreateMcpClientForServer();
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
RegisterMarker("task1");
RegisterMarker("task2");
// Start two tasks
var result1 = await client.CallToolAsync(
var result1 = await client.CallToolRawAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("task1"),
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }
},
cancellationToken: TestContext.Current.CancellationToken);
}, ct);
var result2 = await client.CallToolAsync(
var result2 = await client.CallToolRawAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("task2"),
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }
},
cancellationToken: TestContext.Current.CancellationToken);
}, ct);
Assert.NotNull(result1.Task);
Assert.NotNull(result2.Task);
Assert.True(result1.IsTask);
Assert.True(result2.IsTask);
// Wait for both tools to start
await WaitForStart("task1", TestContext.Current.CancellationToken);
await WaitForStart("task2", TestContext.Current.CancellationToken);
await WaitForStart("task1", ct);
await WaitForStart("task2", ct);
// Act - Cancel only task1
await client.CancelTaskAsync(result1.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken);
// Cancel only task1
await client.CancelTaskAsync(result1.TaskCreated!.TaskId, ct);
// Assert - task1 should be cancelled
var task1Cancelled = await WaitForCancellation("task1", TestContext.Current.CancellationToken);
Assert.True(task1Cancelled, "Task1 should have been cancelled");
// task1 should be cancelled
var task1Cancelled = await WaitForCancellation("task1", ct);
Assert.True(task1Cancelled);
// task2 should still be running (give it a moment to verify it wasn't cancelled)
var task2Status = await client.GetTaskAsync(result2.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(McpTaskStatus.Working, task2Status.Status);
// task2 should still be working
var task2Status = await client.GetTaskAsync(result2.TaskCreated!.TaskId, ct);
Assert.IsType<WorkingTaskResult>(task2Status);
// Clean up - cancel task2
await client.CancelTaskAsync(result2.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task MultipleTasks_WithDifferentTtls_CancelIndependently()
{
// Arrange
await using McpClient client = await CreateMcpClientForServer();
RegisterMarker("short-ttl");
RegisterMarker("long-ttl");
// Start task with short TTL. Use a TTL long enough that thread pool scheduling
// delays on loaded CI machines don't cause the CTS to fire before the tool
// lambda begins executing (CancelAfter starts counting at task creation, not
// when the tool's Task.Run is scheduled).
var shortTtlResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("short-ttl"),
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) }
},
cancellationToken: TestContext.Current.CancellationToken);
// Start task with long TTL
var longTtlResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("long-ttl"),
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }
},
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(shortTtlResult.Task);
Assert.NotNull(longTtlResult.Task);
// Wait for both to start
await WaitForStart("short-ttl", TestContext.Current.CancellationToken);
await WaitForStart("long-ttl", TestContext.Current.CancellationToken);
// Assert - short TTL task should be cancelled automatically
var shortCancelled = await WaitForCancellation("short-ttl", TestContext.Current.CancellationToken);
Assert.True(shortCancelled, "Short TTL task should have been cancelled when TTL expired");
// Long TTL task should still be running
var longTtlStatus = await client.GetTaskAsync(longTtlResult.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(McpTaskStatus.Working, longTtlStatus.Status);
// Clean up
await client.CancelTaskAsync(longTtlResult.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken);
// Cleanup
await client.CancelTaskAsync(result2.TaskCreated!.TaskId, ct);
}
}
@ -388,16 +266,19 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase
public TerminalTaskStatusTransitionTests(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)
{
var taskStore = new InMemoryMcpTaskStore();
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options =>
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
{
options.TaskStore = taskStore;
options.TaskStore = new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
};
});
mcpServerBuilder.WithTools([
@ -429,81 +310,63 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase
]);
}
private static IDictionary<string, JsonElement> EmptyArguments() => new Dictionary<string, JsonElement>();
[Fact]
public async Task CompletedTask_CannotTransitionToOtherStatus()
public async Task CompletedTask_CancelIsAcknowledgedIdempotentlyAndStateUnchanged()
{
// Arrange
await using McpClient client = await CreateMcpClientForServer();
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var callResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "quick-tool",
Arguments = EmptyArguments(),
Task = new McpTaskMetadata()
},
cancellationToken: TestContext.Current.CancellationToken);
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "quick-tool" }, ct);
Assert.NotNull(callResult.Task);
string taskId = callResult.Task.TaskId;
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Wait for completion
McpTask taskStatus;
GetTaskResult? taskResult;
do
{
await Task.Delay(50, TestContext.Current.CancellationToken);
taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
}
while (taskStatus.Status == McpTaskStatus.Working);
while (taskResult is not CompletedTaskResult);
Assert.Equal(McpTaskStatus.Completed, taskStatus.Status);
// SEP-2663: cancel on a terminal task must be acknowledged idempotently.
var cancelResult = await client.CancelTaskAsync(taskId, ct);
Assert.NotNull(cancelResult);
// Act - Try to cancel a completed task (should be idempotent)
var cancelResult = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
// Assert - Status should still be completed (not cancelled)
Assert.Equal(McpTaskStatus.Completed, cancelResult.Status);
// Verify via get
var verifyStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(McpTaskStatus.Completed, verifyStatus.Status);
// Verify status is still completed (not flipped to cancelled).
var verifyResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<CompletedTaskResult>(verifyResult);
}
[Fact]
public async Task FailedTask_CannotTransitionToOtherStatus()
public async Task CompletedWithErrorTask_CancelIsAcknowledgedIdempotently()
{
// Arrange
await using McpClient client = await CreateMcpClientForServer();
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var callResult = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "failing-tool",
Arguments = EmptyArguments(),
Task = new McpTaskMetadata()
},
cancellationToken: TestContext.Current.CancellationToken);
var augmented = await client.CallToolRawAsync(
new CallToolRequestParams { Name = "failing-tool" }, ct);
Assert.NotNull(callResult.Task);
string taskId = callResult.Task.TaskId;
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Wait for failure
McpTask taskStatus;
// Wait for completion (tool errors are wrapped as completed with isError=true)
GetTaskResult? taskResult;
do
{
await Task.Delay(50, TestContext.Current.CancellationToken);
taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
}
while (taskStatus.Status == McpTaskStatus.Working);
while (taskResult is not CompletedTaskResult);
Assert.Equal(McpTaskStatus.Failed, taskStatus.Status);
// SEP-2663: cancel on a terminal task must be acknowledged idempotently.
var cancelResult = await client.CancelTaskAsync(taskId, ct);
Assert.NotNull(cancelResult);
// Act - Try to cancel a failed task (should be idempotent)
var cancelResult = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
// Assert - Status should still be failed
Assert.Equal(McpTaskStatus.Failed, cancelResult.Status);
// Verify status is still completed (not flipped to cancelled).
var verifyResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType<CompletedTaskResult>(verifyResult);
}
}

View file

@ -0,0 +1,59 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Verifies the runtime validation that fires when a handler opts into task-augmented execution
/// (returns a <see cref="CreateTaskResult"/>) without the server having any <c>tasks/get</c>
/// handler registered.
/// </summary>
public class TaskHandlerConfigurationValidationTests : ClientServerTestBase
{
public TaskHandlerConfigurationValidationTests(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.Services.Configure<McpServerOptions>(options =>
{
options.Capabilities ??= new ServerCapabilities();
// Intentionally configure a task-augmented handler without TaskStore or any of the
// task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler).
options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) =>
new ValueTask<ResultOrCreatedTask<CallToolResult>>(new CreateTaskResult
{
TaskId = "orphan-task",
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
});
});
}
[Fact]
public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime()
{
await using var client = await CreateMcpClientForServer();
// Client surfaces a generic protocol error (the server intentionally redacts the message
// on the wire), so use the base McpException type and confirm via server-side logs that
// the originating exception was the misconfiguration guard.
await Assert.ThrowsAnyAsync<McpException>(async () =>
await client.CallToolAsync(
new CallToolRequestParams { Name = "anything" },
cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.Exception is InvalidOperationException ioe &&
ioe.Message.Contains("tasks/get", StringComparison.Ordinal) &&
ioe.Message.Contains("CreateTaskResult", StringComparison.Ordinal));
}
}

View file

@ -0,0 +1,131 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
#pragma warning disable MCPEXP001
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Exercises the client-side guard that prevents an unbounded poll loop when a server keeps a
/// task in <see cref="McpTaskStatus.InputRequired"/> without publishing any new input requests
/// after every previously requested input has been resolved.
/// </summary>
public class TaskPollStuckDetectorTests : ClientServerTestBase
{
public TaskPollStuckDetectorTests(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.Services.Configure<McpServerOptions>(options =>
{
options.Capabilities ??= new ServerCapabilities();
// CallTool always returns a CreateTaskResult with a tiny poll interval so the
// test exercises the threshold in well under a second.
options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) =>
{
var taskId = Guid.NewGuid().ToString("N");
return new ValueTask<ResultOrCreatedTask<CallToolResult>>(new CreateTaskResult
{
TaskId = taskId,
Status = McpTaskStatus.InputRequired,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
PollIntervalMs = 5,
ResultType = "task",
});
};
// GetTask always reports InputRequired with NO outstanding input requests — the
// misbehaving-server condition the stuck-detector exists to break out of.
options.Handlers.GetTaskHandler = (context, cancellationToken) =>
{
return new ValueTask<GetTaskResult>(new InputRequiredTaskResult
{
TaskId = context.Params!.TaskId,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
PollIntervalMs = 5,
InputRequests = new Dictionary<string, InputRequest>(),
ResultType = "complete",
});
};
// CancelTask must succeed since the client issues a best-effort cancel when it
// gives up; otherwise the cancel failure would mask the real exception.
options.Handlers.CancelTaskHandler = (context, cancellationToken) =>
new ValueTask<CancelTaskResult>(new CancelTaskResult { ResultType = "complete" });
// UpdateTask is never invoked in this scenario (there are no input requests to resolve)
// but must be present so the handler-configuration validation passes.
options.Handlers.UpdateTaskHandler = (context, cancellationToken) =>
new ValueTask<UpdateTaskResult>(new UpdateTaskResult { ResultType = "complete" });
});
}
[Fact]
public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_ThrowsAfterThreshold()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var sw = System.Diagnostics.Stopwatch.StartNew();
var ex = await Assert.ThrowsAsync<McpException>(async () =>
await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct));
sw.Stop();
Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message);
Assert.Contains("consecutive polls", ex.Message);
// 60 polls × 5ms ≈ 300ms; allow generous slack for CI.
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10),
$"Stuck-detector should give up promptly but took {sw.Elapsed}.");
}
[Fact]
public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold()
{
// Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync:
// a smaller configured threshold is surfaced verbatim in the McpException message.
const int CustomThreshold = 3;
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
MaxConsecutiveStuckPolls = CustomThreshold,
});
var ct = TestContext.Current.CancellationToken;
var ex = await Assert.ThrowsAsync<McpException>(async () =>
await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct));
// The message embeds the configured threshold, which is the strongest signal that the
// option value (not the 60-default constant) is what governed the loop.
Assert.Contains($"{CustomThreshold} consecutive polls", ex.Message);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(int.MinValue)]
public void McpClientOptions_MaxConsecutiveStuckPolls_RejectsNonPositive(int value)
{
var options = new McpClientOptions();
Assert.Throws<ArgumentOutOfRangeException>(() => options.MaxConsecutiveStuckPolls = value);
}
[Fact]
public void McpClientOptions_MaxConsecutiveStuckPolls_DefaultsTo60()
{
Assert.Equal(60, new McpClientOptions().MaxConsecutiveStuckPolls);
}
}

View file

@ -0,0 +1,82 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
using System.Text.Json;
#pragma warning disable MCPEXP001
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Verifies that when both <see cref="McpServerOptions.TaskStore"/> and
/// <see cref="McpServerHandlers.CallToolWithTaskHandler"/> are configured and the handler returns
/// <see cref="CreateTaskResult"/> (IsTask = true), the store's pre-created task is failed with a
/// clear error rather than being orphaned in <see cref="McpTaskStatus.Working"/> forever.
/// </summary>
public class TaskStoreOrphanedTaskTests : ClientServerTestBase
{
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.Services.Configure<McpServerOptions>(options =>
{
options.Capabilities ??= new ServerCapabilities();
options.TaskStore = new InMemoryMcpTaskStore();
// Returning IsTask = true here while TaskStore is also configured is the
// misconfiguration the server must guard against.
options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) =>
new ValueTask<ResultOrCreatedTask<CallToolResult>>(new CreateTaskResult
{
TaskId = "user-task",
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
});
});
}
[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.CallToolRawAsync(
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<FailedTaskResult>(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(McpServerOptions.TaskStore), message);
Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message);
}
}

View file

@ -1,727 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Integration tests verifying that tools report correct ToolTaskSupport values
/// based on server configuration and method signatures.
/// </summary>
public class ToolTaskSupportTests : LoggedTest
{
public ToolTaskSupportTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
}
[Fact]
public async Task Tools_WithoutTaskStore_ReportForbiddenTaskSupport()
{
// Arrange - Server without a task store
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([
McpServerTool.Create(async (string input, CancellationToken ct) =>
{
await Task.Delay(10, ct);
return $"Async: {input}";
},
new McpServerToolCreateOptions { Name = "async-tool", Description = "An async tool" }),
McpServerTool.Create((string input) => $"Sync: {input}",
new McpServerToolCreateOptions { Name = "sync-tool", Description = "A sync tool" })
]);
});
// Act
var tools = await fixture.Client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Assert - Both tools should have Forbidden task support when no task store is configured
Assert.Equal(2, tools.Count);
var asyncTool = tools.Single(t => t.Name == "async-tool");
var syncTool = tools.Single(t => t.Name == "sync-tool");
// Without a task store, async tools should still report Optional (their intrinsic capability)
// but the server won't have tasks in capabilities. The tool itself declares its support.
Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution?.TaskSupport);
// Sync tools should have null Execution or Forbidden task support
Assert.True(
syncTool.ProtocolTool.Execution is null ||
syncTool.ProtocolTool.Execution.TaskSupport is null ||
syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden,
"Sync tools should not support task execution");
}
[Fact]
public async Task Tools_WithTaskStore_AsyncToolsReportOptionalTaskSupport()
{
// Arrange - Server with a task store
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([
McpServerTool.Create(async (string input, CancellationToken ct) =>
{
await Task.Delay(10, ct);
return $"Async: {input}";
},
new McpServerToolCreateOptions { Name = "async-tool", Description = "An async tool" }),
McpServerTool.Create((string input) => $"Sync: {input}",
new McpServerToolCreateOptions { Name = "sync-tool", Description = "A sync tool" })
]);
},
configureServices: services =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
// Act
var tools = await fixture.Client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Assert
Assert.Equal(2, tools.Count);
var asyncTool = tools.Single(t => t.Name == "async-tool");
var syncTool = tools.Single(t => t.Name == "sync-tool");
// Async tools should report Optional task support
Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution?.TaskSupport);
// Sync tools should have null Execution or Forbidden task support
Assert.True(
syncTool.ProtocolTool.Execution is null ||
syncTool.ProtocolTool.Execution.TaskSupport is null ||
syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden,
"Sync tools should not support task execution");
}
[Fact]
public async Task Tools_WithExplicitTaskSupport_ReportsConfiguredValue()
{
// Arrange - Server with explicit task support configured on tools
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([
McpServerTool.Create(async (string input, CancellationToken ct) =>
{
await Task.Delay(10, ct);
return $"Async: {input}";
},
new McpServerToolCreateOptions
{
Name = "required-async-tool",
Description = "A tool that requires task execution",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required }
}),
McpServerTool.Create((string input) => $"Sync: {input}",
new McpServerToolCreateOptions
{
Name = "forbidden-sync-tool",
Description = "A tool that forbids task execution",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Forbidden }
})
]);
},
configureServices: services =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
// Act
var tools = await fixture.Client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Assert
Assert.Equal(2, tools.Count);
var requiredTool = tools.Single(t => t.Name == "required-async-tool");
var forbiddenTool = tools.Single(t => t.Name == "forbidden-sync-tool");
Assert.Equal(ToolTaskSupport.Required, requiredTool.ProtocolTool.Execution?.TaskSupport);
Assert.Equal(ToolTaskSupport.Forbidden, forbiddenTool.ProtocolTool.Execution?.TaskSupport);
}
[Fact]
public async Task ServerCapabilities_WithoutTaskStore_DoNotIncludeTasksCapability()
{
// Arrange - Server without a task store
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([
McpServerTool.Create((string input) => $"Result: {input}",
new McpServerToolCreateOptions { Name = "test-tool" })
]);
});
// Assert - Server capabilities should not include tasks
Assert.Null(fixture.Client.ServerCapabilities?.Tasks);
}
[Fact]
public async Task ServerCapabilities_WithTaskStore_IncludeTasksCapability()
{
// Arrange - Server with a task store
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([
McpServerTool.Create((string input) => $"Result: {input}",
new McpServerToolCreateOptions { Name = "test-tool" })
]);
},
configureServices: services =>
{
services.Configure<McpServerOptions>(options =>
{
options.TaskStore = taskStore;
});
});
// Assert - Server capabilities should include tasks
Assert.NotNull(fixture.Client.ServerCapabilities?.Tasks);
Assert.NotNull(fixture.Client.ServerCapabilities.Tasks.List);
Assert.NotNull(fixture.Client.ServerCapabilities.Tasks.Cancel);
Assert.NotNull(fixture.Client.ServerCapabilities.Tasks.Requests?.Tools?.Call);
}
#pragma warning disable MCPEXP001 // Tasks feature is experimental
[Fact]
public void McpServerToolAttribute_TaskSupport_CanBeSetOnAttribute()
{
// Test that the TaskSupport property can be set via the attribute
// and is correctly read when creating a tool
var tool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.RequiredTaskTool))!);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Required, tool.ProtocolTool.Execution.TaskSupport);
var optionalTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.OptionalTaskTool))!);
Assert.NotNull(optionalTool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, optionalTool.ProtocolTool.Execution.TaskSupport);
var forbiddenTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.ForbiddenTaskTool))!);
Assert.NotNull(forbiddenTool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Forbidden, forbiddenTool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void McpServerToolAttribute_TaskSupport_WhenNotSet_AllowsAutoDetection()
{
// When TaskSupport is not set on the attribute, async tools should use auto-detection (Optional)
var asyncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.AsyncToolWithoutTaskSupport))!);
Assert.NotNull(asyncTool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution.TaskSupport);
// Sync tools without TaskSupport set should have null Execution or Forbidden
var syncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.SyncToolWithoutTaskSupport))!);
Assert.True(
syncTool.ProtocolTool.Execution is null ||
syncTool.ProtocolTool.Execution.TaskSupport is null ||
syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden,
"Sync tools without explicit TaskSupport should not support tasks");
}
[Fact]
public void McpServerToolAttribute_TaskSupport_ExplicitForbidden_OverridesAutoDetection()
{
// Verify that explicitly setting Forbidden overrides auto-detection for async methods
var forbiddenAsyncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.ForbiddenAsyncTool))!);
Assert.NotNull(forbiddenAsyncTool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Forbidden, forbiddenAsyncTool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void McpServerToolAttribute_TaskSupport_OptionalOnSyncMethod_IsAllowed()
{
// Setting Optional on a sync method is allowed - the tool will just execute very quickly
// This tests that the SDK doesn't prevent this configuration at tool creation time
var tool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.OptionalTaskTool))!);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void McpServerToolAttribute_TaskSupport_RequiredOnSyncMethod_IsAllowed()
{
// Setting Required on a sync method is allowed - the tool will just execute very quickly
// This tests that the SDK doesn't prevent this configuration at tool creation time
var tool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.RequiredTaskTool))!);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Required, tool.ProtocolTool.Execution.TaskSupport);
}
#pragma warning restore MCPEXP001
#pragma warning disable MCPEXP001 // Tasks feature is experimental
[Fact]
public void McpServerToolAttribute_TaskSupport_WhenNotSet_DefaultsBasedOnMethodSignature()
{
// When TaskSupport is not set on the attribute, async tools should default to Optional
var asyncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.AsyncToolWithoutTaskSupport))!);
Assert.NotNull(asyncTool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution.TaskSupport);
// Sync tools should have null or no Execution set
var syncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.SyncToolWithoutTaskSupport))!);
Assert.True(
syncTool.ProtocolTool.Execution is null ||
syncTool.ProtocolTool.Execution.TaskSupport is null ||
syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden,
"Sync tools without explicit TaskSupport should not support tasks");
}
[Theory]
[InlineData(ToolTaskSupport.Forbidden, "\"forbidden\"")]
[InlineData(ToolTaskSupport.Optional, "\"optional\"")]
[InlineData(ToolTaskSupport.Required, "\"required\"")]
public void ToolTaskSupport_SerializesToJsonCorrectly(ToolTaskSupport value, string expectedJson)
{
var json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions);
Assert.Equal(expectedJson, json);
}
[Theory]
[InlineData("\"forbidden\"", ToolTaskSupport.Forbidden)]
[InlineData("\"optional\"", ToolTaskSupport.Optional)]
[InlineData("\"required\"", ToolTaskSupport.Required)]
public void ToolTaskSupport_DeserializesFromJsonCorrectly(string json, ToolTaskSupport expected)
{
var value = JsonSerializer.Deserialize<ToolTaskSupport>(json, McpJsonUtilities.DefaultOptions);
Assert.Equal(expected, value);
}
[Fact]
public void ToolExecution_TaskSupport_NullByDefault()
{
// Verify that ToolExecution.TaskSupport is null by default
var execution = new ToolExecution();
Assert.Null(execution.TaskSupport);
// When serialized with a value, it should appear correctly
var tool = new Tool
{
Name = "test",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
};
var toolJson = JsonSerializer.Serialize(tool, McpJsonUtilities.DefaultOptions);
Assert.Contains("\"optional\"", toolJson);
}
[Fact]
public void McpServerToolCreateOptions_Execution_OverridesAutoDetection()
{
// When Execution is set via options, it should override auto-detection
var tool = McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(1, ct);
return input;
},
new McpServerToolCreateOptions
{
Name = "test",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Forbidden }
});
// Even though this is an async method, it should have Forbidden since it was explicitly set
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Forbidden, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void McpServerToolCreateOptions_Execution_Required_SetsCorrectly()
{
var tool = McpServerTool.Create(
(string input) => input,
new McpServerToolCreateOptions
{
Name = "test",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required }
});
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Required, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void ToolTaskSupport_EnumValues_AreCorrect()
{
// Verify enum values are as expected (Forbidden = 0)
Assert.Equal(0, (int)ToolTaskSupport.Forbidden);
Assert.Equal(1, (int)ToolTaskSupport.Optional);
Assert.Equal(2, (int)ToolTaskSupport.Required);
}
[Fact]
public void McpServerToolAttribute_TaskSupport_PublicPropertyDefaultsToForbidden()
{
// Verify that the public property returns Forbidden when not set
var attr = new McpServerToolAttribute();
Assert.Equal(ToolTaskSupport.Forbidden, attr.TaskSupport);
}
#pragma warning restore MCPEXP001
[McpServerToolType]
private static class TaskSupportAttributeTestTools
{
#pragma warning disable MCPEXP001 // Tasks feature is experimental
[McpServerTool(TaskSupport = ToolTaskSupport.Required)]
public static string RequiredTaskTool(string input) => $"Required: {input}";
[McpServerTool(TaskSupport = ToolTaskSupport.Optional)]
public static string OptionalTaskTool(string input) => $"Optional: {input}";
[McpServerTool(TaskSupport = ToolTaskSupport.Forbidden)]
public static string ForbiddenTaskTool(string input) => $"Forbidden: {input}";
[McpServerTool(TaskSupport = ToolTaskSupport.Forbidden)]
public static async Task<string> ForbiddenAsyncTool(string input, CancellationToken ct)
{
await Task.Delay(1, ct);
return $"ForbiddenAsync: {input}";
}
#pragma warning restore MCPEXP001
[McpServerTool]
public static async Task<string> AsyncToolWithoutTaskSupport(string input, CancellationToken ct)
{
await Task.Delay(1, ct);
return $"Async: {input}";
}
[McpServerTool]
public static string SyncToolWithoutTaskSupport(string input) => $"Sync: {input}";
}
#region Sync Method with Optional/Required TaskSupport Integration Tests
#pragma warning disable MCPEXP001 // Tasks feature is experimental
[Fact]
public async Task SyncTool_WithOptionalTaskSupport_CanBeCalledAsTask()
{
// Arrange - Server with task store and a sync tool with Optional task support
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([McpServerTool.Create(
(string input) => $"Sync result: {input}",
new McpServerToolCreateOptions
{
Name = "optional-sync-tool",
Description = "A sync tool with optional task support",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
})]);
},
configureServices: services =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
// Act - Call the sync tool as a task
var mcpTask = await fixture.Client.CallToolAsTaskAsync(
"optional-sync-tool",
arguments: new Dictionary<string, object?> { ["input"] = "test" },
taskMetadata: new McpTaskMetadata(),
progress: null,
cancellationToken: TestContext.Current.CancellationToken);
// Assert - Task was created successfully
Assert.NotNull(mcpTask);
Assert.NotEmpty(mcpTask.TaskId);
}
[Fact]
public async Task SyncTool_WithRequiredTaskSupport_CanBeCalledAsTask()
{
// Arrange - Server with task store and a sync tool with Required task support
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([McpServerTool.Create(
(string input) => $"Sync result: {input}",
new McpServerToolCreateOptions
{
Name = "required-sync-tool",
Description = "A sync tool with required task support",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required }
})]);
},
configureServices: services =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
// Act - Call the sync tool as a task
var mcpTask = await fixture.Client.CallToolAsTaskAsync(
"required-sync-tool",
arguments: new Dictionary<string, object?> { ["input"] = "test" },
taskMetadata: new McpTaskMetadata(),
progress: null,
cancellationToken: TestContext.Current.CancellationToken);
// Assert - Task was created successfully
Assert.NotNull(mcpTask);
Assert.NotEmpty(mcpTask.TaskId);
}
[Fact]
public async Task SyncTool_WithRequiredTaskSupport_CannotBeCalledDirectly()
{
// Arrange - Server with task store and a sync tool with Required task support
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([McpServerTool.Create(
(string input) => $"Sync result: {input}",
new McpServerToolCreateOptions
{
Name = "required-sync-tool",
Description = "A sync tool with required task support",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required }
})]);
},
configureServices: services =>
{
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
// Act & Assert - Calling directly should fail because task execution is required
var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
fixture.Client.CallToolAsync(
"required-sync-tool",
arguments: new Dictionary<string, object?> { ["input"] = "test" },
cancellationToken: TestContext.Current.CancellationToken).AsTask());
// The server returns InvalidParams because direct invocation is not allowed for required-task tools
Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
Assert.Contains("task", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task TaskPath_Logs_Tool_Name_On_Successful_Call()
{
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([McpServerTool.Create(
(string input) => $"Result: {input}",
new McpServerToolCreateOptions
{
Name = "task-success-tool",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
})]);
},
configureServices: services =>
{
services.AddSingleton<ILoggerProvider>(MockLoggerProvider);
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
var mcpTask = await fixture.Client.CallToolAsTaskAsync(
"task-success-tool",
arguments: new Dictionary<string, object?> { ["input"] = "test" },
taskMetadata: new McpTaskMetadata(),
progress: null,
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(mcpTask);
// Wait for the async task execution to complete
await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-success-tool\" completed. IsError = False.");
Assert.Equal(LogLevel.Information, infoLog.LogLevel);
}
[Fact]
public async Task TaskPath_Logs_Tool_Name_With_IsError_When_Tool_Returns_Error()
{
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([McpServerTool.Create(
() => new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = "Task tool error" }],
},
new McpServerToolCreateOptions
{
Name = "task-error-result-tool",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
})]);
},
configureServices: services =>
{
services.AddSingleton<ILoggerProvider>(MockLoggerProvider);
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
var mcpTask = await fixture.Client.CallToolAsTaskAsync(
"task-error-result-tool",
taskMetadata: new McpTaskMetadata(),
progress: null,
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(mcpTask);
// Wait for the async task execution to complete
await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-error-result-tool\" completed. IsError = True.");
Assert.Equal(LogLevel.Information, infoLog.LogLevel);
}
[Fact]
public async Task TaskPath_Logs_Error_When_Tool_Throws()
{
var taskStore = new InMemoryMcpTaskStore();
await using var fixture = new ClientServerFixture(
LoggerFactory,
configureServer: builder =>
{
builder.WithTools([McpServerTool.Create(
string () => throw new InvalidOperationException("Task tool error"),
new McpServerToolCreateOptions
{
Name = "task-throw-tool",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
})]);
},
configureServices: services =>
{
services.AddSingleton<ILoggerProvider>(MockLoggerProvider);
services.AddSingleton<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
});
var mcpTask = await fixture.Client.CallToolAsTaskAsync(
"task-throw-tool",
taskMetadata: new McpTaskMetadata(),
progress: null,
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(mcpTask);
// Wait for the async task execution to complete
await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
Assert.Equal("\"task-throw-tool\" threw an unhandled exception.", errorLog.Message);
Assert.IsType<InvalidOperationException>(errorLog.Exception);
}
#pragma warning restore MCPEXP001
#endregion
/// <summary>
/// A fixture that creates a connected MCP client-server pair for testing.
/// </summary>
private sealed class ClientServerFixture : IAsyncDisposable
{
private readonly System.IO.Pipelines.Pipe _clientToServerPipe = new();
private readonly System.IO.Pipelines.Pipe _serverToClientPipe = new();
private readonly CancellationTokenSource _cts;
private readonly Task _serverTask;
private readonly IServiceProvider _serviceProvider;
public McpClient Client { get; }
public McpServer Server { get; }
public ClientServerFixture(
ILoggerFactory loggerFactory,
Action<IMcpServerBuilder>? configureServer,
Action<IServiceCollection>? configureServices = null)
{
ServiceCollection sc = new();
sc.AddLogging();
var builder = sc
.AddMcpServer()
.WithStreamServerTransport(_clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream());
configureServer?.Invoke(builder);
configureServices?.Invoke(sc);
_serviceProvider = sc.BuildServiceProvider(validateScopes: true);
_cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
Server = _serviceProvider.GetRequiredService<McpServer>();
_serverTask = Server.RunAsync(_cts.Token);
// Create client synchronously by blocking - this is test code
Client = McpClient.CreateAsync(
new StreamClientTransport(
serverInput: _clientToServerPipe.Writer.AsStream(),
_serverToClientPipe.Reader.AsStream(),
loggerFactory),
loggerFactory: loggerFactory,
cancellationToken: TestContext.Current.CancellationToken).GetAwaiter().GetResult();
}
public async ValueTask DisposeAsync()
{
await Client.DisposeAsync();
await _cts.CancelAsync();
_clientToServerPipe.Writer.Complete();
_serverToClientPipe.Writer.Complete();
await _serverTask;
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else if (_serviceProvider is IDisposable disposable)
{
disposable.Dispose();
}
_cts.Dispose();
}
}
}