diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index 0d3cf3240..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,8 +871,10 @@ 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))
+#pragma warning disable MCPEXP002
+ var routingNameParameter = GetRoutingNameParameter(mcpMethodInBody, serverOptions.RequestHandlers);
+#pragma warning restore MCPEXP002
+ if (routingNameParameter is null)
{
errorMessage = null;
return true;
@@ -911,13 +913,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess
JsonRpcNotification notification => notification.Params,
_ => null,
};
- var mcpNameInBody = mcpMethodInBody switch
- {
- RequestMethods.ToolsCall => GetJsonNodeStringProperty(bodyParams, "name"),
- RequestMethods.ResourcesRead => GetJsonNodeStringProperty(bodyParams, "uri"),
- RequestMethods.PromptsGet => GetJsonNodeStringProperty(bodyParams, "name"),
- _ => 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))
@@ -927,7 +923,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess
}
// 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;
}
@@ -936,6 +932,38 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess
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 a6334aa31..db67b2e6b 100644
--- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
@@ -592,7 +592,8 @@ 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
+#pragma warning disable MCPEXP002
+ string? name = message.Context?.RoutingName ?? message switch
{
JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request
=> GetParamsStringProperty(request.Params, "name"),
@@ -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 28d7774cb..fae0684d9 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;
@@ -75,6 +76,17 @@ 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.
+ ///
+ [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+ [JsonIgnore]
+ 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..2da6d0eb9 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs
@@ -24,6 +24,17 @@ 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.
+ ///
+ [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+ 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 88055b2ad..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)
@@ -118,11 +118,19 @@ public static ValueTask GetTaskAsync(
#endif
ThrowIfTasksNotSupported(client, nameof(GetTaskAsync));
- return client.SendRequestAsync(
+ requestParams = new GetTaskRequestParams
+ {
+ TaskId = requestParams.TaskId,
+ Meta = GetMetaWithTaskCapability(requestParams.Meta),
+ };
+ 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.");
}
///
@@ -148,6 +156,7 @@ public static async ValueTask UpdateTaskAsync(
JsonObject paramsObj = new()
{
["taskId"] = requestParams.TaskId,
+ ["_meta"] = GetMetaWithTaskCapability(requestParams.Meta),
};
if (requestParams.InputResponses is { Count: > 0 } inputResponses)
@@ -157,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)
@@ -190,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)
@@ -204,13 +212,32 @@ public static ValueTask CancelTaskAsync(
#endif
ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync));
- return client.SendRequestAsync(
+ requestParams = new CancelTaskRequestParams
+ {
+ TaskId = requestParams.TaskId,
+ Meta = GetMetaWithTaskCapability(requestParams.Meta),
+ };
+ 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/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/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
index 9c46b0a2c..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)
{
@@ -93,7 +108,8 @@ 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) ||
+ !HasTaskExtensionOptIn(request.JsonRpcRequest))
{
return await next(request, cancellationToken).ConfigureAwait(false);
}
@@ -257,6 +273,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 +290,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 +310,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 +338,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/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs
index abd593ffc..98c225dd9 100644
--- a/tests/Common/Utils/NodeHelpers.cs
+++ b/tests/Common/Utils/NodeHelpers.cs
@@ -535,6 +535,16 @@ 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-wire-fields",
+ "tasks-request-state-removal",
+ "tasks-mrtr-input");
+
private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
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/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index cd538d819..059c5517d 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -127,6 +127,34 @@ public async Task RunMrtrConformanceTest(string scenario)
$"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
+ [Theory]
+ [InlineData("tasks-wire-fields")]
+ [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")]
+ 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.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.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..e195f7cf2 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,14 @@ private static void ConfigureConformanceMcpServer(
.AddMcpServer()
.WithHttpTransport(options => options.Stateless = stateless)
.WithDistributedCacheEventStreamStore()
+ .WithTasks(
+ new InMemoryMcpTaskStore
+ {
+ DefaultPollIntervalMs = 50,
+ DefaultTimeToLive = TimeSpan.FromMinutes(5),
+ })
.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/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
}