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
1 changed files with 8190 additions and 11405 deletions
|
|
@ -1,342 +0,0 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for MCP Tasks feature over HTTP transports.
|
||||
/// Tests task creation, polling, cancellation, and result retrieval.
|
||||
/// </summary>
|
||||
public class HttpTaskIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
|
||||
{
|
||||
private readonly HttpClientTransportOptions DefaultTransportOptions = new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/"),
|
||||
Name = "In-memory Streamable HTTP Client",
|
||||
};
|
||||
|
||||
private Task<McpClient> ConnectMcpClientAsync(
|
||||
HttpClient? httpClient = null,
|
||||
HttpClientTransportOptions? transportOptions = null,
|
||||
McpClientOptions? clientOptions = null)
|
||||
=> McpClient.CreateAsync(
|
||||
new HttpClientTransport(transportOptions ?? DefaultTransportOptions, httpClient ?? HttpClient, LoggerFactory),
|
||||
clientOptions,
|
||||
LoggerFactory,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
private static IDictionary<string, JsonElement> CreateArguments(string key, object? value)
|
||||
{
|
||||
return new Dictionary<string, JsonElement>
|
||||
{
|
||||
[key] = JsonSerializer.SerializeToElement(value, McpJsonUtilities.DefaultOptions)
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolAsTask_ReturnsTask_WhenServerSupportsTasksAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// Act - Call tool with task augmentation
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "long_running_operation",
|
||||
Arguments = CreateArguments("durationMs", 100),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - Response should indicate task was created
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.IsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskAsync_ReturnsTaskStatus_WhenTaskExistsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// First create a task by calling a tool with task augmentation
|
||||
_ = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "long_running_operation",
|
||||
Arguments = CreateArguments("durationMs", 500),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Get all tasks
|
||||
var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotEmpty(tasks);
|
||||
|
||||
// Act - Get the task status
|
||||
var task = await client.GetTaskAsync(tasks[0].TaskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(task);
|
||||
Assert.Equal(tasks[0].TaskId, task.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasksAsync_ReturnsTasks_WhenTasksExistAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// Create multiple tasks
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "long_running_operation",
|
||||
Arguments = CreateArguments("durationMs", 1000),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(tasks);
|
||||
Assert.Equal(3, tasks.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelTaskAsync_CancelsTask_WhenTaskIsRunningAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// Create a long-running task
|
||||
await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "long_running_operation",
|
||||
Arguments = CreateArguments("durationMs", 10000),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotEmpty(tasks);
|
||||
|
||||
// Act - Cancel the task
|
||||
var cancelledTask = await client.CancelTaskAsync(tasks[0].TaskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cancelledTask);
|
||||
Assert.Equal(McpTaskStatus.Cancelled, cancelledTask.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskResultAsync_ReturnsResult_WhenTaskCompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// Create a quick task
|
||||
await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "long_running_operation",
|
||||
Arguments = CreateArguments("durationMs", 50),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotEmpty(tasks);
|
||||
|
||||
// Wait a bit for the task to complete
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act - Get the task result
|
||||
var result = await client.GetTaskResultAsync(tasks[0].TaskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(default, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksIsolated_BetweenSessions_WhenMultipleClientsConnectAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Connect two separate clients
|
||||
await using var client1 = await ConnectMcpClientAsync();
|
||||
await using var client2 = await ConnectMcpClientAsync();
|
||||
|
||||
// Client 1 creates a task
|
||||
await client1.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "long_running_operation",
|
||||
Arguments = CreateArguments("durationMs", 1000),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Act - Both clients list tasks
|
||||
var client1Tasks = await client1.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
var client2Tasks = await client2.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - Tasks should be isolated by session
|
||||
Assert.Single(client1Tasks);
|
||||
Assert.Empty(client2Tasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerCapabilities_IncludesTasks_WhenTaskStoreConfiguredAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client.ServerCapabilities?.Tasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTools_ShowsTaskSupport_WhenToolIsAsyncAsync()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<LongRunningTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// Act
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var asyncTool = tools.FirstOrDefault(t => t.Name == "long_running_operation");
|
||||
Assert.NotNull(asyncTool);
|
||||
Assert.NotNull(asyncTool.ProtocolTool.Execution);
|
||||
Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution.TaskSupport);
|
||||
}
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class LongRunningTools
|
||||
{
|
||||
[McpServerTool, Description("Simulates a long-running operation")]
|
||||
public static async Task<string> LongRunningOperation(
|
||||
[Description("Duration of the operation in milliseconds")] int durationMs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(durationMs, cancellationToken);
|
||||
return $"Operation completed after {durationMs}ms";
|
||||
}
|
||||
|
||||
[McpServerTool, Description("A synchronous tool that does not support tasks")]
|
||||
public static string SyncTool([Description("Input message")] string message)
|
||||
{
|
||||
return $"Sync result: {message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue