Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
adb0367
Add protocol version "DRAFT-2026-v1" as supported protocol version
mikekistler Apr 27, 2026
0207bac
Wire up SEP-2243 conformance test scenarios for client and server
mikekistler Mar 29, 2026
259a28b
Add SEP-2243 HTTP Standardization support
mikekistler Mar 29, 2026
4164474
Add docs for custom HTTP headers from tool parameters
mikekistler May 1, 2026
ef7281a
Fix SEP-2243 spec compliance gaps and add comprehensive tests
tarekgh May 4, 2026
9705707
Gate SEP-2243 conformance tests on harness version >= 0.1.16
tarekgh May 4, 2026
417930b
Fix conformance test false failures on Windows due to libuv cleanup c…
May 10, 2026
e59e5d8
Move McpHeaderEncoder from Client to Protocol namespace
May 11, 2026
52ffc9d
Merge branch 'main' into mdk/sep-2243-implementation
tarekgh May 11, 2026
6b3369b
Improve McpHeaderEncoder and McpHttpHeaders public API
May 11, 2026
bdcf448
Trim whitespace from MCP header values per SEP-2243
May 12, 2026
5f4735b
Use numeric comparison for number-typed custom header validation
May 12, 2026
66deb5e
Fix misleading comment on Base64 prefix case sensitivity
May 12, 2026
410cfe4
Add [NotNullWhen(false)] to ValidateMcpHeaders and remove null-forgiv…
May 13, 2026
471ab88
Fix comment typo: tools/read -> resources/read
May 13, 2026
69362e4
Use SearchValues.Create for vectorized header value validation
May 13, 2026
a313059
Rename Sep2243HeaderTests to HttpHeaderConformanceTests
May 13, 2026
041c54e
Add test for server rejecting invalid UTF-8 encoded header values
May 13, 2026
efe9850
Bump conformance package to 0.1.16 and remove runtime version checks
May 13, 2026
39c85c3
Detect stale node_modules via package-lock.json timestamp
May 13, 2026
794d076
Centralize JSON-to-header conversion in McpHeaderEncoder
May 13, 2026
22a61ad
Add test for tool rejection warning log on invalid x-mcp-header
May 13, 2026
9f942f1
Replace internal virtual with delegate fields for tool discovery hooks
May 13, 2026
6172219
Use exact long comparison for integer schema type in header validation
May 13, 2026
6960dce
Clear tool cache at start of ListToolsAsync to prevent unbounded growth
May 13, 2026
72e8e6e
Update CI to Node.js 22 for conformance package compatibility
May 13, 2026
f883121
Skip SEP-2243 conformance tests until scenarios are available in package
May 13, 2026
8dad361
Make McpHttpHeaders internal via shared source
May 14, 2026
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).
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

311 changes: 308 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);
Comment thread
tarekgh marked this conversation as resolved.
Outdated
return;
}

var session = await GetOrCreateSessionAsync(context, message);
if (session is null)
{
Expand Down Expand Up @@ -540,6 +547,304 @@ 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)
Comment thread
tarekgh marked this conversation as resolved.
Outdated
{
// 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))
Comment thread
halter73 marked this conversation as resolved.
{
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().Trim();
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
Comment thread
tarekgh marked this conversation as resolved.
Outdated
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().Trim();

// 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");
Comment thread
halter73 marked this conversation as resolved.
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().Trim();

// 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 = 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 = McpHeaderEncoder.DecodeValue(expectedHeaderValue);
if (!ValuesMatch(decodedActual, decodedExpected, property.Value))
{
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)
Comment thread
tarekgh marked this conversation as resolved.
Outdated
{
foreach (char c in value)
{
if (c < 0x20 || c > 0x7E)
{
if (c != '\t')
{
return false;
}
}
}

return true;
}

/// <summary>
/// Compares two decoded header values, using numeric comparison for number-typed
/// parameters to handle cross-SDK representation differences (e.g., "42" vs "42.0").
/// </summary>
private static bool ValuesMatch(string? actual, string? expected, System.Text.Json.JsonElement propertySchema)
{
if (string.Equals(actual, expected, StringComparison.Ordinal))
{
return true;
}

// JSON Schema defines two numeric types: "number" (any numeric value including
// decimals like 3.14) and "integer" (whole numbers only like 42). Both produce
// JsonValueKind.Number in the JSON body and are sent as numeric strings in headers.
// We check for both because different SDKs may serialize them differently —
// e.g., a client might send header "42.0" for an "integer" body value of 42,
// or header "42" for a "number" body value of 42.0. Without handling both types,
// valid cross-SDK requests would be incorrectly rejected.
if (propertySchema.TryGetProperty("type", out var typeElement) &&
typeElement.ValueKind == System.Text.Json.JsonValueKind.String &&
typeElement.GetString() is "number" or "integer" &&
actual is not null && expected is not null &&
double.TryParse(actual, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var actualNum) &&
double.TryParse(expected, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var expectedNum))
{
// Allow a small absolute tolerance for floating point representation differences.
if (Math.Abs(actualNum - expectedNum) < 1e-9)
{
return true;
}
}
Comment thread
tarekgh marked this conversation as resolved.
Outdated

return false;
}

private static string? ConvertJsonNodeToHeaderValue(System.Text.Json.Nodes.JsonNode node)
Comment thread
tarekgh marked this conversation as resolved.
Outdated
{
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 McpHeaderEncoder.EncodeValue(value);
}

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

Expand Down
Loading
Loading