Enable complete Tasks extension conformance coverage (#1741)

Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b
This commit is contained in:
Pranav Senthilnathan 2026-07-27 12:45:25 -07:00 • committed by GitHub
commit 7838900e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 190 additions and 14 deletions

View file

@ -0,0 +1,16 @@
namespace ModelContextProtocol.Extensions.Tasks;
/// <summary>
/// Specifies how a tool call participates in the MCP Tasks extension.
/// </summary>
public enum McpTaskExecutionMode
{
/// <summary>The tool always executes synchronously.</summary>
Synchronous,
/// <summary>The tool executes as a task when the client declares the Tasks extension.</summary>
Optional,
/// <summary>The tool requires the client to declare the Tasks extension.</summary>
Required,
}

View file

@ -29,13 +29,36 @@ public static class McpTasksBuilderExtensions
/// <param name="store">The task store.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store)
=> WithTasks(builder, store, static _ => { });
/// <summary>
/// Enables MCP Tasks support backed by the specified task store.
/// </summary>
/// <param name="builder">The server builder.</param>
/// <param name="store">The task store.</param>
/// <param name="configure">A callback that configures per-call task execution behavior.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
public static IMcpServerBuilder WithTasks(
this IMcpServerBuilder builder,
IMcpTaskStore store,
Action<McpTasksOptions> configure)
{
#if NET
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(configure);
#else
if (builder is null) throw new ArgumentNullException(nameof(builder));
if (store is null) throw new ArgumentNullException(nameof(store));
if (configure is null) throw new ArgumentNullException(nameof(configure));
#endif
McpTasksOptions taskOptions = new();
configure(taskOptions);
#if NET
ArgumentNullException.ThrowIfNull(taskOptions.ExecutionModeSelector);
#else
if (taskOptions.ExecutionModeSelector is null) throw new ArgumentNullException(nameof(taskOptions.ExecutionModeSelector));
#endif
// Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the
@ -45,18 +68,21 @@ public static class McpTasksBuilderExtensions
sp => new McpTasksConfigureOptions(
store,
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetService<ILoggerFactory>()));
sp.GetService<ILoggerFactory>(),
taskOptions));
return builder;
}
private sealed class McpTasksConfigureOptions(
IMcpTaskStore store,
IServiceScopeFactory serviceScopeFactory,
ILoggerFactory? loggerFactory) : IConfigureOptions<McpServerOptions>
ILoggerFactory? loggerFactory,
McpTasksOptions taskOptions) : IConfigureOptions<McpServerOptions>
{
private readonly IMcpTaskStore _store = store;
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<McpTasksConfigureOptions>();
private readonly McpTasksOptions _taskOptions = taskOptions;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationSources = new(StringComparer.Ordinal);
public void Configure(McpServerOptions options)
@ -108,9 +134,28 @@ public static class McpTasksBuilderExtensions
options.Filters.Request.CallToolWithAlternateFilters.Count,
async (request, next, cancellationToken) =>
{
var executionMode = _taskOptions.ExecutionModeSelector(request);
if (executionMode is not McpTaskExecutionMode.Synchronous and
not McpTaskExecutionMode.Optional and
not McpTaskExecutionMode.Required)
{
throw new InvalidOperationException(
$"{nameof(McpTasksOptions.ExecutionModeSelector)} returned an invalid {nameof(McpTaskExecutionMode)} value.");
}
if (executionMode == McpTaskExecutionMode.Synchronous)
{
return await next(request, cancellationToken).ConfigureAwait(false);
}
if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) ||
!HasTaskExtensionOptIn(request.JsonRpcRequest))
{
if (executionMode == McpTaskExecutionMode.Required)
{
throw CreateMissingTasksCapabilityException();
}
return await next(request, cancellationToken).ConfigureAwait(false);
}

View file

@ -0,0 +1,22 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Extensions.Tasks;
/// <summary>
/// Configures server-side MCP Tasks behavior.
/// </summary>
public sealed class McpTasksOptions
{
/// <summary>
/// Gets or sets the callback that selects task execution behavior for each tool call.
/// </summary>
/// <remarks>
/// The default treats every tool as task-capable, preserving the behavior of
/// <c>WithTasks</c>.
/// The callback may inspect <see cref="RequestContext{TParams}.MatchedPrimitive"/> to select
/// behavior from tool metadata without requiring Core to reference the Tasks extension.
/// </remarks>
public Func<RequestContext<CallToolRequestParams>, McpTaskExecutionMode> ExecutionModeSelector { get; set; } =
static _ => McpTaskExecutionMode.Optional;
}

View file

@ -129,18 +129,15 @@ public class ServerConformanceTests(
[Theory]
[InlineData("tasks-wire-fields")]
[InlineData("tasks-lifecycle")]
[InlineData("tasks-capability-negotiation")]
[InlineData("tasks-request-headers")]
[InlineData("tasks-dispatch-and-envelope")]
[InlineData("tasks-status-notifications")]
[InlineData("tasks-required-task-error")]
[InlineData("tasks-request-state-removal")]
[InlineData("tasks-mrtr-input")]
// Most remaining scenarios require per-tool task execution configuration that the SDK
// does not currently expose; status notifications await an upstream harness rewrite.
// Keep them listed here for incremental enablement.
// [InlineData("tasks-lifecycle")]
// [InlineData("tasks-capability-negotiation")]
// [InlineData("tasks-request-headers")]
// [InlineData("tasks-dispatch-and-envelope")]
// [InlineData("tasks-status-notifications")]
// [InlineData("tasks-required-task-error")]
// [InlineData("tasks-mrtr-composition")]
[InlineData("tasks-mrtr-composition")]
public async Task RunTasksExtensionConformanceTest(string scenario)
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");

View file

@ -58,6 +58,17 @@ public class Program
{
DefaultPollIntervalMs = 50,
DefaultTimeToLive = TimeSpan.FromMinutes(5),
},
options => options.ExecutionModeSelector = request =>
request.Params?.Name switch
{
"slow_compute" or "protocol_error_job" or "confirm_delete" or "multi_input"
=> McpTaskExecutionMode.Optional,
"failing_job"
=> McpTaskExecutionMode.Required,
"test_tool_with_task" when request.Params.InputResponses is { Count: > 0 }
=> McpTaskExecutionMode.Required,
_ => McpTaskExecutionMode.Synchronous,
})
.WithTools<ConformanceTools>()
.WithTools<ConformanceTaskTools>()

View file

@ -33,6 +33,12 @@ public class McpTaskStoreTests : ClientServerTestBase
.WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
}, options => options.ExecutionModeSelector = request =>
request.Params?.Name switch
{
"sync-tool" => McpTaskExecutionMode.Synchronous,
"required-tool" => McpTaskExecutionMode.Required,
_ => McpTaskExecutionMode.Optional,
});
}
@ -52,6 +58,79 @@ public class McpTaskStoreTests : ClientServerTestBase
Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status);
}
[Fact]
public async Task CallToolAsync_SynchronousExecutionMode_ReturnsResultDirectly()
{
await using McpClient client = await CreateMcpClientForServer();
CallToolResult result = await client.CallToolAsync(
"sync-tool",
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("sync", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CallToolAsync_RequiredExecutionModeWithoutCapability_Throws()
{
await using McpClient client = await CreateMcpClientForServer();
MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync<MissingRequiredClientCapabilityException>(async () =>
await client.CallToolAsync(
"required-tool",
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode);
}
[Fact]
public async Task CallToolAsync_RequiredExecutionModeWithLegacyProtocol_Throws()
{
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
});
MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync<MissingRequiredClientCapabilityException>(async () =>
await client.CallToolAsync(
"required-tool",
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode);
}
[Fact]
public async Task CallToolAsTaskAsync_RequiredExecutionMode_ReturnsTask()
{
await using McpClient client = await CreateMcpClientForServer();
ResultOrCreatedTask<CallToolResult> result = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "required-tool" },
TestContext.Current.CancellationToken);
Assert.True(result.IsTask);
CallToolResult completedResult = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "required-tool" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("required", Assert.IsType<TextContentBlock>(completedResult.Content[0]).Text);
}
[Fact]
public void WithTasks_NullExecutionModeSelector_Throws()
{
ServiceCollection services = new();
Assert.Throws<ArgumentNullException>(() =>
services
.AddMcpServer()
.WithStreamServerTransport(Stream.Null, Stream.Null)
.WithTasks(
new InMemoryMcpTaskStore(),
options => options.ExecutionModeSelector = null!));
}
[Fact]
public async Task CallToolAsync_WithTaskStore_PollsToCompletion()
{
@ -609,6 +688,12 @@ public class McpTaskStoreTests : ClientServerTestBase
[McpServerToolType]
private sealed class TaskStoreTestTools
{
[McpServerTool(Name = "sync-tool")]
public static string SyncTool() => "sync";
[McpServerTool(Name = "required-tool")]
public static string RequiredTool() => "required";
[McpServerTool(Name = "slow-tool"), System.ComponentModel.Description("A tool that takes time")]
public static async Task<string> SlowTool(CancellationToken cancellationToken)
{