Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -829,10 +829,10 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex
/// </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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand All @@ -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;
}
Expand All @@ -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<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;
}
}
Comment thread
jeffhandley marked this conversation as resolved.
}

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,14 +592,16 @@ 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"),
JsonRpcRequest { Method: RequestMethods.ResourcesRead } request
=> GetParamsStringProperty(request.Params, "uri"),
_ => null,
};
#pragma warning restore MCPEXP002

if (name is not null)
{
Expand Down
12 changes: 12 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -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]
Comment thread
jeffhandley marked this conversation as resolved.
public string? RoutingName { get; set; }
Comment thread
jeffhandley marked this conversation as resolved.

/// <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.
Expand Down
6 changes: 6 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public static ValueTask<GetTaskResult> GetTaskAsync(
/// <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)
Expand All @@ -118,11 +118,19 @@ public static ValueTask<GetTaskResult> GetTaskAsync(
#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>
Expand All @@ -148,6 +156,7 @@ public static async ValueTask<UpdateTaskResult> UpdateTaskAsync(
JsonObject paramsObj = new()
{
["taskId"] = requestParams.TaskId,
["_meta"] = GetMetaWithTaskCapability(requestParams.Meta),
};

if (requestParams.InputResponses is { Count: > 0 } inputResponses)
Expand All @@ -157,11 +166,10 @@ public static async ValueTask<UpdateTaskResult> UpdateTaskAsync(
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)
Expand Down Expand Up @@ -190,7 +198,7 @@ public static ValueTask<CancelTaskResult> CancelTaskAsync(
/// <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)
Expand All @@ -204,13 +212,32 @@ public static ValueTask<CancelTaskResult> CancelTaskAsync(
#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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,24 @@ public void Configure(McpServerOptions options)
}

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)
{
Expand All @@ -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);
}
Expand Down Expand Up @@ -257,6 +273,7 @@ private async Task ExecuteToolPipelineAsync(
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);
Expand All @@ -273,6 +290,7 @@ private async Task ExecuteToolPipelineAsync(
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);
Expand All @@ -292,6 +310,7 @@ private async Task ExecuteToolPipelineAsync(
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);
Expand Down Expand Up @@ -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<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);
Expand Down
Loading