Add initial task extension conformance coverage (#1734)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b
This commit is contained in:
Pranav Senthilnathan 2026-07-24 17:16:20 -07:00 • committed by GitHub
commit 83523b0a42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 463 additions and 43 deletions

View file

@ -110,7 +110,7 @@ internal sealed class StreamableHttpHandler(
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 @@ internal sealed class StreamableHttpHandler(
/// </summary>
/// <param name="context">The HTTP context containing the request headers.</param>
/// <param name="message">The JSON-RPC message to validate against.</param>
/// <param name="toolCollection">The tool collection to look up tool schemas for parameter header validation.</param>
/// <param name="serverOptions">The server options containing tools and custom request routing metadata.</param>
/// <param name="errorMessage">Set to the error message if validation fails; null otherwise.</param>
/// <returns>True if validation passes; false otherwise.</returns>
internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerPrimitiveCollection<McpServerTool>? 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 sealed class StreamableHttpHandler(
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 sealed class StreamableHttpHandler(
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 sealed class StreamableHttpHandler(
}
// 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 sealed class StreamableHttpHandler(
return true;
}
#pragma warning disable MCPEXP002
private static string? GetRoutingNameParameter(
string? method,
IList<McpServerRequestHandler>? 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
/// <summary>
/// Validates that all parameters annotated with <c>x-mcp-header</c> in the tool's input schema
/// have corresponding <c>Mcp-Param-*</c> headers present in the request, and that any present

View file

@ -592,7 +592,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
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 sealed partial class StreamableHttpClientSessionTransport : TransportBa
=> GetParamsStringProperty(request.Params, "uri"),
_ => null,
};
#pragma warning restore MCPEXP002
if (name is not null)
{

View file

@ -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
/// </remarks>
public IDictionary<string, object?>? Items { get; set; }
/// <summary>
/// Gets or sets the routing name for this message.
/// </summary>
/// <remarks>
/// Streamable HTTP transports emit this value in the <c>Mcp-Name</c> header. This enables
/// extension methods to identify the named resource targeted by a request.
/// </remarks>
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
[JsonIgnore]
public string? RoutingName { get; set; }
/// <summary>
/// Gets or sets the protocol version from the transport-level header (e.g. <c>Mcp-Protocol-Version</c>)
/// that accompanied this JSON-RPC message.

View file

@ -1011,6 +1011,12 @@ internal sealed partial class McpServerImpl : McpServer
$"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,

View file

@ -24,6 +24,17 @@ public sealed class McpServerRequestHandler
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Gets the name of the top-level request parameter whose value is mirrored in the
/// <c>Mcp-Name</c> HTTP routing header.
/// </summary>
/// <remarks>
/// When set, Streamable HTTP servers require the request to include an <c>Mcp-Name</c>
/// header whose decoded value matches the string value of this parameter.
/// </remarks>
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
public string? RoutingNameParameter { get; init; }
/// <summary>
/// Gets the handler function that processes incoming requests for the specified method.
/// </summary>

View file

@ -104,7 +104,7 @@ public static class McpTasksClientExtensions
/// <summary>
/// Retrieves a task using explicit request parameters.
/// </summary>
public static ValueTask<GetTaskResult> GetTaskAsync(
public static async ValueTask<GetTaskResult> GetTaskAsync(
this McpClient client,
GetTaskRequestParams requestParams,
CancellationToken cancellationToken = default)
@ -118,11 +118,19 @@ public static class McpTasksClientExtensions
#endif
ThrowIfTasksNotSupported(client, nameof(GetTaskAsync));
return client.SendRequestAsync<GetTaskRequestParams, GetTaskResult>(
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.");
}
/// <summary>
@ -148,6 +156,7 @@ public static class McpTasksClientExtensions
JsonObject paramsObj = new()
{
["taskId"] = requestParams.TaskId,
["_meta"] = GetMetaWithTaskCapability(requestParams.Meta),
};
if (requestParams.InputResponses is { Count: > 0 } inputResponses)
@ -157,11 +166,10 @@ public static class McpTasksClientExtensions
McpJsonUtilities.DefaultOptions.GetTypeInfo<IDictionary<string, InputResponse>>());
}
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 class McpTasksClientExtensions
/// <summary>
/// Requests task cancellation using explicit request parameters.
/// </summary>
public static ValueTask<CancelTaskResult> CancelTaskAsync(
public static async ValueTask<CancelTaskResult> CancelTaskAsync(
this McpClient client,
CancelTaskRequestParams requestParams,
CancellationToken cancellationToken = default)
@ -204,13 +212,32 @@ public static class McpTasksClientExtensions
#endif
ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync));
return client.SendRequestAsync<CancelTaskRequestParams, CancelTaskResult>(
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<CallToolResult> PollTaskToCompletionAsync(
McpClient client,
CreateTaskResult taskCreated,

View file

@ -20,4 +20,9 @@ namespace ModelContextProtocol.Extensions.Tasks;
/// </remarks>
public sealed class CancelTaskResult : Result
{
/// <summary>Initializes a new task cancellation acknowledgement.</summary>
public CancelTaskResult()
{
ResultType = "complete";
}
}

View file

@ -19,4 +19,9 @@ namespace ModelContextProtocol.Extensions.Tasks;
/// </remarks>
public sealed class UpdateTaskResult : Result
{
/// <summary>Initializes a new task update acknowledgement.</summary>
public UpdateTaskResult()
{
ResultType = "complete";
}
}

View file

@ -75,9 +75,24 @@ public static class McpTasksBuilderExtensions
}
options.RequestHandlers ??= new List<McpServerRequestHandler>();
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 static class McpTasksBuilderExtensions
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 @@ public static class McpTasksBuilderExtensions
private async ValueTask<JsonNode?> 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 @@ public static class McpTasksBuilderExtensions
private async ValueTask<JsonNode?> HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken)
{
GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate);
GateToTasksCapability(request);
var taskId = request.Params?["taskId"]?.GetValue<string>()
?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams);
@ -292,6 +310,7 @@ public static class McpTasksBuilderExtensions
private async ValueTask<JsonNode?> 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 @@ public static class McpTasksBuilderExtensions
}
}
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<string, object>
{
[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);