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
33 changes: 33 additions & 0 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -703,8 +703,41 @@ private static bool ValidateCustomParamHeaders(

// 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.
return ValidateCustomParamHeadersFromProperties(context, properties, arguments, out errorMessage);
}

/// <summary>
/// Recursively validates x-mcp-header annotated properties at any nesting depth.
/// </summary>
private static bool ValidateCustomParamHeadersFromProperties(
HttpContext context,
System.Text.Json.JsonElement properties,
System.Text.Json.Nodes.JsonNode? arguments,
[NotNullWhen(false)] out string? errorMessage)
{
foreach (var property in properties.EnumerateObject())
{
if (property.Value.ValueKind != System.Text.Json.JsonValueKind.Object)
{
continue;
}

// Recurse into nested object properties
if (property.Value.TryGetProperty("properties", out var nestedProperties) &&
nestedProperties.ValueKind == System.Text.Json.JsonValueKind.Object)
{
System.Text.Json.Nodes.JsonNode? nestedArgs = null;
if (arguments is System.Text.Json.Nodes.JsonObject parentObj)
{
parentObj.TryGetPropertyValue(property.Name, out nestedArgs);
}

if (!ValidateCustomParamHeadersFromProperties(context, nestedProperties, nestedArgs, out errorMessage))
{
return false;
}
}

if (!property.Value.TryGetProperty("x-mcp-header", out var headerNameElement))
{
continue;
Expand Down
6 changes: 4 additions & 2 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,10 @@ 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.
// Validate x-mcp-header annotations per SEP-2243. The spec requires Streamable HTTP
// clients to exclude tools with invalid annotations and permits other transports
// (e.g., stdio) to ignore the annotations entirely. This client validates on all
// transports so a malformed definition is rejected consistently regardless of transport.
if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason))
{
ToolRejected?.Invoke(tool, rejectionReason!);
Expand Down
226 changes: 207 additions & 19 deletions src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System.Net.Http.Headers;
using System.Text.Json;
#if NET
using System.Buffers;
#endif
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;

Expand Down Expand Up @@ -36,10 +39,35 @@ public static void AddParameterHeaders(
return;
}

AddParameterHeadersFromProperties(headers, properties, arguments.Value);
}

/// <summary>
/// Recursively extracts parameter values from properties at any nesting depth
/// and adds them as HTTP headers.
/// </summary>
private static void AddParameterHeadersFromProperties(
HttpRequestHeaders headers,
JsonElement properties,
JsonElement arguments)
{
foreach (var property in properties.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.Object ||
!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
if (property.Value.ValueKind != JsonValueKind.Object)
{
continue;
}

// Recurse into nested object properties
if (property.Value.TryGetProperty("properties", out var nestedProperties) &&
nestedProperties.ValueKind == JsonValueKind.Object &&
arguments.TryGetProperty(property.Name, out var nestedArgs) &&
nestedArgs.ValueKind == JsonValueKind.Object)
{
AddParameterHeadersFromProperties(headers, nestedProperties, nestedArgs);
}

if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
{
continue;
}
Expand All @@ -51,7 +79,7 @@ public static void AddParameterHeaders(
}

// Look for the corresponding argument value
if (!arguments.Value.TryGetProperty(property.Name, out var argValue))
if (!arguments.TryGetProperty(property.Name, out var argValue))
{
continue;
}
Expand Down Expand Up @@ -86,12 +114,35 @@ internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason)
}

var headerNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return ValidateProperties(tool, properties, headerNames, out rejectionReason);
}

/// <summary>
/// Recursively validates properties at any nesting depth for valid <c>x-mcp-header</c> annotations.
/// </summary>
private static bool ValidateProperties(Tool tool, JsonElement properties, HashSet<string> headerNames, out string? rejectionReason)
{
rejectionReason = null;

foreach (var property in properties.EnumerateObject())
{
// Skip properties whose schema is not an object (e.g., boolean `true`/`false` schemas)
if (property.Value.ValueKind != JsonValueKind.Object ||
!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
if (property.Value.ValueKind != JsonValueKind.Object)
{
continue;
}

// Recurse into nested object properties
if (property.Value.TryGetProperty("properties", out var nestedProperties) &&
nestedProperties.ValueKind == JsonValueKind.Object)
{
if (!ValidateProperties(tool, nestedProperties, headerNames, out rejectionReason))
{
return false;
}
}

if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
{
continue;
}
Expand All @@ -112,36 +163,173 @@ internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason)
return false;
}

// MUST contain only ASCII characters (0x21-0x7E) excluding space and colon
foreach (char c in headerName!)
// MUST match HTTP field-name token syntax (1*tchar, RFC 9110 Section 5.1)
// MUST NOT contain control characters including CR and LF
int invalidIdx = FindFirstNonTchar(headerName!);
if (invalidIdx >= 0)
{
if (c < 0x21 || c > 0x7E || c == ':')
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2}).";
return false;
}
char c = headerName![invalidIdx];
rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2}).";
return false;
}

// MUST be case-insensitively unique
if (!headerNames.Add(headerName))
if (!headerNames.Add(headerName!))
{
rejectionReason = $"Tool '{tool.Name}': duplicate x-mcp-header name '{headerName}' (case-insensitive).";
return false;
}

// MUST only be applied to primitive types (string, number, boolean)
// MUST only be applied to parameters with primitive types (string, integer, boolean).
// Parameters with type "number" (or any other non-primitive type) are not permitted.
// The "type" keyword may be omitted (treated as unknown, not rejected, since many valid
// schemas constrain the value via enum/const/$ref instead) or expressed as a JSON Schema
// union array such as ["string", "null"]; only an explicitly disallowed or malformed type
// causes rejection.
if (property.Value.TryGetProperty("type", out var typeElement) &&
typeElement.ValueKind == JsonValueKind.String)
!IsAllowedHeaderType(typeElement))
{
var typeName = typeElement.GetString();
if (typeName is not ("string" or "number" or "integer" or "boolean"))
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', and 'boolean' are allowed.";
return false;
}
}

return true;
}

/// <summary>
/// Determines whether a JSON Schema <c>type</c> keyword is compatible with <c>x-mcp-header</c>,
/// which per SEP-2243 may only be applied to <c>string</c>, <c>integer</c>, or <c>boolean</c>
/// parameters. A union array (e.g., <c>["string", "null"]</c>) is allowed as long as it contains
/// at least one allowed primitive; <c>"null"</c> is tolerated only as an additional union member.
/// Any other shape (a disallowed type name, a non-string array element, an empty array, or a
/// non-string/non-array value) is treated as incompatible.
/// </summary>
private static bool IsAllowedHeaderType(JsonElement typeElement)
{
switch (typeElement.ValueKind)
{
case JsonValueKind.String:
return IsAllowedPrimitiveTypeName(typeElement.GetString());

case JsonValueKind.Array:
bool hasAllowedPrimitive = false;
foreach (var entry in typeElement.EnumerateArray())
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has non-primitive type '{typeName}'.";
return false;
if (entry.ValueKind != JsonValueKind.String)
{
return false;
}

var entryName = entry.GetString();
if (entryName == "null")
{
continue;
}

if (!IsAllowedPrimitiveTypeName(entryName))
{
return false;
}

hasAllowedPrimitive = true;
}

return hasAllowedPrimitive;

default:
// A "type" that is present but is neither a string nor an array of strings is malformed.
return false;
}
}

private static bool IsAllowedPrimitiveTypeName(string? typeName) =>
typeName is "string" or "integer" or "boolean";

// Valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2:
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
private const string TcharChars = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

#if NET
private static readonly SearchValues<char> s_tcharValues = SearchValues.Create(TcharChars);

/// <summary>
/// Returns <see langword="true"/> if every character in <paramref name="value"/> is a valid
/// HTTP token character (tchar) per RFC 9110 Section 5.6.2.
/// </summary>
private static bool IsValidTcharString(string value) =>
value.AsSpan().IndexOfAnyExcept(s_tcharValues) < 0;

internal static int FindFirstNonTchar(string value) =>
value.AsSpan().IndexOfAnyExcept(s_tcharValues);
#else
// Bitmap for O(1) tchar lookup. All valid chars are in 0x21-0x7E range,
// so two ulongs (128 bits) cover the entire ASCII range.
// _tcharBitmapLo covers chars 0-63, _tcharBitmapHi covers chars 64-127.
private static readonly ulong s_tcharBitmapLo = ComputeBitmapLo();
private static readonly ulong s_tcharBitmapHi = ComputeBitmapHi();

private static ulong ComputeBitmapLo()
{
ulong bitmap = 0;
foreach (char c in TcharChars)
{
if (c < 64)
{
bitmap |= 1UL << c;
}
}
return bitmap;
}

private static ulong ComputeBitmapHi()
{
ulong bitmap = 0;
foreach (char c in TcharChars)
{
if (c >= 64)
{
bitmap |= 1UL << (c - 64);
}
}
return bitmap;
}

private static bool IsTchar(char c)
{
if (c >= 128)
{
return false;
}

return c < 64
? (s_tcharBitmapLo & (1UL << c)) != 0
: (s_tcharBitmapHi & (1UL << (c - 64))) != 0;
}

private static bool IsValidTcharString(string value)
{
foreach (char c in value)
{
if (!IsTchar(c))
{
return false;
}
}
return true;
}

internal static int FindFirstNonTchar(string value)
{
for (int i = 0; i < value.Length; i++)
{
if (!IsTchar(value[i]))
{
return i;
}
}
return -1;
}
#endif
}
14 changes: 11 additions & 3 deletions src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ public static class McpHeaderEncoder
return headerValue;
}

// Check for Base64 wrapper. The spec defines the prefix as lowercase "=?base64?"
// but we match case-insensitively for robustness against non-conforming senders.
if (headerValue.StartsWith(Base64Prefix, StringComparison.OrdinalIgnoreCase) &&
// Check for Base64 wrapper. The spec requires the sentinel markers to be
// case-sensitive and exactly lowercase per SEP-2243.
if (headerValue.StartsWith(Base64Prefix, StringComparison.Ordinal) &&
headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal))
{
var base64Content = headerValue.Substring(
Expand Down Expand Up @@ -221,6 +221,14 @@ private static bool RequiresBase64Encoding(string value)
return true;
}

// Avoid sentinel collision: if the value matches the base64 wrapper pattern,
// it must be encoded to prevent ambiguity during decoding.
if (value.StartsWith(Base64Prefix, StringComparison.Ordinal) &&
value.EndsWith(Base64Suffix, StringComparison.Ordinal))
{
return true;
}

foreach (char c in value)
{
// Valid HTTP header field value characters per SEP: visible ASCII (0x21-0x7E) and space (0x20).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -630,8 +630,8 @@ private static JsonElement AddMcpHeaderExtensions(JsonElement inputSchema, Metho
if (!IsPrimitiveHeaderType(paramType))
{
throw new InvalidOperationException(
$"Parameter '{param.Name}' on method '{method.Name}' has [McpHeader] but is not a primitive type. " +
"Only string, numeric, and boolean types may be annotated with [McpHeader].");
$"Parameter '{param.Name}' on method '{method.Name}' has [McpHeader] but is not a supported type. " +
"Only string, integer, and boolean types may be annotated with [McpHeader].");
}

// Validate case-insensitive uniqueness
Expand Down Expand Up @@ -682,9 +682,6 @@ private static bool IsPrimitiveHeaderType(Type type)
type == typeof(int) ||
type == typeof(uint) ||
type == typeof(long) ||
type == typeof(ulong) ||
type == typeof(float) ||
type == typeof(double) ||
type == typeof(decimal);
type == typeof(ulong);
}
}
Loading
Loading