Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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(
Comment thread
tarekgh marked this conversation as resolved.
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
3 changes: 2 additions & 1 deletion src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ public async ValueTask<IList<McpClientTool>> ListToolsAsync(
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.
// Streamable HTTP clients MUST exclude tools with invalid annotations;
// non-HTTP clients MAY also reject them for safety.
Comment thread
tarekgh marked this conversation as resolved.
Outdated
if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason))
{
ToolRejected?.Invoke(tool, rejectionReason!);
Expand Down
169 changes: 154 additions & 15 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))
Comment thread
tarekgh marked this conversation as resolved.
{
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,29 +163,30 @@ 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 primitive types (integer, string, boolean).
// Parameters with type "number" are not permitted.
if (property.Value.TryGetProperty("type", out var typeElement) &&
typeElement.ValueKind == JsonValueKind.String)
{
Comment thread
tarekgh marked this conversation as resolved.
Outdated
var typeName = typeElement.GetString();
if (typeName is not ("string" or "number" or "integer" or "boolean"))
if (typeName is not ("string" or "integer" or "boolean"))
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has non-primitive type '{typeName}'.";
return false;
Expand All @@ -144,4 +196,91 @@ internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason)

return true;
}

// 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;
}
Comment thread
tarekgh marked this conversation as resolved.

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);
Comment thread
tarekgh marked this conversation as resolved.
Outdated
}
}
27 changes: 13 additions & 14 deletions src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using ModelContextProtocol.Client;

namespace ModelContextProtocol.Server;

/// <summary>
Expand All @@ -10,8 +12,8 @@ namespace ModelContextProtocol.Server;
/// HTTP header named <c>Mcp-Param-{Name}</c>.
/// </para>
/// <para>
/// Only parameters with primitive types (string, number, boolean) may use this attribute.
/// The header name must contain only ASCII characters (0x21-0x7E, excluding space and colon)
/// Only parameters with primitive types (integer, string, boolean) may use this attribute.
/// The header name must match HTTP field-name token syntax (tchar per RFC 9110 Section 5.6.2)
/// and must be case-insensitively unique within the tool's input schema.
/// </para>
/// <para>
Expand All @@ -38,7 +40,7 @@ public sealed class McpHeaderAttribute : Attribute
/// </summary>
/// <param name="name">
/// The name portion of the header. The full header name will be <c>Mcp-Param-{name}</c>.
/// Must contain only ASCII characters (0x21-0x7E, excluding space and colon).
/// Must match HTTP field-name token syntax (tchar per RFC 9110 Section 5.6.2).
/// </param>
/// <exception cref="ArgumentException">
/// The name is null, empty, or contains invalid characters.
Expand All @@ -59,23 +61,20 @@ public McpHeaderAttribute(string name)
public string Name { get; }

/// <summary>
/// Validates that a header name contains only valid characters.
/// Validates that a header name contains only valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2.
/// </summary>
/// <param name="name">The header name to validate.</param>
/// <exception cref="ArgumentException">The name contains invalid characters.</exception>
internal static void ValidateHeaderName(string name)
{
foreach (char c in name)
int idx = McpHeaderExtractor.FindFirstNonTchar(name);
if (idx >= 0)
{
// Valid token characters per RFC 9110: visible ASCII (0x21-0x7E) excluding delimiters.
// Space (0x20) and colon (':') are explicitly prohibited.
if (c < 0x21 || c > 0x7E || c == ':')
{
throw new ArgumentException(
$"Header name contains invalid character '{c}' (0x{(int)c:X2}). " +
"Only ASCII characters (0x21-0x7E) excluding colon are allowed.",
nameof(name));
}
char c = name[idx];
throw new ArgumentException(
$"Header name contains invalid character '{c}' (0x{(int)c:X2}). " +
"Only HTTP token characters (tchar per RFC 9110 Section 5.6.2) are allowed.",
nameof(name));
}
}
}
Loading
Loading