Implement SEP-2663 Tasks Extension (#1579)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ed07fdb6f4
commit
dbb7a20176
15 changed files with 8190 additions and 11405 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
655
tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs
Normal file
655
tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
742
tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
Normal file
742
tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue