using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
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;
///
/// Integration tests for task cancellation behavior, including explicit cancellation
/// via tasks/cancel and TTL-based automatic cancellation.
///
public class TaskCancellationIntegrationTests : ClientServerTestBase
{
private readonly TaskCompletionSource _toolCancellationFired = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _toolStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
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)
{
mcpServerBuilder
.WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
DefaultTimeToLive = TimeSpan.FromSeconds(5),
})
.WithTools([McpServerTool.Create(
async (CancellationToken ct) =>
{
_toolStarted.TrySetResult(true);
try
{
await Task.Delay(Timeout.Infinite, ct);
return "completed";
}
catch (OperationCanceledException)
{
_toolCancellationFired.TrySetResult(true);
throw;
}
},
new McpServerToolCreateOptions
{
Name = "long-running-tool",
Description = "A tool that runs until cancelled"
})]);
}
[Fact]
public async Task TaskTool_CancellationToken_FiresWhenExplicitlyCancelled()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "long-running-tool" }, ct);
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Wait for the tool to start executing
await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
// Explicitly cancel the task
await client.CancelTaskAsync(taskId, ct);
// Wait for the cancellation to propagate to the tool
var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
Assert.True(cancelled);
// Verify task status shows cancelled
var taskResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType(taskResult);
}
[Fact]
public async Task TaskTool_CancellationToken_GetTaskShowsWorkingBeforeCancel()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "long-running-tool" }, ct);
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// 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(taskResult);
// Cleanup
await client.CancelTaskAsync(taskId, ct);
}
}
///
/// Tests for task cancellation with multiple concurrent tasks.
///
public class TaskCancellationConcurrencyTests : ClientServerTestBase
{
private readonly Dictionary> _toolCancellations = new();
private readonly Dictionary> _toolStarts = new();
private readonly object _lock = new();
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)
{
mcpServerBuilder
.WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
})
.WithTools([McpServerTool.Create(
async (string marker, CancellationToken ct) =>
{
TaskCompletionSource startTcs;
TaskCompletionSource cancelTcs;
lock (_lock)
{
if (!_toolStarts.TryGetValue(marker, out startTcs!))
{
startTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_toolStarts[marker] = startTcs;
}
if (!_toolCancellations.TryGetValue(marker, out cancelTcs!))
{
cancelTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_toolCancellations[marker] = cancelTcs;
}
}
startTcs.TrySetResult(true);
try
{
await Task.Delay(Timeout.Infinite, ct);
return $"completed-{marker}";
}
catch (OperationCanceledException)
{
cancelTcs.TrySetResult(true);
throw;
}
},
new McpServerToolCreateOptions
{
Name = "trackable-tool",
Description = "A tool that can be tracked by marker"
})]);
}
private void RegisterMarker(string marker)
{
lock (_lock)
{
_toolStarts[marker] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_toolCancellations[marker] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
private Task WaitForStart(string marker, CancellationToken ct)
{
lock (_lock)
{
return _toolStarts[marker].Task.WaitAsync(TestConstants.DefaultTimeout, ct);
}
}
private Task WaitForCancellation(string marker, CancellationToken ct)
{
lock (_lock)
{
return _toolCancellations[marker].Task.WaitAsync(TestConstants.DefaultTimeout, ct);
}
}
private static IDictionary CreateMarkerArgs(string marker) =>
new Dictionary
{
["marker"] = JsonDocument.Parse($"\"{marker}\"").RootElement.Clone()
};
[Fact]
public async Task CancelTask_OnlyCancelsTargetTask_NotOtherTasks()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
RegisterMarker("task1");
RegisterMarker("task2");
// Start two tasks
var result1 = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("task1"),
}, ct);
var result2 = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("task2"),
}, ct);
Assert.True(result1.IsTask);
Assert.True(result2.IsTask);
// Wait for both tools to start
await WaitForStart("task1", ct);
await WaitForStart("task2", ct);
// Cancel only task1
await client.CancelTaskAsync(result1.TaskCreated!.TaskId, ct);
// task1 should be cancelled
var task1Cancelled = await WaitForCancellation("task1", ct);
Assert.True(task1Cancelled);
// task2 should still be working
var task2Status = await client.GetTaskAsync(result2.TaskCreated!.TaskId, ct);
Assert.IsType(task2Status);
// Cleanup
await client.CancelTaskAsync(result2.TaskCreated!.TaskId, ct);
}
}
///
/// Tests verifying that terminal task states (completed, failed, cancelled) cannot transition.
/// Per spec: "Tasks with a completed, failed, or cancelled status are in a terminal state
/// and MUST NOT transition to any other status"
///
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)
{
mcpServerBuilder
.WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
})
.WithTools([
McpServerTool.Create(
async (CancellationToken ct) =>
{
await Task.Delay(10, ct);
return "quick result";
},
new McpServerToolCreateOptions
{
Name = "quick-tool",
Description = "A tool that completes quickly"
}),
McpServerTool.Create(
async (CancellationToken ct) =>
{
await Task.Delay(10, ct);
throw new InvalidOperationException("Intentional failure");
#pragma warning disable CS0162
return "never";
#pragma warning restore CS0162
},
new McpServerToolCreateOptions
{
Name = "failing-tool",
Description = "A tool that always fails"
})
]);
}
[Fact]
public async Task CompletedTask_CancelIsAcknowledgedIdempotentlyAndStateUnchanged()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "quick-tool" }, ct);
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Wait for completion
GetTaskResult? taskResult;
do
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
}
while (taskResult is not CompletedTaskResult);
// SEP-2663: cancel on a terminal task must be acknowledged idempotently.
var cancelResult = await client.CancelTaskAsync(taskId, ct);
Assert.NotNull(cancelResult);
// Verify status is still completed (not flipped to cancelled).
var verifyResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType(verifyResult);
}
[Fact]
public async Task CompletedWithErrorTask_CancelIsAcknowledgedIdempotently()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "failing-tool" }, ct);
Assert.True(augmented.IsTask);
var taskId = augmented.TaskCreated!.TaskId;
// Wait for completion (tool errors are wrapped as completed with isError=true)
GetTaskResult? taskResult;
do
{
await Task.Delay(50, ct);
taskResult = await client.GetTaskAsync(taskId, ct);
}
while (taskResult is not CompletedTaskResult);
// SEP-2663: cancel on a terminal task must be acknowledged idempotently.
var cancelResult = await client.CancelTaskAsync(taskId, ct);
Assert.NotNull(cancelResult);
// Verify status is still completed (not flipped to cancelled).
var verifyResult = await client.GetTaskAsync(taskId, ct);
Assert.IsType(verifyResult);
}
}