Skip to content

Commit 7838900

Browse files
Enable complete Tasks extension conformance coverage (#1741)
Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b
1 parent 9c635ff commit 7838900

6 files changed

Lines changed: 190 additions & 14 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace ModelContextProtocol.Extensions.Tasks;
2+
3+
/// <summary>
4+
/// Specifies how a tool call participates in the MCP Tasks extension.
5+
/// </summary>
6+
public enum McpTaskExecutionMode
7+
{
8+
/// <summary>The tool always executes synchronously.</summary>
9+
Synchronous,
10+
11+
/// <summary>The tool executes as a task when the client declares the Tasks extension.</summary>
12+
Optional,
13+
14+
/// <summary>The tool requires the client to declare the Tasks extension.</summary>
15+
Required,
16+
}

src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,36 @@ public static class McpTasksBuilderExtensions
2929
/// <param name="store">The task store.</param>
3030
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
3131
public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store)
32+
=> WithTasks(builder, store, static _ => { });
33+
34+
/// <summary>
35+
/// Enables MCP Tasks support backed by the specified task store.
36+
/// </summary>
37+
/// <param name="builder">The server builder.</param>
38+
/// <param name="store">The task store.</param>
39+
/// <param name="configure">A callback that configures per-call task execution behavior.</param>
40+
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
41+
public static IMcpServerBuilder WithTasks(
42+
this IMcpServerBuilder builder,
43+
IMcpTaskStore store,
44+
Action<McpTasksOptions> configure)
3245
{
3346
#if NET
3447
ArgumentNullException.ThrowIfNull(builder);
3548
ArgumentNullException.ThrowIfNull(store);
49+
ArgumentNullException.ThrowIfNull(configure);
3650
#else
3751
if (builder is null) throw new ArgumentNullException(nameof(builder));
3852
if (store is null) throw new ArgumentNullException(nameof(store));
53+
if (configure is null) throw new ArgumentNullException(nameof(configure));
54+
#endif
55+
56+
McpTasksOptions taskOptions = new();
57+
configure(taskOptions);
58+
#if NET
59+
ArgumentNullException.ThrowIfNull(taskOptions.ExecutionModeSelector);
60+
#else
61+
if (taskOptions.ExecutionModeSelector is null) throw new ArgumentNullException(nameof(taskOptions.ExecutionModeSelector));
3962
#endif
4063

4164
// 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
4568
sp => new McpTasksConfigureOptions(
4669
store,
4770
sp.GetRequiredService<IServiceScopeFactory>(),
48-
sp.GetService<ILoggerFactory>()));
71+
sp.GetService<ILoggerFactory>(),
72+
taskOptions));
4973
return builder;
5074
}
5175

5276
private sealed class McpTasksConfigureOptions(
5377
IMcpTaskStore store,
5478
IServiceScopeFactory serviceScopeFactory,
55-
ILoggerFactory? loggerFactory) : IConfigureOptions<McpServerOptions>
79+
ILoggerFactory? loggerFactory,
80+
McpTasksOptions taskOptions) : IConfigureOptions<McpServerOptions>
5681
{
5782
private readonly IMcpTaskStore _store = store;
5883
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
5984
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<McpTasksConfigureOptions>();
85+
private readonly McpTasksOptions _taskOptions = taskOptions;
6086
private readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationSources = new(StringComparer.Ordinal);
6187

6288
public void Configure(McpServerOptions options)
@@ -108,9 +134,28 @@ public void Configure(McpServerOptions options)
108134
options.Filters.Request.CallToolWithAlternateFilters.Count,
109135
async (request, next, cancellationToken) =>
110136
{
137+
var executionMode = _taskOptions.ExecutionModeSelector(request);
138+
if (executionMode is not McpTaskExecutionMode.Synchronous and
139+
not McpTaskExecutionMode.Optional and
140+
not McpTaskExecutionMode.Required)
141+
{
142+
throw new InvalidOperationException(
143+
$"{nameof(McpTasksOptions.ExecutionModeSelector)} returned an invalid {nameof(McpTaskExecutionMode)} value.");
144+
}
145+
146+
if (executionMode == McpTaskExecutionMode.Synchronous)
147+
{
148+
return await next(request, cancellationToken).ConfigureAwait(false);
149+
}
150+
111151
if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) ||
112152
!HasTaskExtensionOptIn(request.JsonRpcRequest))
113153
{
154+
if (executionMode == McpTaskExecutionMode.Required)
155+
{
156+
throw CreateMissingTasksCapabilityException();
157+
}
158+
114159
return await next(request, cancellationToken).ConfigureAwait(false);
115160
}
116161

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using ModelContextProtocol.Protocol;
2+
using ModelContextProtocol.Server;
3+
4+
namespace ModelContextProtocol.Extensions.Tasks;
5+
6+
/// <summary>
7+
/// Configures server-side MCP Tasks behavior.
8+
/// </summary>
9+
public sealed class McpTasksOptions
10+
{
11+
/// <summary>
12+
/// Gets or sets the callback that selects task execution behavior for each tool call.
13+
/// </summary>
14+
/// <remarks>
15+
/// The default treats every tool as task-capable, preserving the behavior of
16+
/// <c>WithTasks</c>.
17+
/// The callback may inspect <see cref="RequestContext{TParams}.MatchedPrimitive"/> to select
18+
/// behavior from tool metadata without requiring Core to reference the Tasks extension.
19+
/// </remarks>
20+
public Func<RequestContext<CallToolRequestParams>, McpTaskExecutionMode> ExecutionModeSelector { get; set; } =
21+
static _ => McpTaskExecutionMode.Optional;
22+
}

tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,15 @@ public async Task RunMrtrConformanceTest(string scenario)
129129

130130
[Theory]
131131
[InlineData("tasks-wire-fields")]
132+
[InlineData("tasks-lifecycle")]
133+
[InlineData("tasks-capability-negotiation")]
134+
[InlineData("tasks-request-headers")]
135+
[InlineData("tasks-dispatch-and-envelope")]
136+
[InlineData("tasks-status-notifications")]
137+
[InlineData("tasks-required-task-error")]
132138
[InlineData("tasks-request-state-removal")]
133139
[InlineData("tasks-mrtr-input")]
134-
// Most remaining scenarios require per-tool task execution configuration that the SDK
135-
// does not currently expose; status notifications await an upstream harness rewrite.
136-
// Keep them listed here for incremental enablement.
137-
// [InlineData("tasks-lifecycle")]
138-
// [InlineData("tasks-capability-negotiation")]
139-
// [InlineData("tasks-request-headers")]
140-
// [InlineData("tasks-dispatch-and-envelope")]
141-
// [InlineData("tasks-status-notifications")]
142-
// [InlineData("tasks-required-task-error")]
143-
// [InlineData("tasks-mrtr-composition")]
140+
[InlineData("tasks-mrtr-composition")]
144141
public async Task RunTasksExtensionConformanceTest(string scenario)
145142
{
146143
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");

tests/ModelContextProtocol.ConformanceServer/Program.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,18 @@ private static void ConfigureConformanceMcpServer(
5858
{
5959
DefaultPollIntervalMs = 50,
6060
DefaultTimeToLive = TimeSpan.FromMinutes(5),
61-
})
61+
},
62+
options => options.ExecutionModeSelector = request =>
63+
request.Params?.Name switch
64+
{
65+
"slow_compute" or "protocol_error_job" or "confirm_delete" or "multi_input"
66+
=> McpTaskExecutionMode.Optional,
67+
"failing_job"
68+
=> McpTaskExecutionMode.Required,
69+
"test_tool_with_task" when request.Params.InputResponses is { Count: > 0 }
70+
=> McpTaskExecutionMode.Required,
71+
_ => McpTaskExecutionMode.Synchronous,
72+
})
6273
.WithTools<ConformanceTools>()
6374
.WithTools<ConformanceTaskTools>()
6475
.WithTools<IncompleteResultTools>()

tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
3333
.WithTasks(new InMemoryMcpTaskStore
3434
{
3535
DefaultPollIntervalMs = 50,
36-
});
36+
}, options => options.ExecutionModeSelector = request =>
37+
request.Params?.Name switch
38+
{
39+
"sync-tool" => McpTaskExecutionMode.Synchronous,
40+
"required-tool" => McpTaskExecutionMode.Required,
41+
_ => McpTaskExecutionMode.Optional,
42+
});
3743
}
3844

3945
[Fact]
@@ -52,6 +58,79 @@ public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult
5258
Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status);
5359
}
5460

61+
[Fact]
62+
public async Task CallToolAsync_SynchronousExecutionMode_ReturnsResultDirectly()
63+
{
64+
await using McpClient client = await CreateMcpClientForServer();
65+
66+
CallToolResult result = await client.CallToolAsync(
67+
"sync-tool",
68+
cancellationToken: TestContext.Current.CancellationToken);
69+
70+
Assert.Equal("sync", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
71+
}
72+
73+
[Fact]
74+
public async Task CallToolAsync_RequiredExecutionModeWithoutCapability_Throws()
75+
{
76+
await using McpClient client = await CreateMcpClientForServer();
77+
78+
MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync<MissingRequiredClientCapabilityException>(async () =>
79+
await client.CallToolAsync(
80+
"required-tool",
81+
cancellationToken: TestContext.Current.CancellationToken));
82+
83+
Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode);
84+
}
85+
86+
[Fact]
87+
public async Task CallToolAsync_RequiredExecutionModeWithLegacyProtocol_Throws()
88+
{
89+
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
90+
{
91+
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
92+
});
93+
94+
MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync<MissingRequiredClientCapabilityException>(async () =>
95+
await client.CallToolAsync(
96+
"required-tool",
97+
cancellationToken: TestContext.Current.CancellationToken));
98+
99+
Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode);
100+
}
101+
102+
[Fact]
103+
public async Task CallToolAsTaskAsync_RequiredExecutionMode_ReturnsTask()
104+
{
105+
await using McpClient client = await CreateMcpClientForServer();
106+
107+
ResultOrCreatedTask<CallToolResult> result = await client.CallToolAsTaskAsync(
108+
new CallToolRequestParams { Name = "required-tool" },
109+
TestContext.Current.CancellationToken);
110+
111+
Assert.True(result.IsTask);
112+
113+
CallToolResult completedResult = await client.CallToolWithPollingAsync(
114+
new CallToolRequestParams { Name = "required-tool" },
115+
cancellationToken: TestContext.Current.CancellationToken);
116+
117+
Assert.Equal("required", Assert.IsType<TextContentBlock>(completedResult.Content[0]).Text);
118+
}
119+
120+
[Fact]
121+
public void WithTasks_NullExecutionModeSelector_Throws()
122+
{
123+
ServiceCollection services = new();
124+
125+
Assert.Throws<ArgumentNullException>(() =>
126+
services
127+
.AddMcpServer()
128+
.WithStreamServerTransport(Stream.Null, Stream.Null)
129+
.WithTasks(
130+
new InMemoryMcpTaskStore(),
131+
options => options.ExecutionModeSelector = null!));
132+
}
133+
55134
[Fact]
56135
public async Task CallToolAsync_WithTaskStore_PollsToCompletion()
57136
{
@@ -609,6 +688,12 @@ public async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_Acros
609688
[McpServerToolType]
610689
private sealed class TaskStoreTestTools
611690
{
691+
[McpServerTool(Name = "sync-tool")]
692+
public static string SyncTool() => "sync";
693+
694+
[McpServerTool(Name = "required-tool")]
695+
public static string RequiredTool() => "required";
696+
612697
[McpServerTool(Name = "slow-tool"), System.ComponentModel.Description("A tool that takes time")]
613698
public static async Task<string> SlowTool(CancellationToken cancellationToken)
614699
{

0 commit comments

Comments
 (0)