Extract Tasks into the ModelContextProtocol.Extensions.Tasks extension package (#1693)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
ffc557f920
commit
2ca96f548b
16 changed files with 2559 additions and 1895 deletions
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
|
@ -11,7 +12,7 @@ namespace ModelContextProtocol.Tests.Client;
|
|||
|
||||
/// <summary>
|
||||
/// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync,
|
||||
/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync.
|
||||
/// UpdateTaskAsync, CallToolAsTaskAsync, and the automatic polling in CallToolWithPollingAsync.
|
||||
/// </summary>
|
||||
public class McpClientTaskMethodsTests : ClientServerTestBase
|
||||
{
|
||||
|
|
@ -25,15 +26,12 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = new InMemoryMcpTaskStore
|
||||
mcpServerBuilder
|
||||
.WithTasks(new InMemoryMcpTaskStore
|
||||
{
|
||||
DefaultPollIntervalMs = 50,
|
||||
};
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([McpServerTool.Create(
|
||||
})
|
||||
.WithTools([McpServerTool.Create(
|
||||
async (string input, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(50, ct);
|
||||
|
|
@ -60,7 +58,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
@ -97,12 +95,12 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask()
|
||||
public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
@ -122,12 +120,12 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "hello"),
|
||||
}, ct);
|
||||
}, cancellationToken: ct);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Content);
|
||||
|
|
@ -141,7 +139,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
@ -172,7 +170,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
||||
await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken));
|
||||
await client.CancelTaskAsync((string)null!, cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -194,7 +192,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
@ -235,7 +233,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase
|
|||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@
|
|||
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.Extensions.Apps\ModelContextProtocol.Extensions.Apps.csproj" />
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.Extensions.Tasks\ModelContextProtocol.Extensions.Tasks.csproj" />
|
||||
<ProjectReference Include="..\ModelContextProtocol.TestServer\ModelContextProtocol.TestServer.csproj" />
|
||||
<ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
|
@ -27,8 +28,8 @@ public static class TaskSerializationTests
|
|||
Meta = new JsonObject { ["key"] = "value" }
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<CreateTaskResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<CreateTaskResult>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("task-123", deserialized.TaskId);
|
||||
|
|
@ -57,7 +58,7 @@ public static class TaskSerializationTests
|
|||
ResultType = "task",
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
|
||||
|
||||
// Must use camelCase wire names
|
||||
Assert.Contains("\"ttlMs\":", json);
|
||||
|
|
@ -82,7 +83,7 @@ public static class TaskSerializationTests
|
|||
ResultType = "task",
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
|
||||
var node = JsonNode.Parse(json)!;
|
||||
|
||||
Assert.Equal("task", (string)node["resultType"]!);
|
||||
|
|
@ -104,8 +105,8 @@ public static class TaskSerializationTests
|
|||
PollIntervalMs = 2000,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var working = Assert.IsType<WorkingTaskResult>(deserialized);
|
||||
Assert.Equal("w1", working.TaskId);
|
||||
|
|
@ -126,8 +127,8 @@ public static class TaskSerializationTests
|
|||
Result = resultPayload,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var completed = Assert.IsType<CompletedTaskResult>(deserialized);
|
||||
Assert.Equal("c1", completed.TaskId);
|
||||
|
|
@ -147,8 +148,8 @@ public static class TaskSerializationTests
|
|||
Error = errorPayload,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var failed = Assert.IsType<FailedTaskResult>(deserialized);
|
||||
Assert.Equal("f1", failed.TaskId);
|
||||
|
|
@ -167,8 +168,8 @@ public static class TaskSerializationTests
|
|||
StatusMessage = "User cancelled",
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var cancelled = Assert.IsType<CancelledTaskResult>(deserialized);
|
||||
Assert.Equal("x1", cancelled.TaskId);
|
||||
|
|
@ -195,8 +196,8 @@ public static class TaskSerializationTests
|
|||
InputRequests = inputRequests,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<GetTaskResult>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var inputRequired = Assert.IsType<InputRequiredTaskResult>(deserialized);
|
||||
Assert.Equal("i1", inputRequired.TaskId);
|
||||
|
|
@ -228,7 +229,7 @@ public static class TaskSerializationTests
|
|||
_ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""",
|
||||
};
|
||||
|
||||
var result = JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions);
|
||||
var result = JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options);
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType(expectedType, result);
|
||||
}
|
||||
|
|
@ -238,42 +239,42 @@ public static class TaskSerializationTests
|
|||
public static void GetTaskResult_MissingTaskId_ThrowsJsonException()
|
||||
{
|
||||
var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void GetTaskResult_MissingStatus_ThrowsJsonException()
|
||||
{
|
||||
var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void GetTaskResult_UnknownStatus_ThrowsJsonException()
|
||||
{
|
||||
var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException()
|
||||
{
|
||||
var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void GetTaskResult_FailedMissingError_ThrowsJsonException()
|
||||
{
|
||||
var json = """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void GetTaskResult_InputRequiredMissingInputRequests_ThrowsJsonException()
|
||||
{
|
||||
var json = """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<GetTaskResult>(json, McpTasksJsonContext.Default.Options));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -303,14 +304,14 @@ public static class TaskSerializationTests
|
|||
["k"] = new InputRequest
|
||||
{
|
||||
Method = "test/method",
|
||||
Params = JsonSerializer.SerializeToElement("ask", McpJsonUtilities.DefaultOptions),
|
||||
Params = JsonSerializer.SerializeToElement("ask", McpTasksJsonContext.Default.Options),
|
||||
},
|
||||
},
|
||||
},
|
||||
_ => throw new InvalidOperationException()
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize(value, McpTasksJsonContext.Default.Options);
|
||||
var node = JsonNode.Parse(json)!;
|
||||
|
||||
Assert.Equal("complete", (string?)node["resultType"]);
|
||||
|
|
@ -328,10 +329,10 @@ public static class TaskSerializationTests
|
|||
[InlineData(McpTaskStatus.Failed, "failed")]
|
||||
public static void McpTaskStatus_SerializesAsSnakeCase(McpTaskStatus status, string expectedWireValue)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(status, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize(status, McpTasksJsonContext.Default.Options);
|
||||
Assert.Equal($"\"{expectedWireValue}\"", json);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<McpTaskStatus>(json, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<McpTaskStatus>(json, McpTasksJsonContext.Default.Options);
|
||||
Assert.Equal(status, deserialized);
|
||||
}
|
||||
|
||||
|
|
@ -350,8 +351,8 @@ public static class TaskSerializationTests
|
|||
StatusMessage = "Working on it",
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var working = Assert.IsType<WorkingTaskNotificationParams>(deserialized);
|
||||
Assert.Equal("n1", working.TaskId);
|
||||
|
|
@ -370,8 +371,8 @@ public static class TaskSerializationTests
|
|||
Result = resultPayload,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var completed = Assert.IsType<CompletedTaskNotificationParams>(deserialized);
|
||||
Assert.Equal("n2", completed.TaskId);
|
||||
|
|
@ -390,8 +391,8 @@ public static class TaskSerializationTests
|
|||
Error = errorPayload,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var failed = Assert.IsType<FailedTaskNotificationParams>(deserialized);
|
||||
Assert.Equal("n3", failed.TaskId);
|
||||
|
|
@ -408,8 +409,8 @@ public static class TaskSerializationTests
|
|||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
Assert.IsType<CancelledTaskNotificationParams>(deserialized);
|
||||
}
|
||||
|
|
@ -429,8 +430,8 @@ public static class TaskSerializationTests
|
|||
InputRequests = inputRequests,
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize<TaskStatusNotificationParams>(original, McpTasksJsonContext.Default.Options);
|
||||
var deserialized = JsonSerializer.Deserialize<TaskStatusNotificationParams>(json, McpTasksJsonContext.Default.Options);
|
||||
|
||||
var inputRequired = Assert.IsType<InputRequiredTaskNotificationParams>(deserialized);
|
||||
Assert.NotNull(inputRequired.InputRequests);
|
||||
|
|
@ -439,10 +440,10 @@ public static class TaskSerializationTests
|
|||
|
||||
#endregion
|
||||
|
||||
#region ResultOrCreatedTask
|
||||
#region ResultOrAlternate
|
||||
|
||||
[Fact]
|
||||
public static void ResultOrCreatedTask_ImplicitConversion_FromResult()
|
||||
public static void ResultOrAlternate_ImplicitConversion_FromResult()
|
||||
{
|
||||
CallToolResult callResult = new() { Content = [new TextContentBlock { Text = "hi" }] };
|
||||
|
||||
|
|
@ -454,7 +455,7 @@ public static class TaskSerializationTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult()
|
||||
public static void ResultOrAlternate_ImplicitConversion_FromCreateTaskResult()
|
||||
{
|
||||
CreateTaskResult taskCreated = new()
|
||||
{
|
||||
|
|
@ -472,7 +473,7 @@ public static class TaskSerializationTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public static void ResultOrCreatedTask_IsTask_FalseForResult_TrueForTask()
|
||||
public static void ResultOrAlternate_IsTask_FalseForResult_TrueForTask()
|
||||
{
|
||||
var result = new ResultOrCreatedTask<CallToolResult>(new CallToolResult());
|
||||
var task = new ResultOrCreatedTask<CallToolResult>(new CreateTaskResult
|
||||
|
|
@ -496,7 +497,7 @@ public static class TaskSerializationTests
|
|||
{
|
||||
// SEP-2663: tasks/update responses use resultType="complete".
|
||||
var result = new UpdateTaskResult { ResultType = "complete" };
|
||||
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
|
||||
var node = JsonNode.Parse(json)!;
|
||||
|
||||
Assert.Equal("complete", (string?)node["resultType"]);
|
||||
|
|
@ -507,7 +508,7 @@ public static class TaskSerializationTests
|
|||
{
|
||||
// SEP-2663: tasks/cancel responses use resultType="complete".
|
||||
var result = new CancelTaskResult { ResultType = "complete" };
|
||||
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
|
||||
string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
|
||||
var node = JsonNode.Parse(json)!;
|
||||
|
||||
Assert.Equal("complete", (string?)node["resultType"]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that combining the non-alternate <see cref="McpRequestFilters.CallToolFilters"/> with the
|
||||
/// alternate <see cref="McpRequestFilters.CallToolWithAlternateFilters"/> fails at configuration time
|
||||
/// with an actionable message.
|
||||
/// </summary>
|
||||
public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||
{
|
||||
#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateFilters seam
|
||||
private static McpRequestFilter<CallToolRequestParams, CallToolResult> PassThroughCallToolFilter =>
|
||||
next => next;
|
||||
|
||||
private static McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> PassThroughAlternateFilter =>
|
||||
next => next;
|
||||
|
||||
[Fact]
|
||||
public async Task MixingCallToolFilters_WithAlternateFilters_ThrowsActionableError()
|
||||
{
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
|
||||
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||
options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter);
|
||||
options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => McpServer.Create(transport, options, LoggerFactory));
|
||||
|
||||
Assert.Contains(nameof(McpRequestFilters.CallToolFilters), ex.Message);
|
||||
Assert.Contains(nameof(McpRequestFilters.CallToolWithAlternateFilters), ex.Message);
|
||||
Assert.Contains("AddAuthorizationFilters", ex.Message);
|
||||
Assert.Contains("WithTasks", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolFiltersAlone_Succeeds()
|
||||
{
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
|
||||
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||
options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter);
|
||||
|
||||
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlternateFiltersAlone_Succeeds()
|
||||
{
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
|
||||
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||
options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter);
|
||||
|
||||
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom request handlers registered through
|
||||
/// <see cref="McpServerOptions.RequestHandlers"/> cannot silently replace a built-in method
|
||||
/// or another custom handler.
|
||||
/// </summary>
|
||||
public class CustomRequestHandlerCollisionTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||
{
|
||||
#pragma warning disable MCPEXP002
|
||||
private static McpServerRequestHandler CreateHandler(string method) => new()
|
||||
{
|
||||
Method = method,
|
||||
Handler = (request, cancellationToken) => new ValueTask<JsonNode?>((JsonNode?)null),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_CollidingWithBuiltInMethod_Throws()
|
||||
{
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
|
||||
var options = new McpServerOptions
|
||||
{
|
||||
Capabilities = new() { Tools = new() },
|
||||
RequestHandlers = [CreateHandler("tools/call")],
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => McpServer.Create(transport, options, LoggerFactory));
|
||||
|
||||
Assert.Contains("tools/call", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_CollidingWithInitialize_Throws()
|
||||
{
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
|
||||
var options = new McpServerOptions
|
||||
{
|
||||
RequestHandlers = [CreateHandler("initialize")],
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => McpServer.Create(transport, options, LoggerFactory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_DuplicateCustomMethod_Throws()
|
||||
{
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
|
||||
var options = new McpServerOptions
|
||||
{
|
||||
RequestHandlers = [CreateHandler("custom/method"), CreateHandler("custom/method")],
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => McpServer.Create(transport, options, LoggerFactory));
|
||||
|
||||
Assert.Contains("custom/method", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_UniqueMethod_Succeeds()
|
||||
{
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
|
||||
var options = new McpServerOptions
|
||||
{
|
||||
RequestHandlers = [CreateHandler("custom/method")],
|
||||
};
|
||||
|
||||
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Text.Json;
|
||||
|
|
@ -87,6 +88,59 @@ public class InMemoryMcpTaskStoreTests
|
|||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskAsync_WithinTimeToLive_ReturnsTask()
|
||||
{
|
||||
var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMinutes(10) };
|
||||
var created = await store.CreateTaskAsync(CT);
|
||||
|
||||
var result = await store.GetTaskAsync(created.TaskId, CT);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(created.TaskId, result.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskAsync_AfterTimeToLiveElapsed_ReturnsNull()
|
||||
{
|
||||
var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMilliseconds(100) };
|
||||
var created = await store.CreateTaskAsync(CT);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), CT);
|
||||
|
||||
var result = await store.GetTaskAsync(created.TaskId, CT);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskAsync_WithoutTimeToLive_DoesNotExpire()
|
||||
{
|
||||
var store = new InMemoryMcpTaskStore();
|
||||
var created = await store.CreateTaskAsync(CT);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), CT);
|
||||
|
||||
var result = await store.GetTaskAsync(created.TaskId, CT);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(created.TaskId, result.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskAsync_WithZeroTimeToLive_DoesNotExpire()
|
||||
{
|
||||
var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.Zero };
|
||||
var created = await store.CreateTaskAsync(CT);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), CT);
|
||||
|
||||
var result = await store.GetTaskAsync(created.TaskId, CT);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(created.TaskId, result.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetCompletedAsync_TransitionsToCompleted()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
|
@ -5,6 +6,8 @@ using System.Runtime.InteropServices;
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
#pragma warning disable MCPEXP001, MCPEXP002
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -29,89 +32,97 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.Capabilities ??= new ServerCapabilities();
|
||||
options.Capabilities.Extensions ??= new Dictionary<string, object>();
|
||||
options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject();
|
||||
options.RequestHandlers ??= new List<McpServerRequestHandler>();
|
||||
|
||||
options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) =>
|
||||
options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
|
||||
{
|
||||
_capturedMeta = context.Params?.Meta;
|
||||
var store = context.Server.Services!.GetRequiredService<InMemoryTaskStore>();
|
||||
var toolName = context.Params!.Name;
|
||||
|
||||
if (toolName == "immediate-tool")
|
||||
ResultOrAlternate<CallToolResult> result = toolName switch
|
||||
{
|
||||
return new CallToolResult()
|
||||
"immediate-tool" => new(new CallToolResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "immediate result" }],
|
||||
};
|
||||
}
|
||||
}),
|
||||
"async-tool" => ResultOrAlternate<CallToolResult>.FromAlternate(
|
||||
new CreateTaskResult
|
||||
{
|
||||
TaskId = store.CreateTask(),
|
||||
Status = McpTaskStatus.Working,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
PollIntervalMs = 50,
|
||||
ResultType = "task",
|
||||
},
|
||||
McpTasksJsonContext.Default.CreateTaskResult),
|
||||
"input-required-tool" => ResultOrAlternate<CallToolResult>.FromAlternate(
|
||||
new CreateTaskResult
|
||||
{
|
||||
TaskId = store.CreateTask(McpTaskStatus.InputRequired),
|
||||
Status = McpTaskStatus.InputRequired,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
PollIntervalMs = 50,
|
||||
ResultType = "task",
|
||||
},
|
||||
McpTasksJsonContext.Default.CreateTaskResult),
|
||||
_ => throw new McpException($"Unknown tool: {toolName}"),
|
||||
};
|
||||
|
||||
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 == "async-tool-default-resulttype")
|
||||
{
|
||||
// Intentionally leaves ResultType unset so the server boundary is responsible for
|
||||
// filling in "task" on the wire.
|
||||
var taskId = store.CreateTask();
|
||||
return new CreateTaskResult
|
||||
{
|
||||
TaskId = taskId,
|
||||
Status = McpTaskStatus.Working,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
PollIntervalMs = 50,
|
||||
};
|
||||
}
|
||||
|
||||
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}");
|
||||
return new ValueTask<ResultOrAlternate<CallToolResult>>(result);
|
||||
};
|
||||
|
||||
options.Handlers.GetTaskHandler = async (context, cancellationToken) =>
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
var store = context.Server.Services!.GetRequiredService<InMemoryTaskStore>();
|
||||
var taskId = context.Params!.TaskId;
|
||||
return store.GetTask(taskId);
|
||||
};
|
||||
Method = TasksProtocol.MethodTasksGet,
|
||||
Handler = (request, cancellationToken) =>
|
||||
{
|
||||
var requestParams = JsonSerializer.Deserialize<GetTaskRequestParams>(request.Params, McpTasksJsonContext.Default.Options)
|
||||
?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams);
|
||||
|
||||
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();
|
||||
};
|
||||
GetTaskResult result;
|
||||
try
|
||||
{
|
||||
result = _taskStore.GetTask(requestParams.TaskId);
|
||||
}
|
||||
catch (McpException ex)
|
||||
{
|
||||
throw new McpProtocolException(ex.Message, McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
options.Handlers.CancelTaskHandler = async (context, cancellationToken) =>
|
||||
return new ValueTask<JsonNode?>(JsonSerializer.SerializeToNode(result, McpTasksJsonContext.Default.Options));
|
||||
},
|
||||
});
|
||||
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
var store = context.Server.Services!.GetRequiredService<InMemoryTaskStore>();
|
||||
var taskId = context.Params!.TaskId;
|
||||
store.CancelTask(taskId);
|
||||
return new CancelTaskResult();
|
||||
};
|
||||
Method = TasksProtocol.MethodTasksUpdate,
|
||||
Handler = (request, cancellationToken) =>
|
||||
{
|
||||
var requestParams = JsonSerializer.Deserialize<UpdateTaskRequestParams>(request.Params, McpTasksJsonContext.Default.Options)
|
||||
?? throw new McpProtocolException("Missing params for tasks/update", McpErrorCode.InvalidParams);
|
||||
|
||||
_taskStore.ProvideInput(requestParams.TaskId, requestParams.InputResponses ?? new Dictionary<string, InputResponse>());
|
||||
return new ValueTask<JsonNode?>(JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.Options));
|
||||
},
|
||||
});
|
||||
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksCancel,
|
||||
Handler = (request, cancellationToken) =>
|
||||
{
|
||||
var requestParams = JsonSerializer.Deserialize<CancelTaskRequestParams>(request.Params, McpTasksJsonContext.Default.Options)
|
||||
?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams);
|
||||
|
||||
_taskStore.CancelTask(requestParams.TaskId);
|
||||
return new ValueTask<JsonNode?>(JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.Options));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -120,9 +131,9 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "immediate-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Content);
|
||||
|
|
@ -130,11 +141,11 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask()
|
||||
public async Task CallToolAsTaskAsync_ImmediateResult_ReturnsResultNotTask()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "immediate-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -145,11 +156,11 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated()
|
||||
public async Task CallToolAsTaskAsync_AsyncTool_ReturnsTaskCreated()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -169,7 +180,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
// Complete the task after a brief delay so polling finds it.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(100, ct);
|
||||
await Task.Delay(100, cancellationToken: ct);
|
||||
// The store should have exactly one task by now
|
||||
var taskId = _taskStore.GetAllTaskIds().Single();
|
||||
_taskStore.CompleteTask(taskId, new CallToolResult
|
||||
|
|
@ -178,9 +189,8 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
});
|
||||
}, ct);
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Content);
|
||||
|
|
@ -207,9 +217,8 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
};
|
||||
|
||||
await Assert.ThrowsAsync<McpException>(async () =>
|
||||
await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
ct));
|
||||
await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct));
|
||||
|
||||
Assert.True(await failedTask.Task);
|
||||
}
|
||||
|
|
@ -234,9 +243,8 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
};
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
ct));
|
||||
await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct));
|
||||
|
||||
Assert.True(await cancelledTask.Task);
|
||||
}
|
||||
|
|
@ -246,10 +254,10 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
// Create a task via CallToolRawAsync
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
// Create a task via CallToolAsTaskAsync
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
||||
|
|
@ -265,7 +273,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -291,7 +299,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
var extensions = client.ServerCapabilities.Extensions;
|
||||
#pragma warning restore MCP_EXTENSIONS
|
||||
Assert.NotNull(extensions);
|
||||
Assert.True(extensions.ContainsKey(McpExtensions.Tasks));
|
||||
Assert.True(extensions.ContainsKey(TasksProtocol.ExtensionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -299,7 +307,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -307,26 +315,13 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
Assert.Equal("task", augmented.TaskCreated!.ResultType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTaskResult_WithoutExplicitResultType_ServerFillsTask()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
new CallToolRequestParams { Name = "async-tool-default-resulttype" },
|
||||
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(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -356,7 +351,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" }, ct);
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
||||
|
|
@ -375,7 +370,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Create an input-required task
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "input-required-tool" }, ct);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
|
@ -401,15 +396,15 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta()
|
||||
public async Task CallToolAsTaskAsync_InjectsTaskCapabilityInMeta()
|
||||
{
|
||||
// Verify the server receives the task extension in _meta by intercepting
|
||||
// the handler. The CallToolWithTaskHandler already receives the request,
|
||||
// the handler. The CallToolWithAlternateHandler 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(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -418,14 +413,14 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope()
|
||||
public async Task CallToolAsTaskAsync_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(
|
||||
await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "immediate-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -441,7 +436,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings()
|
||||
public async Task CallToolAsTaskAsync_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.
|
||||
|
|
@ -459,7 +454,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
},
|
||||
};
|
||||
|
||||
await client.CallToolRawAsync(
|
||||
await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "immediate-tool",
|
||||
|
|
@ -480,13 +475,13 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_PreservesExistingUserMeta()
|
||||
public async Task CallToolAsTaskAsync_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(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "immediate-tool",
|
||||
|
|
@ -522,8 +517,8 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
});
|
||||
}, ct);
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct);
|
||||
|
||||
var elapsed = DateTime.UtcNow - startTime;
|
||||
|
||||
|
|
@ -534,25 +529,25 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult()
|
||||
public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallToolResult()
|
||||
{
|
||||
// Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works
|
||||
// Verify that the implicit conversion from CallToolResult to ResultOrAlternate 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(
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "immediate-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("immediate result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive()
|
||||
public async Task CallToolHandler_And_CallToolWithAlternateHandler_AreMutuallyExclusive()
|
||||
{
|
||||
var handlers = new McpServerHandlers();
|
||||
|
||||
handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult();
|
||||
handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult();
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
handlers.CallToolHandler = async (ctx, ct) => new CallToolResult());
|
||||
|
||||
|
|
@ -560,7 +555,7 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
|
||||
handlers.CallToolHandler = async (ctx, ct) => new CallToolResult();
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult());
|
||||
handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -572,8 +567,8 @@ public class McpServerTaskTests : ClientServerTestBase
|
|||
handlers.CallToolHandler = null;
|
||||
|
||||
// Now setting the other should work
|
||||
handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult();
|
||||
Assert.NotNull(handlers.CallToolWithTaskHandler);
|
||||
handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult();
|
||||
Assert.NotNull(handlers.CallToolWithAlternateHandler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
|
@ -10,7 +11,7 @@ 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"/>
|
||||
/// has neither <see cref="McpTasksBuilderExtensions.WithTasks(IMcpServerBuilder, IMcpTaskStore)"/> nor a <see cref="McpServerHandlers.CallToolWithAlternateHandler"/>
|
||||
/// 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>
|
||||
|
|
@ -25,7 +26,7 @@ public class McpServerTasksNoStoreTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
// Intentionally do NOT configure TaskStore or CallToolWithTaskHandler.
|
||||
// Intentionally do NOT configure TaskStore or CallToolWithAlternateHandler.
|
||||
mcpServerBuilder.WithTools<NoStoreTools>();
|
||||
}
|
||||
|
||||
|
|
@ -35,8 +36,8 @@ public class McpServerTasksNoStoreTests : ClientServerTestBase
|
|||
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(
|
||||
// CallToolAsTaskAsync always writes the SEP-2575 tasks opt-in into _meta.
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "sync-tool" }, ct);
|
||||
|
||||
// With no task store configured, the server must complete synchronously and return
|
||||
|
|
@ -54,8 +55,8 @@ public class McpServerTasksNoStoreTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "sync-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "sync-tool" }, cancellationToken: ct);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sync result", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
|
@ -13,7 +14,7 @@ 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
|
||||
/// Verifies that <see cref="McpTasksBuilderExtensions.WithTasks(IMcpServerBuilder, IMcpTaskStore)"/> enables task support
|
||||
/// for <see cref="McpServerTool"/>-based tools.
|
||||
/// </summary>
|
||||
public class McpTaskStoreTests : ClientServerTestBase
|
||||
|
|
@ -27,23 +28,20 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.WithTools<TaskStoreTestTools>();
|
||||
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = new InMemoryMcpTaskStore
|
||||
mcpServerBuilder
|
||||
.WithTools<TaskStoreTestTools>()
|
||||
.WithTasks(new InMemoryMcpTaskStore
|
||||
{
|
||||
DefaultPollIntervalMs = 50,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult()
|
||||
public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "slow-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -60,9 +58,9 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
|
||||
// CallToolAsync should poll until the background execution completes.
|
||||
var result = await client.CallToolAsync(
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "slow-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Content);
|
||||
|
|
@ -75,7 +73,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
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(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "fast-tool" },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -88,8 +86,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
new CallToolRequestParams { Name = "fast-tool" }, ct);
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "fast-tool" }, cancellationToken: ct);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
||||
|
|
@ -115,7 +113,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Create a slow task that won't complete on its own
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "slow-tool" }, ct);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
|
@ -145,7 +143,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "failing-tool" }, ct);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
|
@ -176,7 +174,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
|
@ -210,7 +208,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "mrtr-tool" }, ct);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
|
@ -233,7 +231,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
var message = failed.Error.GetProperty("message").GetString();
|
||||
Assert.NotNull(message);
|
||||
Assert.Contains("MRTR", message);
|
||||
Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message);
|
||||
Assert.Contains("tasks", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -253,8 +251,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// CallToolAsync will poll and resolve input requests automatically.
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "elicit-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("accepted", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
|
||||
|
|
@ -279,8 +277,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
});
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "sample-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "sample-tool" }, cancellationToken: ct);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sampled response", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
|
||||
|
|
@ -309,8 +307,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
});
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "roots-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "roots-tool" }, cancellationToken: ct);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("file:///workspace,file:///other", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
|
||||
|
|
@ -328,12 +326,10 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await using var registration = client.RegisterNotificationHandler(
|
||||
NotificationMethods.TaskStatusNotification,
|
||||
TasksProtocol.NotificationTaskStatus,
|
||||
(notification, _) =>
|
||||
{
|
||||
var typed = JsonSerializer.Deserialize<TaskStatusNotificationParams>(
|
||||
notification.Params,
|
||||
McpJsonUtilities.DefaultOptions);
|
||||
var typed = JsonSerializer.Deserialize<TaskStatusNotificationParams>(notification.Params, McpTasksJsonContext.Default.Options);
|
||||
if (typed is not null)
|
||||
{
|
||||
notifications.Writer.TryWrite(typed);
|
||||
|
|
@ -342,8 +338,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
return default;
|
||||
});
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "notifying-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "notifying-tool" }, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("notified", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
|
||||
|
||||
|
|
@ -377,12 +373,10 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await using var registration = client.RegisterNotificationHandler(
|
||||
NotificationMethods.TaskStatusNotification,
|
||||
TasksProtocol.NotificationTaskStatus,
|
||||
(notification, _) =>
|
||||
{
|
||||
var typed = JsonSerializer.Deserialize<TaskStatusNotificationParams>(
|
||||
notification.Params,
|
||||
McpJsonUtilities.DefaultOptions);
|
||||
var typed = JsonSerializer.Deserialize<TaskStatusNotificationParams>(notification.Params, McpTasksJsonContext.Default.Options);
|
||||
if (typed is FailedTaskNotificationParams)
|
||||
{
|
||||
notifications.Writer.TryWrite(typed);
|
||||
|
|
@ -393,8 +387,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
|
||||
// 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);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "failing-notify-tool" }, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("emitted-failed", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
|
||||
|
||||
|
|
@ -426,8 +420,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
});
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "elicit-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct);
|
||||
|
||||
// The handler should be called exactly once despite potential multiple polls
|
||||
Assert.Equal(1, elicitCallCount);
|
||||
|
|
@ -435,7 +429,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired()
|
||||
public async Task CallToolAsTaskAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
|
|
@ -447,7 +441,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
});
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "elicit-tool" }, ct);
|
||||
|
||||
Assert.True(augmented.IsTask);
|
||||
|
|
@ -476,7 +470,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Run a fast tool to completion via the task store.
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "fast-tool" }, ct);
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
||||
|
|
@ -516,7 +510,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct));
|
||||
await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct));
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal("handler-failed", ex.Message);
|
||||
|
|
@ -531,7 +525,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
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
|
||||
// extension capability." We bypass CallToolAsTaskAsync (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;
|
||||
|
|
@ -561,7 +555,7 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "iserror-tool" }, ct);
|
||||
|
||||
var taskId = augmented.TaskCreated!.TaskId;
|
||||
|
|
@ -602,8 +596,8 @@ public class McpTaskStoreTests : ClientServerTestBase
|
|||
});
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams { Name = "multi-elicit-tool" }, ct);
|
||||
var result = await client.CallToolWithPollingAsync(
|
||||
new CallToolRequestParams { Name = "multi-elicit-tool" }, cancellationToken: ct);
|
||||
|
||||
// Exactly two handler invocations — one per unique input request key.
|
||||
Assert.Equal(2, elicitCount);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
|
|
@ -59,3 +61,88 @@ public class MrtrInputRequiredExceptionTests : ClientServerTestBase
|
|||
Assert.Contains("more than", exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Companion to <see cref="MrtrInputRequiredExceptionTests"/> covering a native (MRTR-capable) round-trip where the
|
||||
/// server RETURNS an <see cref="InputRequiredResult"/> through the alternate result path
|
||||
/// (<see cref="ResultOrAlternate{TResult}"/>) rather than throwing <see cref="InputRequiredException"/>. The MRTR
|
||||
/// client drives the round-trip and receives the final result.
|
||||
/// </summary>
|
||||
public class MrtrReturnedInputRequiredResultNativeTests : ClientServerTestBase
|
||||
{
|
||||
private static readonly JsonTypeInfo<InputRequiredResult> s_inputRequiredResultTypeInfo =
|
||||
(JsonTypeInfo<InputRequiredResult>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult));
|
||||
|
||||
private int _attempt;
|
||||
|
||||
public MrtrReturnedInputRequiredResultNativeTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper, startServer: false)
|
||||
{
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.ProtocolVersion = "2026-07-28";
|
||||
|
||||
options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
|
||||
{
|
||||
Interlocked.Increment(ref _attempt);
|
||||
|
||||
// Retry round: the MRTR client re-sent the request with its responses and our requestState.
|
||||
if (context.Params?.RequestState is not null)
|
||||
{
|
||||
return new ValueTask<ResultOrAlternate<CallToolResult>>(new CallToolResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "resolved" }],
|
||||
});
|
||||
}
|
||||
|
||||
// First round: RETURN an InputRequiredResult through the alternate path. An MRTR client
|
||||
// understands it natively and drives the round-trip.
|
||||
var inputRequired = new InputRequiredResult
|
||||
{
|
||||
InputRequests = new Dictionary<string, InputRequest>
|
||||
{
|
||||
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
|
||||
{
|
||||
Message = "need-input",
|
||||
RequestedSchema = new(),
|
||||
}),
|
||||
},
|
||||
RequestState = "round1",
|
||||
};
|
||||
|
||||
return new ValueTask<ResultOrAlternate<CallToolResult>>(
|
||||
ResultOrAlternate<CallToolResult>.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo));
|
||||
};
|
||||
});
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnedInputRequiredResult_MrtrClient_RoundTripsToFinalResult()
|
||||
{
|
||||
StartServer();
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
Capabilities = new ClientCapabilities { Elicitation = new() },
|
||||
};
|
||||
clientOptions.Handlers.ElicitationHandler = (_, _) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
|
||||
|
||||
await using var client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
"return-form",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Two handler invocations: initial (returned InputRequiredResult) + client-driven retry (final result).
|
||||
Assert.Equal(2, _attempt);
|
||||
var content = Assert.Single(result.Content);
|
||||
Assert.Equal("resolved", Assert.IsType<TextContentBlock>(content).Text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
|
@ -111,3 +113,89 @@ public class MrtrServerBackcompatTests : ClientServerTestBase
|
|||
Assert.Equal("final-state:<null>", text);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Companion to <see cref="MrtrServerBackcompatTests"/> covering the other way a handler can surface an
|
||||
/// input-required result to a non-MRTR client: by RETURNING an <see cref="InputRequiredResult"/> through the
|
||||
/// alternate result path (<see cref="ResultOrAlternate{TResult}"/>) instead of throwing
|
||||
/// <see cref="InputRequiredException"/>. The legacy backcompat resolver must normalize both forms so a non-MRTR
|
||||
/// stateful client gets the same server-side resolution either way.
|
||||
/// </summary>
|
||||
public class MrtrReturnedInputRequiredResultBackcompatTests : ClientServerTestBase
|
||||
{
|
||||
private static readonly JsonTypeInfo<InputRequiredResult> s_inputRequiredResultTypeInfo =
|
||||
(JsonTypeInfo<InputRequiredResult>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult));
|
||||
|
||||
private int _attempt;
|
||||
|
||||
public MrtrReturnedInputRequiredResultBackcompatTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper, startServer: false)
|
||||
{
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
|
||||
{
|
||||
Interlocked.Increment(ref _attempt);
|
||||
|
||||
// Retry round: the backcompat resolver re-invoked us with the client's responses.
|
||||
if (context.Params?.RequestState is not null)
|
||||
{
|
||||
return new ValueTask<ResultOrAlternate<CallToolResult>>(new CallToolResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "resolved" }],
|
||||
});
|
||||
}
|
||||
|
||||
// First round: RETURN an InputRequiredResult through the alternate path rather than throwing.
|
||||
var inputRequired = new InputRequiredResult
|
||||
{
|
||||
InputRequests = new Dictionary<string, InputRequest>
|
||||
{
|
||||
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
|
||||
{
|
||||
Message = "need-input",
|
||||
RequestedSchema = new(),
|
||||
}),
|
||||
},
|
||||
RequestState = "round1",
|
||||
};
|
||||
|
||||
return new ValueTask<ResultOrAlternate<CallToolResult>>(
|
||||
ResultOrAlternate<CallToolResult>.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo));
|
||||
};
|
||||
});
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnedInputRequiredResult_NonMrtrStatefulClient_ResolvedServerSide()
|
||||
{
|
||||
StartServer();
|
||||
|
||||
// Non-MRTR client → server falls into the legacy backcompat resolver path, which must handle a
|
||||
// RETURNED InputRequiredResult exactly like a thrown InputRequiredException.
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = "2025-06-18",
|
||||
Capabilities = new ClientCapabilities { Elicitation = new() },
|
||||
};
|
||||
clientOptions.Handlers.ElicitationHandler = (_, _) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
|
||||
|
||||
await using var client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
"return-form",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Two handler invocations: initial (returned InputRequiredResult) + retry (final result).
|
||||
Assert.Equal(2, _attempt);
|
||||
var content = Assert.Single(result.Content);
|
||||
Assert.Equal("resolved", Assert.IsType<TextContentBlock>(content).Text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
|
@ -29,16 +30,13 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = new InMemoryMcpTaskStore
|
||||
mcpServerBuilder
|
||||
.WithTasks(new InMemoryMcpTaskStore
|
||||
{
|
||||
DefaultPollIntervalMs = 50,
|
||||
DefaultTimeToLive = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([McpServerTool.Create(
|
||||
})
|
||||
.WithTools([McpServerTool.Create(
|
||||
async (CancellationToken ct) =>
|
||||
{
|
||||
_toolStarted.TrySetResult(true);
|
||||
|
|
@ -66,7 +64,7 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "long-running-tool" }, ct);
|
||||
|
||||
Assert.True(augmented.IsTask);
|
||||
|
|
@ -93,7 +91,7 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "long-running-tool" }, ct);
|
||||
|
||||
Assert.True(augmented.IsTask);
|
||||
|
|
@ -130,15 +128,12 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = new InMemoryMcpTaskStore
|
||||
mcpServerBuilder
|
||||
.WithTasks(new InMemoryMcpTaskStore
|
||||
{
|
||||
DefaultPollIntervalMs = 50,
|
||||
};
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([McpServerTool.Create(
|
||||
})
|
||||
.WithTools([McpServerTool.Create(
|
||||
async (string marker, CancellationToken ct) =>
|
||||
{
|
||||
TaskCompletionSource<bool> startTcs;
|
||||
|
|
@ -219,14 +214,14 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
|
|||
RegisterMarker("task2");
|
||||
|
||||
// Start two tasks
|
||||
var result1 = await client.CallToolRawAsync(
|
||||
var result1 = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "trackable-tool",
|
||||
Arguments = CreateMarkerArgs("task1"),
|
||||
}, ct);
|
||||
|
||||
var result2 = await client.CallToolRawAsync(
|
||||
var result2 = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "trackable-tool",
|
||||
|
|
@ -273,15 +268,12 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = new InMemoryMcpTaskStore
|
||||
mcpServerBuilder
|
||||
.WithTasks(new InMemoryMcpTaskStore
|
||||
{
|
||||
DefaultPollIntervalMs = 50,
|
||||
};
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([
|
||||
})
|
||||
.WithTools([
|
||||
McpServerTool.Create(
|
||||
async (CancellationToken ct) =>
|
||||
{
|
||||
|
|
@ -316,7 +308,7 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "quick-tool" }, ct);
|
||||
|
||||
Assert.True(augmented.IsTask);
|
||||
|
|
@ -346,7 +338,7 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "failing-tool" }, ct);
|
||||
|
||||
Assert.True(augmented.IsTask);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
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.
|
||||
/// Verifies behavior when a handler returns a <see cref="CreateTaskResult"/> alternate without
|
||||
/// the server having any <c>tasks/get</c> handler registered. After the ResultOrAlternate
|
||||
/// generalization, the Core server no longer guards against this -- the extension is responsible
|
||||
/// for ensuring lifecycle handlers are registered.
|
||||
/// </summary>
|
||||
public class TaskHandlerConfigurationValidationTests : ClientServerTestBase
|
||||
{
|
||||
private static readonly JsonTypeInfo<CreateTaskResult> s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult;
|
||||
|
||||
public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
|
||||
{
|
||||
#if !NET
|
||||
|
|
@ -19,41 +25,40 @@ public class TaskHandlerConfigurationValidationTests : ClientServerTestBase
|
|||
#endif
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam
|
||||
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,
|
||||
});
|
||||
// Configure a task-augmented handler without TaskStore or any of the
|
||||
// task lifecycle request handlers.
|
||||
options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
|
||||
new ValueTask<ResultOrAlternate<CallToolResult>>(
|
||||
ResultOrAlternate<CallToolResult>.FromAlternate(
|
||||
new CreateTaskResult
|
||||
{
|
||||
TaskId = "orphan-task",
|
||||
Status = McpTaskStatus.Working,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
},
|
||||
s_createTaskResultTypeInfo));
|
||||
});
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
[Fact]
|
||||
public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime()
|
||||
public async Task ServerAcceptsAlternateHandler_WithoutTasksGetHandler_NoStartupError()
|
||||
{
|
||||
// The Core guard that previously threw InvalidOperationException at request time when
|
||||
// a CallToolWithAlternateHandler returned a CreateTaskResult without tasks/get being
|
||||
// registered has been removed. The extension is now responsible for that guarantee.
|
||||
// This test verifies the server starts and connects successfully with such configuration.
|
||||
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));
|
||||
// If we get here, the server accepted the handler config without error.
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
#pragma warning disable MCPEXP001
|
||||
#pragma warning disable MCPEXP001, MCPEXP002
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
|
|
@ -29,49 +32,66 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase
|
|||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.Capabilities ??= new ServerCapabilities();
|
||||
options.Capabilities.Extensions ??= new Dictionary<string, object>();
|
||||
options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject();
|
||||
options.RequestHandlers ??= new List<McpServerRequestHandler>();
|
||||
|
||||
// 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) =>
|
||||
options.Handlers.CallToolWithAlternateHandler = (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",
|
||||
});
|
||||
return new ValueTask<ResultOrAlternate<CallToolResult>>(
|
||||
ResultOrAlternate<CallToolResult>.FromAlternate(
|
||||
new CreateTaskResult
|
||||
{
|
||||
TaskId = taskId,
|
||||
Status = McpTaskStatus.InputRequired,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
PollIntervalMs = 5,
|
||||
ResultType = "task",
|
||||
},
|
||||
McpTasksJsonContext.Default.CreateTaskResult));
|
||||
};
|
||||
|
||||
// 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) =>
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
Interlocked.Increment(ref _pollCount);
|
||||
|
||||
return new ValueTask<GetTaskResult>(new InputRequiredTaskResult
|
||||
Method = TasksProtocol.MethodTasksGet,
|
||||
Handler = (request, cancellationToken) =>
|
||||
{
|
||||
TaskId = context.Params!.TaskId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
PollIntervalMs = 5,
|
||||
InputRequests = new Dictionary<string, InputRequest>(),
|
||||
ResultType = "complete",
|
||||
});
|
||||
};
|
||||
var requestParams = JsonSerializer.Deserialize<GetTaskRequestParams>(request.Params, McpTasksJsonContext.Default.Options)
|
||||
?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams);
|
||||
|
||||
// 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" });
|
||||
Interlocked.Increment(ref _pollCount);
|
||||
|
||||
// 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" });
|
||||
return new ValueTask<JsonNode?>(JsonSerializer.SerializeToNode(new InputRequiredTaskResult
|
||||
{
|
||||
TaskId = requestParams.TaskId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
PollIntervalMs = 5,
|
||||
InputRequests = new Dictionary<string, InputRequest>(),
|
||||
ResultType = "complete",
|
||||
}, McpTasksJsonContext.Default.Options));
|
||||
},
|
||||
});
|
||||
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksCancel,
|
||||
Handler = (request, cancellationToken) =>
|
||||
new ValueTask<JsonNode?>(JsonSerializer.SerializeToNode(new CancelTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)),
|
||||
});
|
||||
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksUpdate,
|
||||
Handler = (request, cancellationToken) =>
|
||||
new ValueTask<JsonNode?>(JsonSerializer.SerializeToNode(new UpdateTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +102,7 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(async () =>
|
||||
await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct));
|
||||
await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, cancellationToken: ct));
|
||||
|
||||
Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message);
|
||||
Assert.Contains("consecutive polls", ex.Message);
|
||||
|
|
@ -93,18 +113,15 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase
|
|||
[Fact]
|
||||
public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold()
|
||||
{
|
||||
// Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync:
|
||||
// Verifies CallToolWithPollingAsync plumbs the explicit threshold 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,
|
||||
});
|
||||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(async () =>
|
||||
await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct));
|
||||
await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, maxConsecutiveStuckPolls: CustomThreshold, cancellationToken: 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.
|
||||
|
|
@ -112,19 +129,4 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase
|
|||
Assert.Equal(CustomThreshold, _pollCount);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
|
@ -6,8 +7,6 @@ using System.Runtime.InteropServices;
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
#pragma warning disable MCPEXP001
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -32,15 +31,12 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.TaskStore = new InMemoryMcpTaskStore
|
||||
mcpServerBuilder
|
||||
.WithTasks(new InMemoryMcpTaskStore
|
||||
{
|
||||
DefaultPollIntervalMs = 50,
|
||||
};
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([McpServerTool.Create(
|
||||
})
|
||||
.WithTools([McpServerTool.Create(
|
||||
async (string input, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(50, ct);
|
||||
|
|
@ -68,7 +64,7 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
{
|
||||
[ExtensionsKey] = new JsonObject
|
||||
{
|
||||
[McpExtensions.Tasks] = new JsonObject(),
|
||||
[TasksProtocol.ExtensionId] = new JsonObject(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -115,7 +111,7 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolRawAsync(
|
||||
var result = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
@ -134,7 +130,7 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
|
||||
// Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy
|
||||
// request. The server rejects reserved per-request metadata before it can affect behavior.
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.CallToolRawAsync(
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
@ -156,10 +152,9 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
// gates tasks/* to the 2026-07-28 protocol and must reject this legacy request with MethodNotFound.
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Method = RequestMethods.TasksGet,
|
||||
Method = TasksProtocol.MethodTasksGet,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new GetTaskRequestParams { TaskId = "some-task-id" },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
new GetTaskRequestParams { TaskId = "some-task-id" }, McpTasksJsonContext.Default.Options),
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
|
||||
|
|
@ -175,7 +170,7 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
await using var client = await CreateMcpClientForServer();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var result = await client.CallToolRawAsync(
|
||||
var result = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
using ModelContextProtocol.Extensions.Tasks;
|
||||
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
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when both <see cref="McpServerOptions.TaskStore"/> and
|
||||
/// <see cref="McpServerHandlers.CallToolWithTaskHandler"/> are configured and the handler returns
|
||||
/// Verifies that when both <see cref="McpTasksBuilderExtensions.WithTasks(IMcpServerBuilder, IMcpTaskStore)"/> and
|
||||
/// <see cref="McpServerHandlers.CallToolWithAlternateHandler"/> 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
|
||||
{
|
||||
#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam
|
||||
private static readonly JsonTypeInfo<CreateTaskResult> s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult;
|
||||
|
||||
public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
|
||||
{
|
||||
#if !NET
|
||||
|
|
@ -26,21 +29,25 @@ public class TaskStoreOrphanedTaskTests : ClientServerTestBase
|
|||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
mcpServerBuilder.WithTasks(new InMemoryMcpTaskStore());
|
||||
|
||||
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.Capabilities ??= new ServerCapabilities();
|
||||
options.TaskStore = new InMemoryMcpTaskStore();
|
||||
|
||||
// Returning IsTask = true here while TaskStore is also configured is the
|
||||
// Returning IsTask = true here while the tasks extension 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,
|
||||
});
|
||||
options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
|
||||
new ValueTask<ResultOrAlternate<CallToolResult>>(
|
||||
ResultOrAlternate<CallToolResult>.FromAlternate(
|
||||
new CreateTaskResult
|
||||
{
|
||||
TaskId = "user-task",
|
||||
Status = McpTaskStatus.Working,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
},
|
||||
s_createTaskResultTypeInfo));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +58,7 @@ public class TaskStoreOrphanedTaskTests : ClientServerTestBase
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// The store's task is created synchronously and its taskId returned to the client.
|
||||
var augmented = await client.CallToolRawAsync(
|
||||
var augmented = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams { Name = "anything" }, ct);
|
||||
|
||||
Assert.True(augmented.IsTask);
|
||||
|
|
@ -76,7 +83,8 @@ public class TaskStoreOrphanedTaskTests : ClientServerTestBase
|
|||
|
||||
var message = failed.Error.GetProperty("message").GetString();
|
||||
Assert.NotNull(message);
|
||||
Assert.Contains(nameof(McpServerOptions.TaskStore), message);
|
||||
Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message);
|
||||
Assert.Contains(nameof(IMcpTaskStore), message);
|
||||
Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message);
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue