diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs
new file mode 100644
index 000000000..9045cea97
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs
@@ -0,0 +1,16 @@
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Specifies how a tool call participates in the MCP Tasks extension.
+///
+public enum McpTaskExecutionMode
+{
+ /// The tool always executes synchronously.
+ Synchronous,
+
+ /// The tool executes as a task when the client declares the Tasks extension.
+ Optional,
+
+ /// The tool requires the client to declare the Tasks extension.
+ Required,
+}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
index f9626c7d6..cbdf73c9a 100644
--- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
@@ -29,13 +29,36 @@ public static class McpTasksBuilderExtensions
/// The task store.
/// The builder provided in .
public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store)
+ => WithTasks(builder, store, static _ => { });
+
+ ///
+ /// Enables MCP Tasks support backed by the specified task store.
+ ///
+ /// The server builder.
+ /// The task store.
+ /// A callback that configures per-call task execution behavior.
+ /// The builder provided in .
+ public static IMcpServerBuilder WithTasks(
+ this IMcpServerBuilder builder,
+ IMcpTaskStore store,
+ Action 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 IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTa
sp => new McpTasksConfigureOptions(
store,
sp.GetRequiredService(),
- sp.GetService()));
+ sp.GetService(),
+ taskOptions));
return builder;
}
private sealed class McpTasksConfigureOptions(
IMcpTaskStore store,
IServiceScopeFactory serviceScopeFactory,
- ILoggerFactory? loggerFactory) : IConfigureOptions
+ ILoggerFactory? loggerFactory,
+ McpTasksOptions taskOptions) : IConfigureOptions
{
private readonly IMcpTaskStore _store = store;
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+ private readonly McpTasksOptions _taskOptions = taskOptions;
private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal);
public void Configure(McpServerOptions options)
@@ -108,9 +134,28 @@ public void Configure(McpServerOptions options)
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);
}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs
new file mode 100644
index 000000000..818a53fcf
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs
@@ -0,0 +1,22 @@
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Configures server-side MCP Tasks behavior.
+///
+public sealed class McpTasksOptions
+{
+ ///
+ /// Gets or sets the callback that selects task execution behavior for each tool call.
+ ///
+ ///
+ /// The default treats every tool as task-capable, preserving the behavior of
+ /// WithTasks.
+ /// The callback may inspect to select
+ /// behavior from tool metadata without requiring Core to reference the Tasks extension.
+ ///
+ public Func, McpTaskExecutionMode> ExecutionModeSelector { get; set; } =
+ static _ => McpTaskExecutionMode.Optional;
+}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index 059c5517d..2cb1d736e 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -129,18 +129,15 @@ public async Task RunMrtrConformanceTest(string scenario)
[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.");
diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs
index e195f7cf2..4c6c54f11 100644
--- a/tests/ModelContextProtocol.ConformanceServer/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs
@@ -58,7 +58,18 @@ private static void ConfigureConformanceMcpServer(
{
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()
.WithTools()
.WithTools()
diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
index a904c87ed..ebf7ca4ad 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
@@ -33,7 +33,13 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
.WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
- });
+ }, options => options.ExecutionModeSelector = request =>
+ request.Params?.Name switch
+ {
+ "sync-tool" => McpTaskExecutionMode.Synchronous,
+ "required-tool" => McpTaskExecutionMode.Required,
+ _ => McpTaskExecutionMode.Optional,
+ });
}
[Fact]
@@ -52,6 +58,79 @@ public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult
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(result.Content[0]).Text);
+ }
+
+ [Fact]
+ public async Task CallToolAsync_RequiredExecutionModeWithoutCapability_Throws()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync(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(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 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(completedResult.Content[0]).Text);
+ }
+
+ [Fact]
+ public void WithTasks_NullExecutionModeSelector_Throws()
+ {
+ ServiceCollection services = new();
+
+ Assert.Throws(() =>
+ 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 async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_Acros
[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 SlowTool(CancellationToken cancellationToken)
{