From 2b4a7d276175b10eaa06d0b4ae7064b65ed4cac5 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Fri, 24 Jul 2026 11:47:23 -0700 Subject: [PATCH 1/5] Add task extension conformance coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b --- .../StreamableHttpHandler.cs | 11 +- .../StreamableHttpClientSessionTransport.cs | 2 + .../Client/McpTasksClientExtensions.cs | 11 ++ .../Protocol/CancelTaskResult.cs | 5 + .../Protocol/UpdateTaskResult.cs | 5 + .../Server/McpTaskExecutionMode.cs | 16 +++ .../Server/McpTasksBuilderExtensions.cs | 80 ++++++++++++-- .../Server/McpTasksOptions.cs | 20 ++++ tests/Common/Utils/NodeHelpers.cs | 17 +++ .../ServerConformanceTests.cs | 25 +++++ ...elContextProtocol.ConformanceServer.csproj | 1 + .../Program.cs | 17 +++ .../Tools/ConformanceTaskTools.cs | 102 ++++++++++++++++++ .../Client/McpClientTaskMethodsTests.cs | 5 +- .../Protocol/TaskSerializationTests.cs | 12 +++ .../Server/McpTaskStoreTests.cs | 21 +++- 16 files changed, 337 insertions(+), 13 deletions(-) create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs create mode 100644 tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 0d3cf3240..9fc7d27e6 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -871,8 +871,14 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess return false; } - // From here on, only validate resources/read, tools/call, and prompts/get requests - if (mcpMethodInBody is not (RequestMethods.ToolsCall or RequestMethods.ResourcesRead or RequestMethods.PromptsGet)) + // From here on, only validate requests whose params carry a name-shaped routing identifier. + if (mcpMethodInBody is not ( + RequestMethods.ToolsCall or + RequestMethods.ResourcesRead or + RequestMethods.PromptsGet or + "tasks/get" or + "tasks/update" or + "tasks/cancel")) { errorMessage = null; return true; @@ -916,6 +922,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess RequestMethods.ToolsCall => GetJsonNodeStringProperty(bodyParams, "name"), RequestMethods.ResourcesRead => GetJsonNodeStringProperty(bodyParams, "uri"), RequestMethods.PromptsGet => GetJsonNodeStringProperty(bodyParams, "name"), + "tasks/get" or "tasks/update" or "tasks/cancel" => GetJsonNodeStringProperty(bodyParams, "taskId"), _ => null, }; diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index a6334aa31..22b54e187 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -598,6 +598,8 @@ internal static void AddMcpRequestHeaders(HttpRequestHeaders headers, JsonRpcMes => GetParamsStringProperty(request.Params, "name"), JsonRpcRequest { Method: RequestMethods.ResourcesRead } request => GetParamsStringProperty(request.Params, "uri"), + JsonRpcRequest { Method: "tasks/get" or "tasks/update" or "tasks/cancel" } request + => GetParamsStringProperty(request.Params, "taskId"), _ => null, }; diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs index 88055b2ad..7602ecf11 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -118,6 +118,11 @@ public static ValueTask GetTaskAsync( #endif ThrowIfTasksNotSupported(client, nameof(GetTaskAsync)); + requestParams = new GetTaskRequestParams + { + TaskId = requestParams.TaskId, + Meta = GetMetaWithTaskCapability(requestParams.Meta), + }; return client.SendRequestAsync( TasksProtocol.MethodTasksGet, requestParams, @@ -148,6 +153,7 @@ public static async ValueTask UpdateTaskAsync( JsonObject paramsObj = new() { ["taskId"] = requestParams.TaskId, + ["_meta"] = GetMetaWithTaskCapability(requestParams.Meta), }; if (requestParams.InputResponses is { Count: > 0 } inputResponses) @@ -204,6 +210,11 @@ public static ValueTask CancelTaskAsync( #endif ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync)); + requestParams = new CancelTaskRequestParams + { + TaskId = requestParams.TaskId, + Meta = GetMetaWithTaskCapability(requestParams.Meta), + }; return client.SendRequestAsync( TasksProtocol.MethodTasksCancel, requestParams, diff --git a/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs index c9d92a06e..5176cfa56 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs @@ -20,4 +20,9 @@ namespace ModelContextProtocol.Extensions.Tasks; /// public sealed class CancelTaskResult : Result { + /// Initializes a new task cancellation acknowledgement. + public CancelTaskResult() + { + ResultType = "complete"; + } } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs index b9f59f395..0a7488688 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs @@ -19,4 +19,9 @@ namespace ModelContextProtocol.Extensions.Tasks; /// public sealed class UpdateTaskResult : Result { + /// Initializes a new task update acknowledgement. + public UpdateTaskResult() + { + ResultType = "complete"; + } } 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 9c46b0a2c..e717a5001 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -29,13 +29,37 @@ 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 ArgumentException( + $"{nameof(McpTasksOptions.ExecutionModeSelector)} must not be null.", nameof(configure)); #endif // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the @@ -45,18 +69,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) @@ -93,11 +120,27 @@ public void Configure(McpServerOptions options) options.Filters.Request.CallToolWithAlternateFilters.Count, async (request, next, cancellationToken) => { - if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || !HasTaskExtensionOptIn(request.Params?.Meta)) + if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) + { + return await next(request, cancellationToken).ConfigureAwait(false); + } + + var executionMode = _taskOptions.ExecutionModeSelector(request); + if (executionMode == McpTaskExecutionMode.Synchronous) { return await next(request, cancellationToken).ConfigureAwait(false); } + if (!HasTaskExtensionOptIn(request.JsonRpcRequest)) + { + if (executionMode == McpTaskExecutionMode.Required) + { + throw CreateMissingTasksCapabilityException(); + } + + return await next(request, cancellationToken).ConfigureAwait(false); + } + return await RunAsTaskAsync(next, request, cancellationToken).ConfigureAwait(false); }); } @@ -257,6 +300,7 @@ private async Task ExecuteToolPipelineAsync( private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) { GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet); + GateToTasksCapability(request); var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams) ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); @@ -273,6 +317,7 @@ private async Task ExecuteToolPipelineAsync( private async ValueTask HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken) { GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate); + GateToTasksCapability(request); var taskId = request.Params?["taskId"]?.GetValue() ?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams); @@ -292,6 +337,7 @@ private async Task ExecuteToolPipelineAsync( private async ValueTask HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken) { GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel); + GateToTasksCapability(request); var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams) ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); @@ -319,11 +365,29 @@ private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string } } - private static bool HasTaskExtensionOptIn(JsonObject? meta) => - meta is not null && - meta[MetaKeys.ClientCapabilities] is JsonObject caps && - caps["extensions"] is JsonObject exts && - exts.ContainsKey(TasksProtocol.ExtensionId); + private static void GateToTasksCapability(JsonRpcRequest request) + { + if (!HasTaskExtensionOptIn(request)) + { + throw CreateMissingTasksCapabilityException(); + } + } + + private static MissingRequiredClientCapabilityException CreateMissingTasksCapabilityException() => + new( + new ClientCapabilities + { + Extensions = new Dictionary + { + [TasksProtocol.ExtensionId] = new JsonObject(), + }, + }, + $"The request requires the '{TasksProtocol.ExtensionId}' client extension capability."); + + private static bool HasTaskExtensionOptIn(JsonRpcRequest request) => + request.Context?.ClientCapabilities?.Extensions?.ContainsKey(TasksProtocol.ExtensionId) is true || + request.Params?["_meta"]?[MetaKeys.ClientCapabilities]?["extensions"] is JsonObject extensions && + extensions.ContainsKey(TasksProtocol.ExtensionId); private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion); diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs new file mode 100644 index 000000000..ecf8757cb --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs @@ -0,0 +1,20 @@ +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 the + /// overload that only accepts a task store. + /// + public Func, McpTaskExecutionMode> ExecutionModeSelector { get; set; } = + static _ => McpTaskExecutionMode.Optional; +} diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index abd593ffc..d465bbd7c 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -535,6 +535,23 @@ public static bool HasMrtrScenarios() "input-required-result-ignore-extra-params", "input-required-result-validate-input"); + /// + /// Checks whether the SEP-2663 Tasks extension server scenarios are available in the + /// installed conformance package. + /// + public static bool HasTasksExtensionScenarios() + => HasInstalledConformanceScenarios( + "tasks-lifecycle", + "tasks-capability-negotiation", + "tasks-wire-fields", + "tasks-request-state-removal", + "tasks-mrtr-input", + "tasks-request-headers", + "tasks-dispatch-and-envelope", + "tasks-status-notifications", + "tasks-required-task-error", + "tasks-mrtr-composition"); + private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index cd538d819..502edf0ec 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -127,6 +127,31 @@ public async Task RunMrtrConformanceTest(string scenario) $"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + [Theory] + [InlineData("tasks-lifecycle")] + [InlineData("tasks-capability-negotiation")] + [InlineData("tasks-wire-fields")] + [InlineData("tasks-request-state-removal")] + [InlineData("tasks-mrtr-input")] + [InlineData("tasks-request-headers")] + [InlineData("tasks-dispatch-and-envelope")] + [InlineData("tasks-status-notifications")] + [InlineData("tasks-required-task-error")] + [InlineData("tasks-mrtr-composition")] + public async Task RunTasksExtensionConformanceTest(string scenario) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen( + !NodeHelpers.HasTasksExtensionScenarios(), + "SEP-2663 Tasks extension scenarios are not available in the installed conformance package."); + + var result = await RunStatelessConformanceTestAsync( + $"server --url {fixture.StatelessServerUrl} --scenario {scenario}"); + + Assert.True(result.Success, + $"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { return await NodeHelpers.RunServerConformanceAsync( diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index c81d8d262..dffffa9d3 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 22e22275d..95e720d90 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -3,6 +3,7 @@ using ConformanceServer.Tools; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Extensions.Tasks; using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; @@ -52,7 +53,23 @@ private static void ConfigureConformanceMcpServer( .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() + .WithTasks( + new InMemoryMcpTaskStore + { + 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() .WithTools([ConformanceTools.CreateJsonSchema202012Tool()]) .WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) => diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs new file mode 100644 index 000000000..4441c1d39 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs @@ -0,0 +1,102 @@ +#pragma warning disable MCPEXP001 // MRTR (SEP-2322) is experimental. + +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ConformanceServer.Tools; + +[McpServerToolType] +public sealed class ConformanceTaskTools +{ + [McpServerTool(Name = "greet")] + [Description("Returns a synchronous greeting.")] + public static string Greet(string name) => $"Hello, {name}!"; + + [McpServerTool(Name = "slow_compute")] + [Description("Completes after the requested number of seconds.")] + public static async Task SlowCompute(int seconds, string? label, CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken); + return $"Computed {label ?? "result"}"; + } + + [McpServerTool(Name = "failing_job")] + [Description("Produces a tool execution error.")] + public static async Task FailingJob(CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + throw new Exception("The conformance task failed."); + } + + [McpServerTool(Name = "protocol_error_job")] + [Description("Produces a protocol-level error.")] + public static string ProtocolErrorJob() => + throw new McpProtocolException("The conformance task encountered a protocol error.", McpErrorCode.InternalError); + + [McpServerTool(Name = "confirm_delete")] + [Description("Waits for elicitation before confirming deletion.")] + public static async Task ConfirmDelete( + McpServer server, + string filename, + CancellationToken cancellationToken) + { + var result = await server.ElicitAsync(CreateConfirmationRequest($"Delete {filename}?"), cancellationToken); + return result.Action == "accept" ? $"Deleted {filename}" : $"Did not delete {filename}"; + } + + [McpServerTool(Name = "multi_input")] + [Description("Waits for two independent elicitation responses.")] + public static async Task MultiInput(McpServer server, CancellationToken cancellationToken) + { + await Task.WhenAll( + server.ElicitAsync(CreateConfirmationRequest("Confirm the first operation."), cancellationToken).AsTask(), + server.ElicitAsync(CreateConfirmationRequest("Confirm the second operation."), cancellationToken).AsTask()); + return "Both inputs received."; + } + + [McpServerTool(Name = "test_tool_with_task")] + [Description("Collects input synchronously, then completes through a task.")] + public static string ToolWithTask(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_name", out var response)) + { + var elicitation = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + var name = elicitation?.Content?["name"].GetString() ?? "world"; + return $"Hello, {name}!"; + } + + throw new InputRequiredException( + new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }); + } + + private static ElicitRequestParams CreateConfirmationRequest(string message) => + new() + { + Message = message, + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = + { + ["confirm"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["confirm"], + }, + }; +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index af1334fab..e3da699c4 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -154,9 +154,10 @@ public async Task CancelTaskAsync_ForWorkingTask_Succeeds() { await client.CancelTaskAsync(taskId, ct); - // If cancel succeeded, verify the task is cancelled + // Cancellation is eventually consistent. The task may complete before the cancellation + // request wins the race, but the terminal-task cancellation acknowledgement is idempotent. var taskResult = await client.GetTaskAsync(taskId, ct); - Assert.IsType(taskResult); + Assert.True(taskResult is CancelledTaskResult or CompletedTaskResult); } catch (McpProtocolException) { diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index da4afb62f..f2e4472ce 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -91,6 +91,18 @@ public static void CreateTaskResult_ResultType_SerializesAsTask() #endregion + [Theory] + [InlineData(typeof(UpdateTaskResult))] + [InlineData(typeof(CancelTaskResult))] + public static void TaskAcknowledgement_SerializesExplicitCompleteResultType(Type resultType) + { + var result = (Result)Activator.CreateInstance(resultType)!; + + var json = JsonSerializer.SerializeToNode(result, resultType, McpTasksJsonContext.Default.Options)!; + + Assert.Equal("complete", (string)json["resultType"]!); + } + #region GetTaskResult Subtypes [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index a904c87ed..810d0f13b 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -33,7 +33,10 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }); + }, options => options.ExecutionModeSelector = request => + request.Params?.Name == "sync-tool" + ? McpTaskExecutionMode.Synchronous + : McpTaskExecutionMode.Optional); } [Fact] @@ -80,6 +83,19 @@ public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask() Assert.True(augmented.IsTask); } + [Fact] + public async Task CallToolAsTaskAsync_WithSynchronousExecutionMode_ReturnsDirectResult() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "sync-tool" }, + TestContext.Current.CancellationToken); + + Assert.False(result.IsTask); + Assert.Equal("sync result", Assert.IsType(Assert.Single(result.Result!.Content)).Text); + } + [Fact] public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult() { @@ -619,6 +635,9 @@ public static async Task SlowTool(CancellationToken cancellationToken) [McpServerTool(Name = "fast-tool"), System.ComponentModel.Description("A fast tool")] public static string FastTool() => "fast result"; + [McpServerTool(Name = "sync-tool"), System.ComponentModel.Description("A synchronous-only tool")] + public static string SyncTool() => "sync result"; + [McpServerTool(Name = "failing-tool"), System.ComponentModel.Description("A tool that fails")] public static string FailingTool() => throw new InvalidOperationException("intentional failure"); From 2f19b70fdf4c1f05c3e2156229725bfd1f599439 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Fri, 24 Jul 2026 14:46:56 -0700 Subject: [PATCH 2/5] Limit task conformance to existing API Remove the proposed per-tool execution mode API and enable only the task scenarios supported by the existing global task-store behavior. Keep deferred scenarios listed for future API work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b --- .../Server/McpTaskExecutionMode.cs | 16 ------ .../Server/McpTasksBuilderExtensions.cs | 50 ++----------------- .../Server/McpTasksOptions.cs | 20 -------- tests/Common/Utils/NodeHelpers.cs | 9 +--- .../ServerConformanceTests.cs | 17 ++++--- .../Program.cs | 9 ---- .../Server/McpTaskStoreTests.cs | 21 +------- 7 files changed, 16 insertions(+), 126 deletions(-) delete mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs delete mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs deleted file mode 100644 index 9045cea97..000000000 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs +++ /dev/null @@ -1,16 +0,0 @@ -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 e717a5001..a4622b14c 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -29,37 +29,13 @@ 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 ArgumentException( - $"{nameof(McpTasksOptions.ExecutionModeSelector)} must not be null.", nameof(configure)); #endif // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the @@ -69,21 +45,18 @@ public static IMcpServerBuilder WithTasks( sp => new McpTasksConfigureOptions( store, sp.GetRequiredService(), - sp.GetService(), - taskOptions)); + sp.GetService())); return builder; } private sealed class McpTasksConfigureOptions( IMcpTaskStore store, IServiceScopeFactory serviceScopeFactory, - ILoggerFactory? loggerFactory, - McpTasksOptions taskOptions) : IConfigureOptions + ILoggerFactory? loggerFactory) : 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) @@ -120,27 +93,12 @@ public void Configure(McpServerOptions options) options.Filters.Request.CallToolWithAlternateFilters.Count, async (request, next, cancellationToken) => { - if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) - { - return await next(request, cancellationToken).ConfigureAwait(false); - } - - var executionMode = _taskOptions.ExecutionModeSelector(request); - if (executionMode == McpTaskExecutionMode.Synchronous) + if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || + !HasTaskExtensionOptIn(request.JsonRpcRequest)) { return await next(request, cancellationToken).ConfigureAwait(false); } - if (!HasTaskExtensionOptIn(request.JsonRpcRequest)) - { - if (executionMode == McpTaskExecutionMode.Required) - { - throw CreateMissingTasksCapabilityException(); - } - - return await next(request, cancellationToken).ConfigureAwait(false); - } - return await RunAsTaskAsync(next, request, cancellationToken).ConfigureAwait(false); }); } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs deleted file mode 100644 index ecf8757cb..000000000 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 the - /// overload that only accepts a task store. - /// - public Func, McpTaskExecutionMode> ExecutionModeSelector { get; set; } = - static _ => McpTaskExecutionMode.Optional; -} diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index d465bbd7c..98c225dd9 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -541,16 +541,9 @@ public static bool HasMrtrScenarios() /// public static bool HasTasksExtensionScenarios() => HasInstalledConformanceScenarios( - "tasks-lifecycle", - "tasks-capability-negotiation", "tasks-wire-fields", "tasks-request-state-removal", - "tasks-mrtr-input", - "tasks-request-headers", - "tasks-dispatch-and-envelope", - "tasks-status-notifications", - "tasks-required-task-error", - "tasks-mrtr-composition"); + "tasks-mrtr-input"); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 502edf0ec..059c5517d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -128,16 +128,19 @@ public async Task RunMrtrConformanceTest(string scenario) } [Theory] - [InlineData("tasks-lifecycle")] - [InlineData("tasks-capability-negotiation")] [InlineData("tasks-wire-fields")] [InlineData("tasks-request-state-removal")] [InlineData("tasks-mrtr-input")] - [InlineData("tasks-request-headers")] - [InlineData("tasks-dispatch-and-envelope")] - [InlineData("tasks-status-notifications")] - [InlineData("tasks-required-task-error")] - [InlineData("tasks-mrtr-composition")] + // 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")] 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 95e720d90..e195f7cf2 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -58,15 +58,6 @@ 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() diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index 810d0f13b..a904c87ed 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -33,10 +33,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }, options => options.ExecutionModeSelector = request => - request.Params?.Name == "sync-tool" - ? McpTaskExecutionMode.Synchronous - : McpTaskExecutionMode.Optional); + }); } [Fact] @@ -83,19 +80,6 @@ public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask() Assert.True(augmented.IsTask); } - [Fact] - public async Task CallToolAsTaskAsync_WithSynchronousExecutionMode_ReturnsDirectResult() - { - await using var client = await CreateMcpClientForServer(); - - var result = await client.CallToolAsTaskAsync( - new CallToolRequestParams { Name = "sync-tool" }, - TestContext.Current.CancellationToken); - - Assert.False(result.IsTask); - Assert.Equal("sync result", Assert.IsType(Assert.Single(result.Result!.Content)).Text); - } - [Fact] public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult() { @@ -635,9 +619,6 @@ public static async Task SlowTool(CancellationToken cancellationToken) [McpServerTool(Name = "fast-tool"), System.ComponentModel.Description("A fast tool")] public static string FastTool() => "fast result"; - [McpServerTool(Name = "sync-tool"), System.ComponentModel.Description("A synchronous-only tool")] - public static string SyncTool() => "sync result"; - [McpServerTool(Name = "failing-tool"), System.ComponentModel.Description("A tool that fails")] public static string FailingTool() => throw new InvalidOperationException("intentional failure"); From 0a6ce37602f710a3940349bb835fd9d1d2d18cea Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Fri, 24 Jul 2026 16:22:38 -0700 Subject: [PATCH 3/5] Generalize extension routing metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b --- .../StreamableHttpHandler.cs | 61 ++++++++++----- .../StreamableHttpClientSessionTransport.cs | 4 +- .../Protocol/JsonRpcMessageContext.cs | 9 +++ .../Server/McpServerImpl.cs | 6 ++ .../Server/McpServerRequestHandler.cs | 10 +++ .../Client/McpTasksClientExtensions.cs | 46 +++++++---- .../Server/McpTasksBuilderExtensions.cs | 21 ++++- .../HttpHeaderConformanceTests.cs | 30 +++++++ .../StreamableHttpClientConformanceTests.cs | 78 ++++++++++++++++++- .../CustomRequestHandlerCollisionTests.cs | 23 ++++++ 10 files changed, 244 insertions(+), 44 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 9fc7d27e6..75281aa96 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -110,7 +110,7 @@ await WriteJsonRpcErrorAsync(context, return; } - if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId); return; @@ -829,10 +829,10 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex /// /// The HTTP context containing the request headers. /// The JSON-RPC message to validate against. - /// The tool collection to look up tool schemas for parameter header validation. + /// The server options containing tools and custom request routing metadata. /// Set to the error message if validation fails; null otherwise. /// True if validation passes; false otherwise. - internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerPrimitiveCollection? toolCollection, [NotNullWhen(false)] out string? errorMessage) + internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerOptions serverOptions, [NotNullWhen(false)] out string? errorMessage) { // Only validate for protocol versions that support standard headers. var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); @@ -871,14 +871,10 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess return false; } - // From here on, only validate requests whose params carry a name-shaped routing identifier. - if (mcpMethodInBody is not ( - RequestMethods.ToolsCall or - RequestMethods.ResourcesRead or - RequestMethods.PromptsGet or - "tasks/get" or - "tasks/update" or - "tasks/cancel")) +#pragma warning disable MCPEXP002 + var routingNameParameter = GetRoutingNameParameter(mcpMethodInBody, serverOptions.RequestHandlers); +#pragma warning restore MCPEXP002 + if (routingNameParameter is null) { errorMessage = null; return true; @@ -917,14 +913,7 @@ RequestMethods.PromptsGet or JsonRpcNotification notification => notification.Params, _ => null, }; - var mcpNameInBody = mcpMethodInBody switch - { - RequestMethods.ToolsCall => GetJsonNodeStringProperty(bodyParams, "name"), - RequestMethods.ResourcesRead => GetJsonNodeStringProperty(bodyParams, "uri"), - RequestMethods.PromptsGet => GetJsonNodeStringProperty(bodyParams, "name"), - "tasks/get" or "tasks/update" or "tasks/cancel" => GetJsonNodeStringProperty(bodyParams, "taskId"), - _ => null, - }; + var mcpNameInBody = GetJsonNodeStringProperty(bodyParams, routingNameParameter); // Check that the header value matches the body value if the body value is present. if (!string.Equals(decodedMcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) @@ -934,7 +923,7 @@ RequestMethods.PromptsGet or } // Validate Mcp-Param-* custom headers against tool schema - if (!ValidateCustomParamHeaders(context, message, toolCollection, out errorMessage)) + if (!ValidateCustomParamHeaders(context, message, serverOptions.ToolCollection, out errorMessage)) { return false; } @@ -943,6 +932,38 @@ RequestMethods.PromptsGet or return true; } +#pragma warning disable MCPEXP002 + private static string? GetRoutingNameParameter( + string? method, + IList? requestHandlers) + { + var builtInParameter = method switch + { + RequestMethods.ToolsCall or RequestMethods.PromptsGet => "name", + RequestMethods.ResourcesRead => "uri", + _ => null, + }; + + if (builtInParameter is not null) + { + return builtInParameter; + } + + if (requestHandlers is not null) + { + foreach (var requestHandler in requestHandlers) + { + if (string.Equals(requestHandler.Method, method, StringComparison.Ordinal)) + { + return requestHandler.RoutingNameParameter; + } + } + } + + return null; + } +#pragma warning restore MCPEXP002 + /// /// Validates that all parameters annotated with x-mcp-header in the tool's input schema /// have corresponding Mcp-Param-* headers present in the request, and that any present diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 22b54e187..f70a6ed85 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -592,14 +592,12 @@ internal static void AddMcpRequestHeaders(HttpRequestHeaders headers, JsonRpcMes headers.Add(McpHttpHeaders.Method, method); // Add Mcp-Name header for methods that target a specific named resource - string? name = message switch + string? name = message.Context?.RoutingName ?? message switch { JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request => GetParamsStringProperty(request.Params, "name"), JsonRpcRequest { Method: RequestMethods.ResourcesRead } request => GetParamsStringProperty(request.Params, "uri"), - JsonRpcRequest { Method: "tasks/get" or "tasks/update" or "tasks/cancel" } request - => GetParamsStringProperty(request.Params, "taskId"), _ => null, }; diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 28d7774cb..1f04aa039 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -75,6 +75,15 @@ public sealed class JsonRpcMessageContext /// public IDictionary? Items { get; set; } + /// + /// Gets or sets the routing name for this message. + /// + /// + /// Streamable HTTP transports emit this value in the Mcp-Name header. This enables + /// extension methods to identify the named resource targeted by a request. + /// + public string? RoutingName { get; set; } + /// /// Gets or sets the protocol version from the transport-level header (e.g. Mcp-Protocol-Version) /// that accompanied this JSON-RPC message. diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 650b3ea17..961100bc6 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1011,6 +1011,12 @@ private void ConfigureCustomRequestHandlers(McpServerOptions options) $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has a null or empty {nameof(McpServerRequestHandler.Method)}."); } + if (entry.RoutingNameParameter is not null && string.IsNullOrWhiteSpace(entry.RoutingNameParameter)) + { + throw new InvalidOperationException( + $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has an empty {nameof(McpServerRequestHandler.RoutingNameParameter)}."); + } + // Custom handlers are registered after all built-in handlers, so a method already present // belongs to a built-in method (e.g. initialize, tools/call) or an earlier custom handler. // Silently overwriting it would bypass the built-in handler's filters and protocol gating, diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs index be845f599..95a1005c2 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs @@ -24,6 +24,16 @@ public sealed class McpServerRequestHandler /// public required string Method { get; init; } + /// + /// Gets the name of the top-level request parameter whose value is mirrored in the + /// Mcp-Name HTTP routing header. + /// + /// + /// When set, Streamable HTTP servers require the request to include an Mcp-Name + /// header whose decoded value matches the string value of this parameter. + /// + public string? RoutingNameParameter { get; init; } + /// /// Gets the handler function that processes incoming requests for the specified method. /// diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs index 7602ecf11..833f2b250 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -104,7 +104,7 @@ public static ValueTask GetTaskAsync( /// /// Retrieves a task using explicit request parameters. /// - public static ValueTask GetTaskAsync( + public static async ValueTask GetTaskAsync( this McpClient client, GetTaskRequestParams requestParams, CancellationToken cancellationToken = default) @@ -123,11 +123,14 @@ public static ValueTask GetTaskAsync( TaskId = requestParams.TaskId, Meta = GetMetaWithTaskCapability(requestParams.Meta), }; - return client.SendRequestAsync( + JsonRpcRequest jsonRpcRequest = CreateTaskRequest( TasksProtocol.MethodTasksGet, - requestParams, - McpTasksJsonContext.Default.Options, - cancellationToken: cancellationToken); + JsonSerializer.SerializeToNode(requestParams, McpTasksJsonContext.Default.GetTaskRequestParams), + requestParams.TaskId); + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.GetTaskResult) + ?? throw new JsonException("Unexpected JSON result in response."); } /// @@ -163,11 +166,10 @@ public static async ValueTask UpdateTaskAsync( McpJsonUtilities.DefaultOptions.GetTypeInfo>()); } - JsonRpcRequest jsonRpcRequest = new() - { - Method = TasksProtocol.MethodTasksUpdate, - Params = paramsObj, - }; + JsonRpcRequest jsonRpcRequest = CreateTaskRequest( + TasksProtocol.MethodTasksUpdate, + paramsObj, + requestParams.TaskId); JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult) @@ -196,7 +198,7 @@ public static ValueTask CancelTaskAsync( /// /// Requests task cancellation using explicit request parameters. /// - public static ValueTask CancelTaskAsync( + public static async ValueTask CancelTaskAsync( this McpClient client, CancelTaskRequestParams requestParams, CancellationToken cancellationToken = default) @@ -215,13 +217,27 @@ public static ValueTask CancelTaskAsync( TaskId = requestParams.TaskId, Meta = GetMetaWithTaskCapability(requestParams.Meta), }; - return client.SendRequestAsync( + JsonRpcRequest jsonRpcRequest = CreateTaskRequest( TasksProtocol.MethodTasksCancel, - requestParams, - McpTasksJsonContext.Default.Options, - cancellationToken: cancellationToken); + JsonSerializer.SerializeToNode(requestParams, McpTasksJsonContext.Default.CancelTaskRequestParams), + requestParams.TaskId); + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.CancelTaskResult) + ?? new CancelTaskResult(); } + private static JsonRpcRequest CreateTaskRequest(string method, JsonNode? parameters, string taskId) => + new() + { + Method = method, + Params = parameters, + Context = new JsonRpcMessageContext + { + RoutingName = taskId, + }, + }; + private static async ValueTask PollTaskToCompletionAsync( McpClient client, CreateTaskResult taskCreated, diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index a4622b14c..f9626c7d6 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -75,9 +75,24 @@ public void Configure(McpServerOptions options) } options.RequestHandlers ??= new List(); - options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, Handler = HandleGetTask }); - options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask }); - options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksGet, + RoutingNameParameter = "taskId", + Handler = HandleGetTask, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksUpdate, + RoutingNameParameter = "taskId", + Handler = HandleUpdateTask, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksCancel, + RoutingNameParameter = "taskId", + Handler = HandleCancelTask, + }); if (options.Filters.Request.CallToolFilters.Count > 0) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 32e3d00b0..914b98db0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -8,6 +8,7 @@ using System.Net.ServerSentEvents; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.AspNetCore.Tests; @@ -32,6 +33,18 @@ private async Task StartAsync() Name = nameof(HttpHeaderConformanceTests), Version = "1.0", }; +#pragma warning disable MCPEXP002 + options.RequestHandlers = + [ + new McpServerRequestHandler + { + Method = "extension/get", + RoutingNameParameter = "itemId", + Handler = static (_, _) => new ValueTask( + new JsonObject { ["resultType"] = "complete" }), + }, + ]; +#pragma warning restore MCPEXP002 }).WithTools(Tools).WithHttpTransport(); _app = Builder.Build(); @@ -110,6 +123,23 @@ private static McpServerTool CreateUnionHeaderTestTool() #region Server-side validation tests + [Fact] + public async Task Server_UsesCustomHandlerRoutingNameMetadata() + { + await StartAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent( + """{"jsonrpc":"2.0","id":2,"method":"extension/get","params":{"itemId":"item-42","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"""); + request.Headers.Add(McpHttpHeaders.ProtocolVersion, "2026-07-28"); + request.Headers.Add(McpHttpHeaders.Method, "extension/get"); + request.Headers.Add(McpHttpHeaders.Name, "item-42"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task Server_AcceptsUnionIntegerCanonicalForm() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 57b12d246..366053fcd 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; @@ -11,6 +12,7 @@ using System.Threading; using System.Threading.Tasks; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore.Tests; @@ -606,6 +608,31 @@ public async Task Client_SendsCorrectHeaders_EndToEnd() Assert.Equal("us-west-2", capturedHeaders["Mcp-Param-Region"]); } + [Fact] + public async Task TasksClient_SendsRoutingNameHeader_EndToEnd() + { + var capturedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + await StartHeaderCapturingServer(capturedHeaders, supportsTasks: true); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + new McpClientOptions { ProtocolVersion = "2026-07-28" }, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + capturedHeaders.Clear(); + await client.GetTaskAsync("task-42", TestContext.Current.CancellationToken); + + Assert.Equal("tasks/get", capturedHeaders[McpHttpHeaders.Method]); + Assert.Equal("task-42", capturedHeaders[McpHttpHeaders.Name]); + } + private async Task StartHeaderToolServer() { Builder.Services.Configure(options => @@ -683,7 +710,9 @@ private async Task StartHeaderToolServer() await _app.StartAsync(TestContext.Current.CancellationToken); } - private async Task StartHeaderCapturingServer(Dictionary capturedHeaders) + private async Task StartHeaderCapturingServer( + Dictionary capturedHeaders, + bool supportsTasks = false) { Builder.Services.Configure(options => { @@ -706,12 +735,45 @@ private async Task StartHeaderCapturingServer(Dictionary capture Result = JsonSerializer.SerializeToNode(new InitializeResult { ProtocolVersion = "2025-11-25", - Capabilities = new() { Tools = new() }, + Capabilities = new() + { + Tools = new(), + Extensions = supportsTasks + ? new Dictionary + { + ["io.modelcontextprotocol/tasks"] = new JsonObject(), + } + : null, + }, ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) }); } + if (request.Method == "server/discover" && supportsTasks) + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = ["2026-07-28"], + Capabilities = new() + { + Tools = new(), + Extensions = new Dictionary + { + ["io.modelcontextprotocol/tasks"] = new JsonObject(), + }, + }, + ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + ResultType = "complete", + }, McpJsonUtilities.DefaultOptions), + }); + } + if (request.Method == "tools/list") { return Results.Json(new JsonRpcResponse @@ -732,7 +794,7 @@ private async Task StartHeaderCapturingServer(Dictionary capture }); } - if (request.Method == "tools/call") + if (request.Method is "tools/call" or "tasks/get") { // Capture all MCP headers for verification foreach (var header in context.Request.Headers) @@ -743,6 +805,16 @@ private async Task StartHeaderCapturingServer(Dictionary capture } } + if (request.Method == "tasks/get") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonNode.Parse( + """{"taskId":"task-42","status":"working","createdAt":"2026-01-01T00:00:00Z","lastUpdatedAt":"2026-01-01T00:00:00Z"}"""), + }); + } + var parameters = JsonSerializer.Deserialize(request.Params, GetJsonTypeInfo()); return Results.Json(new JsonRpcResponse { diff --git a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs index 424537461..56e8fd1e6 100644 --- a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs @@ -75,5 +75,28 @@ public async Task CustomHandler_UniqueMethod_Succeeds() await using var server = McpServer.Create(transport, options, LoggerFactory); Assert.NotNull(server); } + + [Fact] + public async Task CustomHandler_EmptyRoutingNameParameter_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = + [ + new McpServerRequestHandler + { + Method = "custom/method", + RoutingNameParameter = " ", + Handler = (request, cancellationToken) => new ValueTask((JsonNode?)null), + }, + ], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(nameof(McpServerRequestHandler.RoutingNameParameter), ex.Message); + } #pragma warning restore MCPEXP002 } From bfad171307e6d147d06cf84db42a119ec5e58b5d Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Fri, 24 Jul 2026 16:46:00 -0700 Subject: [PATCH 4/5] Mark routing metadata APIs experimental Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b --- .../Client/StreamableHttpClientSessionTransport.cs | 2 ++ src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs | 2 ++ src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs | 1 + 3 files changed, 5 insertions(+) diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index f70a6ed85..db67b2e6b 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -592,6 +592,7 @@ internal static void AddMcpRequestHeaders(HttpRequestHeaders headers, JsonRpcMes headers.Add(McpHttpHeaders.Method, method); // Add Mcp-Name header for methods that target a specific named resource +#pragma warning disable MCPEXP002 string? name = message.Context?.RoutingName ?? message switch { JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request @@ -600,6 +601,7 @@ internal static void AddMcpRequestHeaders(HttpRequestHeaders headers, JsonRpcMes => GetParamsStringProperty(request.Params, "uri"), _ => null, }; +#pragma warning restore MCPEXP002 if (name is not null) { diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 1f04aa039..022f1bfcd 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Server; +using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Text.Json.Serialization; @@ -82,6 +83,7 @@ public sealed class JsonRpcMessageContext /// Streamable HTTP transports emit this value in the Mcp-Name header. This enables /// extension methods to identify the named resource targeted by a request. /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] public string? RoutingName { get; set; } /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs index 95a1005c2..2da6d0eb9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs @@ -32,6 +32,7 @@ public sealed class McpServerRequestHandler /// When set, Streamable HTTP servers require the request to include an Mcp-Name /// header whose decoded value matches the string value of this parameter. /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] public string? RoutingNameParameter { get; init; } /// From 0dcd05b75235cb9cd339cae9addfe98c007b4e81 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Fri, 24 Jul 2026 17:02:32 -0700 Subject: [PATCH 5/5] Exclude routing context from JSON Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b --- src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 022f1bfcd..fae0684d9 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -84,6 +84,7 @@ public sealed class JsonRpcMessageContext /// extension methods to identify the named resource targeted by a request. /// [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [JsonIgnore] public string? RoutingName { get; set; } ///