Implement MCP task support (SEP-1686) (#1170)
Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
parent
81664c3fce
commit
10e4f5b20f
2 changed files with 11580 additions and 46 deletions
|
|
@ -0,0 +1,261 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
public class McpClientTaskMethodsTests : ClientServerTestBase
|
||||
{
|
||||
public McpClientTaskMethodsTests(ITestOutputHelper outputHelper)
|
||||
: base(outputHelper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
// Add task store for server-side task support
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
services.AddSingleton<IMcpTaskStore>(taskStore);
|
||||
|
||||
// Configure server to use the task store directly
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
});
|
||||
|
||||
// Add a simple tool for testing
|
||||
mcpServerBuilder.WithTools([McpServerTool.Create(
|
||||
async (string input, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(50, ct);
|
||||
return $"Processed: {input}";
|
||||
},
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "test-tool",
|
||||
Description = "A test tool"
|
||||
})]);
|
||||
}
|
||||
|
||||
private static IDictionary<string, JsonElement> CreateArguments(string key, object? value)
|
||||
{
|
||||
// For simple strings, just create a JsonElement from a string value
|
||||
return new Dictionary<string, JsonElement>
|
||||
{
|
||||
[key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone()
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskAsync_ReturnsTaskStatus()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Create a task by calling a tool with task metadata
|
||||
var callResult = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "test"),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// The response should contain task metadata
|
||||
Assert.NotNull(callResult.Task);
|
||||
|
||||
string taskId = callResult.Task.TaskId;
|
||||
|
||||
// Now get the task status
|
||||
var task = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(taskId, task.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskAsync_ThrowsForInvalidTaskId()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await client.GetTaskAsync("", cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskResultAsync_ReturnsDeserializedResult()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Create a task
|
||||
var callResult = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "hello"),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(callResult.Task);
|
||||
string taskId = callResult.Task.TaskId;
|
||||
|
||||
// Wait for task to complete and get the result
|
||||
JsonElement result = await client.GetTaskResultAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Verify the result has the expected CallToolResult shape
|
||||
CallToolResult? toolResult = result.Deserialize<CallToolResult>(McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(toolResult);
|
||||
Assert.NotEmpty(toolResult.Content);
|
||||
|
||||
TextContentBlock? textContent = toolResult.Content[0] as TextContentBlock;
|
||||
Assert.NotNull(textContent);
|
||||
Assert.Equal("Processed: hello", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskResultAsync_ThrowsForInvalidTaskId()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await client.GetTaskResultAsync("", cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasksAsync_ReturnsTasks()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Create a task
|
||||
var callResult = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "test"),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(callResult.Task);
|
||||
string taskId = callResult.Task.TaskId;
|
||||
|
||||
// List all tasks
|
||||
var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(tasks);
|
||||
Assert.Contains(tasks, t => t.TaskId == taskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasksAsync_HandlesEmptyResult()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// List tasks (may or may not be empty depending on state)
|
||||
var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(tasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasksAsync_LowLevel_ReturnsRawResult()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Create a task first
|
||||
await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "task1"),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Use low-level API
|
||||
var result = await client.ListTasksAsync(new ListTasksRequestParams(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Tasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasksAsync_LowLevel_ThrowsForNullParams()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
||||
await client.ListTasksAsync((ListTasksRequestParams)null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelTaskAsync_CancelsRunningTask()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Create a task
|
||||
var callResult = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "test"),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(callResult.Task);
|
||||
string taskId = callResult.Task.TaskId;
|
||||
|
||||
// Cancel the task
|
||||
var canceledTask = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(taskId, canceledTask.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelTaskAsync_ThrowsForInvalidTaskId()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await client.CancelTaskAsync("", cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTasksAsync_HandlesPagination()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Create multiple tasks
|
||||
var taskIds = new List<string>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", $"task-{i}"),
|
||||
Task = new McpTaskMetadata()
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result.Task);
|
||||
taskIds.Add(result.Task.TaskId);
|
||||
}
|
||||
|
||||
// List all tasks (should handle pagination automatically if needed)
|
||||
var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(tasks);
|
||||
Assert.True(tasks.Count >= taskIds.Count, "Should retrieve at least the tasks we created");
|
||||
|
||||
// Verify all our tasks are in the result
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
Assert.Contains(tasks, t => t.TaskId == taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,866 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for task-based sampling and elicitation on the client side.
|
||||
/// Tests the client's ability to receive task-augmented requests from the server,
|
||||
/// execute them as tasks, and report results.
|
||||
/// </summary>
|
||||
public class McpClientTaskSamplingElicitationTests : ClientServerTestBase
|
||||
{
|
||||
public McpClientTaskSamplingElicitationTests(ITestOutputHelper outputHelper)
|
||||
: base(outputHelper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
// Add task store for server-side task support
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
services.AddSingleton<IMcpTaskStore>(taskStore);
|
||||
|
||||
// Configure server to use the task store
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = taskStore;
|
||||
});
|
||||
|
||||
// Add a tool that uses sampling to generate responses
|
||||
mcpServerBuilder.WithTools([McpServerTool.Create(
|
||||
async (string prompt, McpServer server, CancellationToken ct) =>
|
||||
{
|
||||
// This tool requests sampling from the client
|
||||
var result = await server.SampleAsync(new CreateMessageRequestParams
|
||||
{
|
||||
Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }],
|
||||
MaxTokens = 100
|
||||
}, ct);
|
||||
|
||||
return result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "No response";
|
||||
},
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "sample-tool",
|
||||
Description = "A tool that uses sampling"
|
||||
}),
|
||||
McpServerTool.Create(
|
||||
async (string message, McpServer server, CancellationToken ct) =>
|
||||
{
|
||||
// This tool requests elicitation from the client
|
||||
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||
{
|
||||
Message = message,
|
||||
RequestedSchema = new()
|
||||
}, ct);
|
||||
|
||||
return result.Action == "confirm" ? "Confirmed" : "Declined";
|
||||
},
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "elicit-tool",
|
||||
Description = "A tool that uses elicitation"
|
||||
})]);
|
||||
}
|
||||
|
||||
private static IDictionary<string, JsonElement> CreateArguments(string key, object? value)
|
||||
{
|
||||
return new Dictionary<string, JsonElement>
|
||||
{
|
||||
[key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone()
|
||||
};
|
||||
}
|
||||
|
||||
#region Client Task-Based Sampling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithTaskStoreAndSamplingHandler_AdvertisesTaskAugmentedSamplingCapability()
|
||||
{
|
||||
// Arrange - Create client with task store and sampling handler
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = (request, progress, ct) =>
|
||||
{
|
||||
return new ValueTask<CreateMessageResult>(new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Sampled response" }],
|
||||
Model = "test-model"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// The server should see the client's task capabilities
|
||||
// We verify by checking server can use task-augmented requests
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
Assert.NotNull(Server.ClientCapabilities.Sampling);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Sampling?.CreateMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithoutTaskStore_DoesNotAdvertiseTaskAugmentedSamplingCapability()
|
||||
{
|
||||
// Arrange - Create client with sampling handler but NO task store
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
// No TaskStore configured
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = (request, progress, ct) =>
|
||||
{
|
||||
return new ValueTask<CreateMessageResult>(new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Sampled response" }],
|
||||
Model = "test-model"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// The server should see sampling capability but NOT task-augmented sampling
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
Assert.NotNull(Server.ClientCapabilities.Sampling);
|
||||
|
||||
// Task capabilities should be null (no task store)
|
||||
Assert.Null(Server.ClientCapabilities.Tasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_SampleAsTaskAsync_FailsWhenClientDoesNotSupportTaskAugmentedSampling()
|
||||
{
|
||||
// Arrange - Client with sampling handler but NO task store
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = (request, progress, ct) =>
|
||||
{
|
||||
return new ValueTask<CreateMessageResult>(new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Response" }],
|
||||
Model = "model"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Act & Assert - Server should throw when trying to use task-augmented sampling
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await Server.SampleAsTaskAsync(
|
||||
new CreateMessageRequestParams
|
||||
{
|
||||
Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Test" }] }],
|
||||
MaxTokens = 100
|
||||
},
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
});
|
||||
|
||||
Assert.Contains("task-augmented sampling", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithTaskStore_CanExecuteSamplingAsTask()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var samplingCompleted = new TaskCompletionSource<bool>();
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = async (request, progress, ct) =>
|
||||
{
|
||||
// Simulate some work
|
||||
await Task.Delay(50, ct);
|
||||
samplingCompleted.TrySetResult(true);
|
||||
return new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Task-based sampling response" }],
|
||||
Model = "test-model"
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Act - Server requests task-augmented sampling
|
||||
var mcpTask = await Server.SampleAsTaskAsync(
|
||||
new CreateMessageRequestParams
|
||||
{
|
||||
Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }],
|
||||
MaxTokens = 100
|
||||
},
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - Task was created
|
||||
Assert.NotNull(mcpTask);
|
||||
Assert.NotEmpty(mcpTask.TaskId);
|
||||
Assert.Equal(McpTaskStatus.Working, mcpTask.Status);
|
||||
|
||||
// Wait for sampling to complete
|
||||
await samplingCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// Poll until task is complete
|
||||
McpTask taskStatus;
|
||||
do
|
||||
{
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken);
|
||||
}
|
||||
while (taskStatus.Status == McpTaskStatus.Working);
|
||||
|
||||
Assert.Equal(McpTaskStatus.Completed, taskStatus.Status);
|
||||
|
||||
// Get the result
|
||||
var result = await Server.GetTaskResultAsync<CreateMessageResult>(
|
||||
mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
|
||||
Assert.Equal("Task-based sampling response", textContent.Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Client Task-Based Elicitation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithTaskStoreAndElicitationHandler_AdvertisesTaskAugmentedElicitationCapability()
|
||||
{
|
||||
// Arrange - Create client with task store and elicitation handler
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
return new ValueTask<ElicitResult>(new ElicitResult { Action = "confirm" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Verify client advertised task-augmented elicitation
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
Assert.NotNull(Server.ClientCapabilities.Elicitation);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Elicitation?.Create);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithoutTaskStore_DoesNotAdvertiseTaskAugmentedElicitationCapability()
|
||||
{
|
||||
// Arrange - Create client with elicitation handler but NO task store
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
// No TaskStore configured
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
return new ValueTask<ElicitResult>(new ElicitResult { Action = "confirm" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Verify elicitation is supported but NOT task-augmented
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
Assert.NotNull(Server.ClientCapabilities.Elicitation);
|
||||
Assert.Null(Server.ClientCapabilities.Tasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_ElicitAsTaskAsync_FailsWhenClientDoesNotSupportTaskAugmentedElicitation()
|
||||
{
|
||||
// Arrange - Client with elicitation handler but NO task store
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
return new ValueTask<ElicitResult>(new ElicitResult { Action = "confirm" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Act & Assert - Server should throw when trying to use task-augmented elicitation
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await Server.ElicitAsTaskAsync(
|
||||
new ElicitRequestParams
|
||||
{
|
||||
Message = "Please confirm",
|
||||
RequestedSchema = new()
|
||||
},
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
});
|
||||
|
||||
Assert.Contains("task-augmented elicitation", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithTaskStore_CanExecuteElicitationAsTask()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var elicitationCompleted = new TaskCompletionSource<bool>();
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = async (request, ct) =>
|
||||
{
|
||||
// Simulate user interaction time
|
||||
await Task.Delay(50, ct);
|
||||
elicitationCompleted.TrySetResult(true);
|
||||
return new ElicitResult
|
||||
{
|
||||
Action = "accept",
|
||||
Content = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["answer"] = JsonDocument.Parse("\"yes\"").RootElement.Clone()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Act - Server requests task-augmented elicitation
|
||||
var mcpTask = await Server.ElicitAsTaskAsync(
|
||||
new ElicitRequestParams
|
||||
{
|
||||
Message = "Do you want to proceed?",
|
||||
RequestedSchema = new()
|
||||
},
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - Task was created
|
||||
Assert.NotNull(mcpTask);
|
||||
Assert.NotEmpty(mcpTask.TaskId);
|
||||
Assert.Equal(McpTaskStatus.Working, mcpTask.Status);
|
||||
|
||||
// Wait for elicitation to complete
|
||||
await elicitationCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// Poll until task is complete
|
||||
McpTask taskStatus;
|
||||
do
|
||||
{
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken);
|
||||
}
|
||||
while (taskStatus.Status == McpTaskStatus.Working);
|
||||
|
||||
Assert.Equal(McpTaskStatus.Completed, taskStatus.Status);
|
||||
|
||||
// Get the result
|
||||
var result = await Server.GetTaskResultAsync<ElicitResult>(
|
||||
mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("accept", result.Action);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Client Task Reporting Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Client_CanListOwnTasks()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = async (request, progress, ct) =>
|
||||
{
|
||||
await Task.Delay(50, ct);
|
||||
return new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Response" }],
|
||||
Model = "model"
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Create multiple tasks
|
||||
var task1 = await Server.SampleAsTaskAsync(
|
||||
new CreateMessageRequestParams { Messages = [], MaxTokens = 100 },
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var task2 = await Server.SampleAsTaskAsync(
|
||||
new CreateMessageRequestParams { Messages = [], MaxTokens = 100 },
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Act - Server lists tasks from client
|
||||
var tasks = await Server.ListTasksAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(tasks);
|
||||
Assert.True(tasks.Count >= 2, "Should have at least 2 tasks");
|
||||
Assert.Contains(tasks, t => t.TaskId == task1.TaskId);
|
||||
Assert.Contains(tasks, t => t.TaskId == task2.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_CanCancelTasks()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var samplingStarted = new TaskCompletionSource<bool>();
|
||||
var allowCompletion = new TaskCompletionSource<bool>();
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = async (request, progress, ct) =>
|
||||
{
|
||||
samplingStarted.TrySetResult(true);
|
||||
// Wait for either completion signal or cancellation
|
||||
try
|
||||
{
|
||||
await allowCompletion.Task.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Should not reach here" }],
|
||||
Model = "model"
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Create a task that will be in progress
|
||||
var mcpTask = await Server.SampleAsTaskAsync(
|
||||
new CreateMessageRequestParams { Messages = [], MaxTokens = 100 },
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for sampling to start
|
||||
await samplingStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// Act - Cancel the task
|
||||
var cancelledTask = await Server.CancelTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cancelledTask);
|
||||
Assert.Equal(McpTaskStatus.Cancelled, cancelledTask.Status);
|
||||
|
||||
// Allow completion to avoid hanging (the handler might still be running)
|
||||
allowCompletion.TrySetResult(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_TaskStatusNotifications_SentWhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var workingNotificationReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var completedNotificationReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var notificationsReceived = new List<McpTask>();
|
||||
var notificationsLock = new object();
|
||||
string? expectedTaskId = null;
|
||||
var expectedTaskIdLock = new object();
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
SendTaskStatusNotifications = true,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = async (request, progress, ct) =>
|
||||
{
|
||||
await Task.Delay(100, ct);
|
||||
return new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Done" }],
|
||||
Model = "model"
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register notification handler on the server BEFORE creating the client
|
||||
var notificationHandler = Server.RegisterNotificationHandler(
|
||||
NotificationMethods.TaskStatusNotification,
|
||||
(notification, ct) =>
|
||||
{
|
||||
if (notification.Params is not { } paramsNode)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var taskNotification = JsonSerializer.Deserialize<McpTaskStatusNotificationParams>(
|
||||
paramsNode, McpJsonUtilities.DefaultOptions);
|
||||
if (taskNotification is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
// Only track notifications for our task
|
||||
string? taskId;
|
||||
lock (expectedTaskIdLock)
|
||||
{
|
||||
taskId = expectedTaskId;
|
||||
}
|
||||
if (taskId is not null && taskNotification.TaskId != taskId)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
lock (notificationsLock)
|
||||
{
|
||||
notificationsReceived.Add(new McpTask
|
||||
{
|
||||
TaskId = taskNotification.TaskId,
|
||||
Status = taskNotification.Status,
|
||||
CreatedAt = taskNotification.CreatedAt,
|
||||
LastUpdatedAt = taskNotification.LastUpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
// Signal when we receive the Working and Completed notifications
|
||||
if (taskNotification.Status == McpTaskStatus.Working)
|
||||
{
|
||||
workingNotificationReceived.TrySetResult(true);
|
||||
}
|
||||
else if (taskNotification.Status == McpTaskStatus.Completed)
|
||||
{
|
||||
completedNotificationReceived.TrySetResult(true);
|
||||
}
|
||||
|
||||
return default;
|
||||
});
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Act - Create a task
|
||||
var mcpTask = await Server.SampleAsTaskAsync(
|
||||
new CreateMessageRequestParams { Messages = [], MaxTokens = 100 },
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Store the expected task ID for filtering
|
||||
lock (expectedTaskIdLock)
|
||||
{
|
||||
expectedTaskId = mcpTask.TaskId;
|
||||
}
|
||||
|
||||
// Wait for both Working and Completed notifications to arrive
|
||||
// The notifications are sent asynchronously so we need to wait for both
|
||||
await Task.WhenAll(
|
||||
workingNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken),
|
||||
completedNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||
|
||||
// Assert - Should have received notifications for status transitions
|
||||
await notificationHandler.DisposeAsync();
|
||||
|
||||
List<McpTask> notifications;
|
||||
lock (notificationsLock)
|
||||
{
|
||||
notifications = [.. notificationsReceived];
|
||||
}
|
||||
|
||||
Assert.NotEmpty(notifications);
|
||||
Assert.Contains(notifications, t => t.Status == McpTaskStatus.Working);
|
||||
Assert.Contains(notifications, t => t.Status == McpTaskStatus.Completed);
|
||||
|
||||
// Verify all notifications are for the correct task
|
||||
Assert.All(notifications, t => Assert.Equal(mcpTask.TaskId, t.TaskId));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Client_SamplingHandlerException_ResultsInFailedTask()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var samplingAttempted = new TaskCompletionSource<bool>();
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = (request, progress, ct) =>
|
||||
{
|
||||
samplingAttempted.TrySetResult(true);
|
||||
throw new InvalidOperationException("Sampling failed!");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Act
|
||||
var mcpTask = await Server.SampleAsTaskAsync(
|
||||
new CreateMessageRequestParams { Messages = [], MaxTokens = 100 },
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for sampling attempt
|
||||
await samplingAttempted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// Poll until task status changes
|
||||
McpTask taskStatus;
|
||||
do
|
||||
{
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken);
|
||||
}
|
||||
while (taskStatus.Status == McpTaskStatus.Working);
|
||||
|
||||
// Assert - Task should be in failed state
|
||||
Assert.Equal(McpTaskStatus.Failed, taskStatus.Status);
|
||||
Assert.NotNull(taskStatus.StatusMessage);
|
||||
Assert.Contains("Sampling failed!", taskStatus.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_ElicitationHandlerException_ResultsInFailedTask()
|
||||
{
|
||||
// Arrange
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var elicitationAttempted = new TaskCompletionSource<bool>();
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
elicitationAttempted.TrySetResult(true);
|
||||
throw new InvalidOperationException("Elicitation failed!");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Act
|
||||
var mcpTask = await Server.ElicitAsTaskAsync(
|
||||
new ElicitRequestParams
|
||||
{
|
||||
Message = "Test",
|
||||
RequestedSchema = new()
|
||||
},
|
||||
new McpTaskMetadata(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for elicitation attempt
|
||||
await elicitationAttempted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// Poll until task status changes
|
||||
McpTask taskStatus;
|
||||
do
|
||||
{
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken);
|
||||
}
|
||||
while (taskStatus.Status == McpTaskStatus.Working);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(McpTaskStatus.Failed, taskStatus.Status);
|
||||
Assert.NotNull(taskStatus.StatusMessage);
|
||||
Assert.Contains("Elicitation failed!", taskStatus.StatusMessage);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Capability Validation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithOnlySamplingHandler_OnlyAdvertisesSamplingTasks()
|
||||
{
|
||||
// Arrange - Client with only sampling handler and task store
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = (request, progress, ct) =>
|
||||
{
|
||||
return new ValueTask<CreateMessageResult>(new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Response" }],
|
||||
Model = "model"
|
||||
});
|
||||
}
|
||||
// No ElicitationHandler
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks);
|
||||
|
||||
// Should have sampling task capability
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Sampling?.CreateMessage);
|
||||
|
||||
// Should NOT have elicitation task capability
|
||||
Assert.Null(Server.ClientCapabilities.Tasks.Requests?.Elicitation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithOnlyElicitationHandler_OnlyAdvertisesElicitationTasks()
|
||||
{
|
||||
// Arrange - Client with only elicitation handler and task store
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
return new ValueTask<ElicitResult>(new ElicitResult { Action = "confirm" });
|
||||
}
|
||||
// No SamplingHandler
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks);
|
||||
|
||||
// Should have elicitation task capability
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Elicitation?.Create);
|
||||
|
||||
// Should NOT have sampling task capability
|
||||
Assert.Null(Server.ClientCapabilities.Tasks.Requests?.Sampling);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithBothHandlers_AdvertisesBothTaskCapabilities()
|
||||
{
|
||||
// Arrange - Client with both handlers and task store
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
SamplingHandler = (request, progress, ct) =>
|
||||
{
|
||||
return new ValueTask<CreateMessageResult>(new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "Response" }],
|
||||
Model = "model"
|
||||
});
|
||||
},
|
||||
ElicitationHandler = (request, ct) =>
|
||||
{
|
||||
return new ValueTask<ElicitResult>(new ElicitResult { Action = "confirm" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Requests);
|
||||
|
||||
// Should have both capabilities
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Requests.Sampling?.CreateMessage);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Requests.Elicitation?.Create);
|
||||
|
||||
// Should also have list and cancel capabilities
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.List);
|
||||
Assert.NotNull(Server.ClientCapabilities.Tasks.Cancel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithNoHandlers_DoesNotAdvertiseTaskCapabilities()
|
||||
{
|
||||
// Arrange - Client with task store but no handlers
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
TaskStore = taskStore,
|
||||
Handlers = new McpClientHandlers()
|
||||
// No handlers configured
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Assert - No capabilities should be advertised without handlers
|
||||
Assert.NotNull(Server.ClientCapabilities);
|
||||
|
||||
// Note: Tasks capability is advertised based on task store being present,
|
||||
// but request types depend on specific handlers
|
||||
if (Server.ClientCapabilities.Tasks is not null)
|
||||
{
|
||||
// If Tasks is present, requests should be null or have no request types
|
||||
var requests = Server.ClientCapabilities.Tasks.Requests;
|
||||
if (requests is not null)
|
||||
{
|
||||
Assert.Null(requests.Sampling);
|
||||
Assert.Null(requests.Elicitation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Loading…
Reference in a new issue