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 }