Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Cake tools
/[Tt]ools/

# Language server cache
*.lscache

# Build output
[Bb]uildArtifacts/
# Build results
Expand Down
25 changes: 25 additions & 0 deletions docs/concepts/tools/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,28 @@ public static string Search(
// Schema will include descriptions and default value for maxResults
}
```

### Custom HTTP headers from tool parameters

When using the Streamable HTTP transport, tool parameters can be mirrored as HTTP headers so that network infrastructure (load balancers, proxies, gateways) can make routing decisions without parsing the JSON-RPC request body. Apply the <xref:ModelContextProtocol.Server.McpHeaderAttribute> to a parameter to opt it in:

```csharp
[McpServerTool, Description("Executes a SQL query in a specific region")]
public static string ExecuteSql(
[McpHeader("Region"), Description("Target datacenter region")] string region,
[Description("The SQL query to execute")] string query)
{
// Clients will send an additional HTTP header:
// Mcp-Param-Region: <region value>
}
```

When the tool's schema is generated, the annotated parameter includes an `x-mcp-header` extension property. Clients read this annotation and automatically add the corresponding `Mcp-Param-{Name}` header on outgoing `tools/call` requests. The server validates that the header value matches the value in the JSON-RPC body.

Rules and constraints:

- Only primitive parameter types (`string`, numeric types, `bool`) are supported.
- The header name must contain only visible ASCII characters (0x21–0x7E) excluding colon (`:`).
- Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper.
- Header names must be case-insensitively unique within the tool's input schema.
- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later).
276 changes: 273 additions & 3 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ internal sealed class StreamableHttpHandler(
IServiceProvider applicationServices,
ILoggerFactory loggerFactory)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version";
private const string LastEventIdHeaderName = "Last-Event-ID";
private const string McpSessionIdHeaderName = McpHttpHeaders.SessionId;
private const string McpProtocolVersionHeaderName = McpHttpHeaders.ProtocolVersion;
private const string LastEventIdHeaderName = McpHttpHeaders.LastEventId;

/// <summary>
/// All protocol versions supported by this implementation.
Expand All @@ -37,6 +37,7 @@ internal sealed class StreamableHttpHandler(
"2025-03-26",
"2025-06-18",
"2025-11-25",
"DRAFT-2026-v1",
];

private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
Expand Down Expand Up @@ -79,6 +80,12 @@ await WriteJsonRpcErrorAsync(context,
return;
}

if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out errorMessage))
{
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch);
return;
}

var session = await GetOrCreateSessionAsync(context, message);
if (session is null)
{
Expand Down Expand Up @@ -540,6 +547,269 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, out strin
return true;
}

/// <summary>
/// Validates standard MCP request headers (Mcp-Method, Mcp-Name) and custom parameter headers
/// (Mcp-Param-*) against the JSON-RPC request body.
/// Validation is only performed for protocol versions that include the HTTP Standardization feature.
/// </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="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, out string? errorMessage)
{
// Only validate for protocol versions that support standard headers.
var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
if (!McpHttpHeaders.SupportsStandardHeaders(protocolVersion))
{
errorMessage = null;
return true;
}

// Only validate for JSON-RPC requests and notifications, not responses.
if (!(message is JsonRpcRequest || message is JsonRpcNotification))
{
errorMessage = null;
return true;
}

// For requests that support standard headers, the Mcp-Method header must be present
// and match the method in the JSON-RPC body.
if (!context.Request.Headers.ContainsKey(McpHttpHeaders.Method))
{
errorMessage = "Missing required Mcp-Method header.";
return false;
}

var mcpMethodInHeader = context.Request.Headers[McpHttpHeaders.Method].ToString();
var mcpMethodInBody = message switch
{
JsonRpcRequest request => request.Method,
JsonRpcNotification notification => notification.Method,
_ => null, // This case is already ruled out by the earlier check, but we need it to satisfy the compiler.
};

if (!string.Equals(mcpMethodInHeader, mcpMethodInBody, StringComparison.Ordinal))
{
errorMessage = $"Header mismatch: Mcp-Method header value '{mcpMethodInHeader}' does not match body value '{mcpMethodInBody}'.";
return false;
}

// From here on, only validate tools/read, tools/call, and prompts/get requests
if (mcpMethodInBody is not (RequestMethods.ToolsCall or RequestMethods.ResourcesRead or RequestMethods.PromptsGet))
{
errorMessage = null;
return true;
}

// For these requests, the Mcp-Name header must be present and match the name or uri in the JSON-RPC body.
if (!context.Request.Headers.ContainsKey(McpHttpHeaders.Name))
{
errorMessage = "Missing required Mcp-Name header.";
return false;
}

var mcpNameInHeader = context.Request.Headers[McpHttpHeaders.Name].ToString();

// Extract the params and name value from the body based on the method, if present.
var bodyParams = message switch
{
JsonRpcRequest request => request.Params,
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,
};

// Check that the header value matches the body value if the body value is present.
if (!string.Equals(mcpNameInHeader, mcpNameInBody, StringComparison.Ordinal))
{
errorMessage = $"Header mismatch: Mcp-Name header value '{mcpNameInHeader}' does not match body value '{mcpNameInBody}'.";
return false;
}

// Validate Mcp-Param-* custom headers against tool schema
if (!ValidateCustomParamHeaders(context, message, toolCollection, out errorMessage))
{
return false;
}

errorMessage = null;
return true;
}

/// <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
/// <c>Mcp-Param-*</c> headers have valid encoding.
/// </summary>
private static bool ValidateCustomParamHeaders(
HttpContext context,
JsonRpcMessage message,
McpServerPrimitiveCollection<McpServerTool>? toolCollection,
out string? errorMessage)
{
// Custom param headers are only relevant for tools/call requests
if (message is not JsonRpcRequest { Method: RequestMethods.ToolsCall, Params: { } bodyParams })
{
errorMessage = null;
return true;
}

// Look up the tool to check for x-mcp-header annotations in the schema
var toolName = GetJsonNodeStringProperty(bodyParams, "name");
if (toolName is null || toolCollection is null || !toolCollection.TryGetPrimitive(toolName, out var tool))
{
errorMessage = null;
return true;
}

var inputSchema = tool.ProtocolTool.InputSchema;
if (inputSchema.ValueKind != System.Text.Json.JsonValueKind.Object ||
!inputSchema.TryGetProperty("properties", out var properties) ||
properties.ValueKind != System.Text.Json.JsonValueKind.Object)
{
errorMessage = null;
return true;
}

// Get the arguments from the body for value comparison
System.Text.Json.Nodes.JsonNode? arguments = null;
if (bodyParams is System.Text.Json.Nodes.JsonObject paramsObj)
{
paramsObj.TryGetPropertyValue("arguments", out arguments);
}

// Check that every x-mcp-header annotated parameter has a corresponding header,
// that the header value is validly encoded, and that it matches the body value.
foreach (var property in properties.EnumerateObject())
{
if (!property.Value.TryGetProperty("x-mcp-header", out var headerNameElement))
{
continue;
}

var headerName = headerNameElement.GetString();
if (string.IsNullOrEmpty(headerName))
{
continue;
}

var fullHeaderName = $"{McpHttpHeaders.ParamPrefix}{headerName}";
if (!context.Request.Headers.ContainsKey(fullHeaderName))
{
// Per the SEP: if the parameter value is null or not provided in
// the arguments, the client MUST omit the header and the server
// MUST NOT expect it. Only reject when a non-null value is present
// in the body but the header is missing.
bool hasNonNullBodyValue = arguments is System.Text.Json.Nodes.JsonObject argsForMissing &&
argsForMissing.TryGetPropertyValue(property.Name, out var argForMissing) &&
argForMissing is not null &&
argForMissing.GetValueKind() != System.Text.Json.JsonValueKind.Null;

if (hasNonNullBodyValue)
{
errorMessage = $"Missing required {fullHeaderName} header for parameter '{property.Name}' annotated with x-mcp-header.";
return false;
}

continue;
}

var actualHeaderValue = context.Request.Headers[fullHeaderName].ToString();

// Validate the raw header value for invalid characters per SEP.
// Servers MUST reject headers containing characters outside the valid HTTP header value range.
if (!IsValidHeaderValue(actualHeaderValue))
{
errorMessage = $"Header mismatch: {fullHeaderName} header contains invalid characters.";
return false;
}

var decodedActual = Client.McpHeaderEncoder.DecodeValue(actualHeaderValue);
if (decodedActual is null)
{
errorMessage = $"Header mismatch: {fullHeaderName} header contains invalid Base64 encoding.";
return false;
}

// Verify the header value matches the argument value in the body
if (arguments is System.Text.Json.Nodes.JsonObject argsObj &&
argsObj.TryGetPropertyValue(property.Name, out var argNode) &&
argNode is not null)
{
var expectedHeaderValue = ConvertJsonNodeToHeaderValue(argNode);
if (expectedHeaderValue is not null)
{
var decodedExpected = Client.McpHeaderEncoder.DecodeValue(expectedHeaderValue);
if (!string.Equals(decodedActual, decodedExpected, StringComparison.Ordinal))
{
errorMessage = $"Header mismatch: {fullHeaderName} header value does not match body argument '{property.Name}'.";
return false;
}
}
}
}

errorMessage = null;
return true;
}

private static string? GetJsonNodeStringProperty(System.Text.Json.Nodes.JsonNode? node, string propertyName)
{
if (node is System.Text.Json.Nodes.JsonObject obj && obj.TryGetPropertyValue(propertyName, out var value))
{
return value?.GetValue<string>();
}

return null;
}

/// <summary>
/// Validates that a header value contains only characters allowed in HTTP header field values
/// per RFC 9110: visible ASCII (0x21-0x7E), space (0x20), and horizontal tab (0x09).
/// </summary>
private static bool IsValidHeaderValue(string value)
{
foreach (char c in value)
{
if (c < 0x20 || c > 0x7E)
{
if (c != '\t')
{
return false;
}
}
}

return true;
}

private static string? ConvertJsonNodeToHeaderValue(System.Text.Json.Nodes.JsonNode node)
{
if (node is not System.Text.Json.Nodes.JsonValue jsonValue)
{
return null;
}

object? value = jsonValue.GetValueKind() switch
{
System.Text.Json.JsonValueKind.String => jsonValue.GetValue<string>(),
System.Text.Json.JsonValueKind.Number => jsonValue.ToJsonString(),
System.Text.Json.JsonValueKind.True => true,
System.Text.Json.JsonValueKind.False => false,
_ => null
};

return Client.McpHeaderEncoder.EncodeValue(value);
}

private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("application/json");

Expand Down
29 changes: 29 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ public async ValueTask<IList<McpClientTool>> ListToolsAsync(
tools ??= new(toolResults.Tools.Count);
foreach (var tool in toolResults.Tools)
{
// Validate x-mcp-header annotations per SEP-2243.
// Clients MUST exclude tools with invalid annotations and SHOULD log a warning.
if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason))
{
OnToolRejected(tool, rejectionReason!);
continue;
}

OnToolDiscovered(tool);
tools.Add(new(this, tool, options?.JsonSerializerOptions));
}

Expand All @@ -194,6 +203,26 @@ public async ValueTask<IList<McpClientTool>> ListToolsAsync(
return tools;
}

/// <summary>
/// Called when a tool definition is discovered from a <c>tools/list</c> response.
/// </summary>
/// <remarks>
/// Override this method to cache or process tool definitions for use in
/// subsequent <c>tools/call</c> requests (e.g., for adding custom HTTP headers).
/// </remarks>
internal virtual void OnToolDiscovered(Tool tool)
{
}

/// <summary>
/// Called when a tool definition is rejected due to invalid <c>x-mcp-header</c> annotations.
/// </summary>
/// <param name="tool">The tool that was rejected.</param>
/// <param name="reason">The reason the tool was rejected.</param>
internal virtual void OnToolRejected(Tool tool, string reason)
{
}

/// <summary>
/// Retrieves a list of available tools from the server.
/// </summary>
Expand Down
Loading
Loading