diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index a489cd9e6..ad4930e80 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -716,8 +716,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);
+ }
+
+ ///
+ /// Recursively validates x-mcp-header annotated properties at any nesting depth.
+ ///
+ 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;
@@ -776,10 +809,14 @@ argForMissing is not null &&
if (expectedHeaderValue is not null)
{
var decodedExpected = McpHeaderEncoder.DecodeValue(expectedHeaderValue);
- if (!ValuesMatch(decodedActual, decodedExpected, property.Value))
+ switch (ValuesMatch(decodedActual, decodedExpected, property.Value))
{
- errorMessage = $"Header mismatch: {fullHeaderName} header value does not match body argument '{property.Name}'.";
- return false;
+ case HeaderValueComparison.IntegerOutOfRange:
+ errorMessage = $"Header mismatch: {fullHeaderName} integer value for parameter '{property.Name}' is outside the JavaScript safe integer range (-{MaxSafeInteger} to {MaxSafeInteger}).";
+ return false;
+ case HeaderValueComparison.Mismatch:
+ errorMessage = $"Header mismatch: {fullHeaderName} header value does not match body argument '{property.Name}'.";
+ return false;
}
}
}
@@ -812,50 +849,152 @@ private static bool IsValidHeaderValue(string value) =>
value.AsSpan().IndexOfAnyExcept(s_validHeaderValueChars) < 0;
///
- /// Compares two decoded header values, using numeric comparison for number-typed
- /// parameters to handle cross-SDK representation differences (e.g., "42" vs "42.0").
+ /// The maximum magnitude for an integer that can be represented exactly by an IEEE 754
+ /// double-precision value (2^53 - 1). Per SEP-2243 integer x-mcp-header values MUST be within
+ /// the JavaScript safe integer range (-2^53+1 to 2^53-1).
///
- private static bool ValuesMatch(string? actual, string? expected, System.Text.Json.JsonElement propertySchema)
+ private const long MaxSafeInteger = 9007199254740991L;
+
+ private enum HeaderValueComparison
{
- if (string.Equals(actual, expected, StringComparison.Ordinal))
- {
- return true;
- }
+ Match,
+ Mismatch,
+ IntegerOutOfRange,
+ }
- // 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 &&
- actual is not null && expected is not null)
- {
- var schemaType = typeElement.GetString();
-
- // For "integer" type, prefer exact long comparison to preserve full precision
- // for values beyond double's ~15-17 significant digit limit.
- if (schemaType == "integer" &&
- long.TryParse(actual, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var actualLong) &&
- long.TryParse(expected, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var expectedLong))
+ ///
+ /// Compares two decoded header values. For integer-typed parameters the values are
+ /// compared numerically (so cross-SDK forms such as "42" and "42.0" are treated
+ /// as equal) and validated against the JavaScript safe integer range per SEP-2243.
+ ///
+ private static HeaderValueComparison ValuesMatch(string? actual, string? expected, System.Text.Json.JsonElement propertySchema)
+ {
+ // Per SEP-2243, x-mcp-header may only be applied to integer, string, or boolean parameters.
+ // For "integer" the spec recommends numeric comparison so that representations like "42" and
+ // "42.0" are considered equal, while still requiring values to stay within the safe range.
+ // This must run before the ordinal comparison below so that an invalid integer value is
+ // rejected even when the header and body strings are byte-for-byte identical.
+ if (actual is not null && expected is not null && SchemaTypeIsInteger(propertySchema))
+ {
+ var actualResult = ParseSafeInteger(actual, out long actualValue);
+ var expectedResult = ParseSafeInteger(expected, out long expectedValue);
+
+ // A numeric value outside the safe integer range is always rejected.
+ if (actualResult == SafeIntegerParse.OutOfRange || expectedResult == SafeIntegerParse.OutOfRange)
{
- return actualLong == expectedLong;
+ return HeaderValueComparison.IntegerOutOfRange;
}
- // For "number" type, or "integer" values in decimal format (e.g., cross-SDK "42.0" vs "42"),
- // use double comparison with tolerance.
- if (schemaType is "number" or "integer" &&
- 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) &&
- Math.Abs(actualNum - expectedNum) < 1e-9)
+ if (actualResult == SafeIntegerParse.SafeInteger && expectedResult == SafeIntegerParse.SafeInteger)
+ {
+ return actualValue == expectedValue ? HeaderValueComparison.Match : HeaderValueComparison.Mismatch;
+ }
+
+ // A numeric-but-non-integer value (e.g. "42.5") for an integer-typed parameter is invalid
+ // and must not be allowed to slip through the ordinal comparison just because the header
+ // and body strings happen to be identical.
+ if (actualResult == SafeIntegerParse.NonInteger || expectedResult == SafeIntegerParse.NonInteger)
+ {
+ return HeaderValueComparison.Mismatch;
+ }
+
+ // Otherwise at least one side is not numeric at all (NotNumeric); fall through to the
+ // ordinal comparison below.
+ }
+
+ return string.Equals(actual, expected, StringComparison.Ordinal)
+ ? HeaderValueComparison.Match
+ : HeaderValueComparison.Mismatch;
+ }
+
+ ///
+ /// Determines whether the property schema's type keyword declares an integer type,
+ /// either directly or as a member of a JSON Schema union array (e.g. ["integer", "null"]).
+ ///
+ private static bool SchemaTypeIsInteger(System.Text.Json.JsonElement propertySchema)
+ {
+ if (!propertySchema.TryGetProperty("type", out var typeElement))
+ {
+ return false;
+ }
+
+ switch (typeElement.ValueKind)
+ {
+ case System.Text.Json.JsonValueKind.String:
+ return typeElement.ValueEquals("integer");
+
+ case System.Text.Json.JsonValueKind.Array:
+ foreach (var entry in typeElement.EnumerateArray())
+ {
+ if (entry.ValueKind == System.Text.Json.JsonValueKind.String && entry.ValueEquals("integer"))
+ {
+ return true;
+ }
+ }
+
+ return false;
+
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Classifies how a header/body string parses as a SEP-2243 integer value.
+ ///
+ private enum SafeIntegerParse
+ {
+ /// A whole number within the JavaScript safe integer range.
+ SafeInteger,
+
+ /// A numeric value whose magnitude is outside the safe integer range.
+ OutOfRange,
+
+ /// A numeric value that is not a whole number (e.g. "42.5").
+ NonInteger,
+
+ /// The value is not a numeric literal at all.
+ NotNumeric,
+ }
+
+ ///
+ /// Parses a header/body value as a whole integer within the JavaScript safe integer range.
+ /// Decimal and exponent forms whose fractional part is zero (e.g. "42.0", "4.2e1")
+ /// are accepted.
+ /// inspects the actual digits (so it rejects non-integers such as "42.5" without rounding)
+ /// and fails fast on overflow (so a huge literal such as "1e1000000" cannot allocate a
+ /// large number).
+ ///
+ private static SafeIntegerParse ParseSafeInteger(string text, out long value)
+ {
+ value = 0;
+
+ const System.Globalization.NumberStyles Styles =
+ System.Globalization.NumberStyles.AllowLeadingSign |
+ System.Globalization.NumberStyles.AllowDecimalPoint |
+ System.Globalization.NumberStyles.AllowExponent;
+
+ if (long.TryParse(text, Styles, System.Globalization.CultureInfo.InvariantCulture, out long parsed))
+ {
+ if (parsed < -MaxSafeInteger || parsed > MaxSafeInteger)
{
- return true;
+ return SafeIntegerParse.OutOfRange;
}
+
+ value = parsed;
+ return SafeIntegerParse.SafeInteger;
+ }
+
+ // The value is not representable as a 64-bit integer. Use double only as an order-of-magnitude
+ // gate to distinguish a numeric literal beyond the safe range (e.g. "1e100") from a numeric but
+ // non-integer value (e.g. "42.5"). double's loss of precision is irrelevant for this magnitude
+ // comparison because every in-range value was already handled exactly by long.TryParse above.
+ if (double.TryParse(text, Styles, System.Globalization.CultureInfo.InvariantCulture, out double d))
+ {
+ return System.Math.Abs(d) > MaxSafeInteger ? SafeIntegerParse.OutOfRange : SafeIntegerParse.NonInteger;
}
- return false;
+ return SafeIntegerParse.NotNumeric;
}
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index f04c32ffd..79d4b0a02 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -186,8 +186,10 @@ public async ValueTask> 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!);
diff --git a/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
index 349168f04..99fe5462a 100644
--- a/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
+++ b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
@@ -1,5 +1,9 @@
using System.Net.Http.Headers;
+using System.Globalization;
using System.Text.Json;
+#if NET
+using System.Buffers;
+#endif
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
@@ -36,10 +40,35 @@ public static void AddParameterHeaders(
return;
}
+ AddParameterHeadersFromProperties(headers, properties, arguments.Value);
+ }
+
+ ///
+ /// Recursively extracts parameter values from properties at any nesting depth
+ /// and adds them as HTTP headers.
+ ///
+ 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;
}
@@ -51,7 +80,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;
}
@@ -62,7 +91,7 @@ public static void AddParameterHeaders(
continue;
}
- var headerValue = McpHeaderEncoder.ConvertToHeaderValue(argValue);
+ var headerValue = ConvertArgumentToHeaderValue(property.Value, property.Name, argValue);
if (headerValue is not null)
{
headers.Add($"{McpHttpHeaders.ParamPrefix}{headerName}", headerValue);
@@ -70,6 +99,92 @@ public static void AddParameterHeaders(
}
}
+ // The maximum magnitude for an integer that can be represented exactly by an IEEE 754
+ // double-precision value (2^53 - 1). Per SEP-2243 integer x-mcp-header values MUST be within
+ // the JavaScript safe integer range (-2^53+1 to 2^53-1) so intermediaries can compare them.
+ private const long MaxSafeInteger = 9007199254740991L;
+
+ ///
+ /// Converts an argument value to its encoded header representation. When the property schema
+ /// declares an integer type, the value is canonicalized to its decimal string form
+ /// (e.g. a body value of 42.0 is emitted as "42") per SEP-2243.
+ ///
+ private static string? ConvertArgumentToHeaderValue(JsonElement propertySchema, string propertyName, JsonElement argValue)
+ {
+ if (argValue.ValueKind == JsonValueKind.Number && SchemaTypeIsInteger(propertySchema))
+ {
+ if (!TryGetCanonicalSafeInteger(argValue, out long canonical))
+ {
+ throw new McpException(
+ $"The value '{argValue.GetRawText()}' for parameter '{propertyName}' annotated with x-mcp-header " +
+ $"is not a whole number within the JavaScript safe integer range (-{MaxSafeInteger} to {MaxSafeInteger}).");
+ }
+
+ return McpHeaderEncoder.EncodeValue(canonical);
+ }
+
+ return McpHeaderEncoder.ConvertToHeaderValue(argValue);
+ }
+
+ ///
+ /// Determines whether the property schema's type keyword declares an integer type,
+ /// either directly or as a member of a JSON Schema union array (e.g. ["integer", "null"]).
+ ///
+ private static bool SchemaTypeIsInteger(JsonElement propertySchema)
+ {
+ if (!propertySchema.TryGetProperty("type", out var typeElement))
+ {
+ return false;
+ }
+
+ switch (typeElement.ValueKind)
+ {
+ case JsonValueKind.String:
+ return typeElement.ValueEquals("integer");
+
+ case JsonValueKind.Array:
+ foreach (var entry in typeElement.EnumerateArray())
+ {
+ if (entry.ValueKind == JsonValueKind.String && entry.ValueEquals("integer"))
+ {
+ return true;
+ }
+ }
+
+ return false;
+
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Attempts to interpret a JSON number as a whole integer within the JavaScript safe integer
+ /// range. Decimal and exponent forms whose fractional part is zero (e.g. 42.0, 4.2e1)
+ /// are accepted; non-integers and out-of-range values are rejected.
+ ///
+ private static bool TryGetCanonicalSafeInteger(JsonElement element, out long value)
+ {
+ if (element.TryGetInt64(out value))
+ {
+ return value >= -MaxSafeInteger && value <= MaxSafeInteger;
+ }
+
+ // Handle decimal/exponent representations of whole numbers such as "42.0" or "4.2e1".
+ // long.TryParse inspects the actual digits (so non-integers such as "42.5" are rejected
+ // without rounding) and fails fast on overflow (no large-number allocation).
+ const NumberStyles Styles = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent;
+ if (long.TryParse(element.GetRawText(), Styles, CultureInfo.InvariantCulture, out long parsed) &&
+ parsed >= -MaxSafeInteger && parsed <= MaxSafeInteger)
+ {
+ value = parsed;
+ return true;
+ }
+
+ value = 0;
+ return false;
+ }
+
///
/// Validates a tool's inputSchema for valid x-mcp-header annotations.
/// Returns if the tool is valid; with a reason if it should be rejected.
@@ -86,12 +201,35 @@ internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason)
}
var headerNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ return ValidateProperties(tool, properties, headerNames, out rejectionReason);
+ }
+
+ ///
+ /// Recursively validates properties at any nesting depth for valid x-mcp-header annotations.
+ ///
+ private static bool ValidateProperties(Tool tool, JsonElement properties, HashSet 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;
}
@@ -112,36 +250,154 @@ 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;
+ }
+
+ ///
+ /// Determines whether a JSON Schema type keyword is compatible with x-mcp-header,
+ /// which per SEP-2243 may only be applied to string, integer, or boolean
+ /// parameters. A union array (e.g., ["string", "null"]) is allowed as long as it contains
+ /// at least one allowed primitive; "null" 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.
+ ///
+ 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 s_tcharValues = SearchValues.Create(TcharChars);
+
+ 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;
+ }
- return true;
+ 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;
+ }
+
+ internal static int FindFirstNonTchar(string value)
+ {
+ for (int i = 0; i < value.Length; i++)
+ {
+ if (!IsTchar(value[i]))
+ {
+ return i;
+ }
+ }
+ return -1;
}
+#endif
}
diff --git a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
index c31e93388..9366063e4 100644
--- a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
+++ b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
@@ -14,12 +14,18 @@ namespace ModelContextProtocol.Protocol;
/// including Base64 encoding for values that cannot be safely transmitted as plain text.
///
///
+/// Per SEP-2243 only primitive parameter types are supported: string, integer, and
+/// boolean. The JSON Schema number type is not permitted, and integer values must be
+/// within the JavaScript safe integer range (−2^53+1 to 2^53−1).
+///
+///
/// Encoding rules:
///
/// - Plain ASCII values (0x20-0x7E): sent as-is
/// - Values with leading/trailing whitespace: Base64 encoded with =?base64?{value}?= wrapper
/// - Non-ASCII characters: Base64 encoded
/// - Control characters: Base64 encoded
+/// - Plain ASCII values that themselves match the =?base64?...?= sentinel pattern: Base64 encoded to avoid ambiguity
///
///
///
@@ -28,6 +34,10 @@ public static class McpHeaderEncoder
private const string Base64Prefix = "=?base64?";
private const string Base64Suffix = "?=";
+ // Strict UTF-8 decoder that throws on invalid byte sequences rather than silently substituting
+ // U+FFFD replacement characters, so a malformed Base64-wrapped header value is rejected.
+ private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+
///
/// Encodes a string parameter value for use in an HTTP header.
///
@@ -58,26 +68,19 @@ public static class McpHeaderEncoder
public static string EncodeValue(bool value) => value ? "true" : "false";
///
- /// Encodes a numeric parameter value for use in an HTTP header.
+ /// Encodes an integer parameter value for use in an HTTP header.
///
- /// The numeric value to encode.
+ /// The integer value to encode.
/// The decimal string representation of the value.
public static string EncodeValue(long value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
- ///
- /// Encodes a numeric parameter value for use in an HTTP header.
- ///
- /// The numeric value to encode.
- /// The decimal string representation of the value.
- public static string EncodeValue(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
-
///
/// Encodes a parameter value for use in an HTTP header.
///
- /// The value to encode. Supported types are string, numeric types, and boolean.
+ /// The value to encode. Supported types are string, integer, and boolean.
///
/// The encoded header value, or if the value is
- /// or is not a supported type (string, numeric, or boolean).
+ /// or is not a supported type (string, integer, or boolean).
///
public static string? EncodeValue(object? value)
{
@@ -121,9 +124,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(
@@ -133,12 +136,16 @@ public static class McpHeaderEncoder
try
{
var bytes = Convert.FromBase64String(base64Content);
- return Encoding.UTF8.GetString(bytes);
+ return s_strictUtf8.GetString(bytes);
}
catch (FormatException)
{
return null;
}
+ catch (DecoderFallbackException)
+ {
+ return null;
+ }
}
return headerValue;
@@ -201,9 +208,6 @@ public static class McpHeaderEncoder
uint n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
long n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
ulong n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
- float n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
- double n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
- decimal n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
_ => null
};
}
@@ -221,6 +225,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).
diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
index 961344c2c..2b39beefe 100644
--- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
+++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
@@ -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
@@ -673,6 +673,12 @@ private static JsonElement AddMcpHeaderExtensions(JsonElement inputSchema, Metho
private static bool IsPrimitiveHeaderType(Type type)
{
+ // Per SEP-2243, x-mcp-header may only be applied to integer, string, or boolean parameters,
+ // and integer values must stay within the JavaScript safe integer range (-2^53+1 to 2^53-1).
+ // ulong is excluded because its upper range (above long.MaxValue) cannot be represented as a
+ // signed integer and the bulk of its domain falls outside the safe range. Remaining integer
+ // types are allowed here; long values are additionally range-checked per value when emitted
+ // (client) and validated (server).
return type == typeof(string) ||
type == typeof(bool) ||
type == typeof(byte) ||
@@ -681,10 +687,6 @@ private static bool IsPrimitiveHeaderType(Type type)
type == typeof(ushort) ||
type == typeof(int) ||
type == typeof(uint) ||
- type == typeof(long) ||
- type == typeof(ulong) ||
- type == typeof(float) ||
- type == typeof(double) ||
- type == typeof(decimal);
+ type == typeof(long);
}
}
\ No newline at end of file
diff --git a/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs b/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
index 516b73580..d81523a56 100644
--- a/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
+++ b/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
@@ -1,3 +1,5 @@
+using ModelContextProtocol.Client;
+
namespace ModelContextProtocol.Server;
///
@@ -10,8 +12,8 @@ namespace ModelContextProtocol.Server;
/// HTTP header named Mcp-Param-{Name}.
///
///
-/// 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.
///
///
@@ -38,7 +40,7 @@ public sealed class McpHeaderAttribute : Attribute
///
///
/// The name portion of the header. The full header name will be Mcp-Param-{name}.
- /// 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).
///
///
/// The name is null, empty, or contains invalid characters.
@@ -59,23 +61,20 @@ public McpHeaderAttribute(string name)
public string Name { get; }
///
- /// 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.
///
/// The header name to validate.
/// The name contains invalid characters.
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));
}
}
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs
index 7ab98f38e..852fb122e 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs
@@ -169,6 +169,78 @@ public async Task AddKnownTools_ThenCallTool_SendsMcpParamHeaders_WithoutListToo
Assert.Equal("42", headers["Mcp-Param-Priority"]);
}
+ [Theory]
+ [InlineData("42.0", "42")] // decimal body form canonicalized
+ [InlineData("-7.00", "-7")] // trailing zeros canonicalized
+ [InlineData("-0.0", "0")] // negative zero canonicalized
+ [InlineData("4.2e1", "42")] // exponent body form canonicalized
+ [InlineData("9007199254740991", "9007199254740991")] // max safe integer preserved exactly
+ [InlineData("-9007199254740991", "-9007199254740991")] // min safe integer preserved exactly
+ public async Task CallTool_EmitsCanonicalIntegerHeader(string bodyValue, string expectedHeader)
+ {
+ await StartAsync();
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new("http://localhost:5000/mcp"),
+ TransportMode = HttpTransportMode.StreamableHttp,
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ client.AddKnownTools([CreateToolWithHeaders()]);
+
+ // Pass the raw JSON number so the body retains the exact form under test.
+ var result = await client.CallToolAsync(
+ "my_tool",
+ new Dictionary
+ {
+ ["region"] = "us-west-2",
+ ["priority"] = JsonDocument.Parse(bodyValue).RootElement,
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result);
+ var headers = _capturedHeaders.Values.First();
+ Assert.Equal(expectedHeader, headers["Mcp-Param-Priority"]);
+ }
+
+ [Theory]
+ [InlineData("9007199254740993")] // 2^53 + 1, above the safe range
+ [InlineData("-9007199254740993")] // -(2^53 + 1), below the safe range
+ [InlineData("42.5")] // not a whole number
+ [InlineData("12e-1")] // 1.2 in exponent form, not a whole number
+ [InlineData("42.0000000000000000000000000001")] // high-precision fraction (decimal would round this to 42)
+ public async Task CallTool_ThrowsForInvalidIntegerHeaderValue(string bodyValue)
+ {
+ await StartAsync();
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new("http://localhost:5000/mcp"),
+ TransportMode = HttpTransportMode.StreamableHttp,
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ client.AddKnownTools([CreateToolWithHeaders()]);
+
+ // Values outside the JavaScript safe integer range (or non-integral) must be rejected
+ // before the request is sent.
+ await Assert.ThrowsAsync(async () => await client.CallToolAsync(
+ "my_tool",
+ new Dictionary
+ {
+ ["region"] = "us-west-2",
+ ["priority"] = JsonDocument.Parse(bodyValue).RootElement,
+ },
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Empty(_capturedHeaders);
+ }
+
[Fact]
public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders()
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs
index c3232b56a..b950553f5 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs
@@ -54,7 +54,7 @@ public async ValueTask DisposeAsync()
// Create a tool with x-mcp-header annotations in the schema.
// We set InputSchema directly because TransformSchemaNode doesn't provide
// property-level path context for lambda-based tool creation.
- private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool()];
+ private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool(), CreateUnionHeaderTestTool()];
private static readonly JsonSerializerOptions s_reflectionOptions = new()
{
@@ -65,7 +65,7 @@ private static McpServerTool CreateHeaderTestTool()
{
var tool = McpServerTool.Create(
[McpServerTool(Name = "header_test")]
- static (string region, int priority, bool verbose, string emptyVal) =>
+ static (string region, long priority, bool verbose, string emptyVal) =>
$"region={region},priority={priority},verbose={verbose},empty={emptyVal}",
new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions });
@@ -86,8 +86,93 @@ private static McpServerTool CreateHeaderTestTool()
return tool;
}
+ // A tool whose integer header parameter uses a JSON Schema union type (["integer", "null"]).
+ private static McpServerTool CreateUnionHeaderTestTool()
+ {
+ var tool = McpServerTool.Create(
+ [McpServerTool(Name = "union_test")]
+ static (long priority) => $"priority={priority}",
+ new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions });
+
+ using var doc = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "properties": {
+ "priority": { "type": ["integer", "null"], "x-mcp-header": "Priority" }
+ },
+ "required": ["priority"]
+ }
+ """);
+ tool.ProtocolTool.InputSchema = doc.RootElement.Clone();
+
+ return tool;
+ }
+
#region Server-side validation tests
+ [Fact]
+ public async Task Server_AcceptsUnionIntegerCanonicalForm()
+ {
+ await StartAsync();
+ await InitializeWithDraftVersionAsync();
+
+ // Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body
+ // carries the decimal form 42.0. The server must treat the union type as integer and match.
+ var callJson = CallTool("union_test", """{"priority":42.0}""");
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
+ request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "union_test");
+ request.Headers.Add("Mcp-Param-Priority", "42");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Server_RejectsUnionIntegerOutsideSafeRange()
+ {
+ await StartAsync();
+ await InitializeWithDraftVersionAsync();
+
+ var callJson = CallTool("union_test", """{"priority":9007199254740993}""");
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
+ request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "union_test");
+ request.Headers.Add("Mcp-Param-Priority", "9007199254740993");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Server_AcceptsExponentBodyMatchingDecimalHeader()
+ {
+ await StartAsync();
+ await InitializeWithDraftVersionAsync();
+
+ // Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100".
+ var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}""");
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
+ request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "header_test");
+ request.Headers.Add("Mcp-Param-Region", "test");
+ request.Headers.Add("Mcp-Param-Priority", "100");
+ request.Headers.Add("Mcp-Param-Verbose", "false");
+ request.Headers.Add("Mcp-Param-EmptyVal", "");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
[Fact]
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
{
@@ -209,15 +294,15 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars()
}
[Fact]
- public async Task Server_AcceptsLargeIntegerWithFullPrecision()
+ public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
- // Use a large integer that would lose precision if converted through double
- // 2^53 + 1 = 9007199254740993 (cannot be represented exactly as double)
- const long largeInt = 9007199254740993L;
- var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{largeInt}},"verbose":false,"emptyVal":""}""");
+ // The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without
+ // losing precision through a double conversion.
+ const long maxSafeInt = 9007199254740991L;
+ var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{maxSafeInt}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
@@ -225,7 +310,7 @@ public async Task Server_AcceptsLargeIntegerWithFullPrecision()
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
- request.Headers.Add("Mcp-Param-Priority", largeInt.ToString());
+ request.Headers.Add("Mcp-Param-Priority", maxSafeInt.ToString());
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
@@ -234,15 +319,47 @@ public async Task Server_AcceptsLargeIntegerWithFullPrecision()
}
[Theory]
- [InlineData("42", 42)] // "42" header vs 42 body → exact integer match
- [InlineData("42.0", 42)] // "42.0" header vs 42 body → numeric equivalence
- [InlineData("42", 42.0)] // "42" header vs 42.0 body → numeric equivalence
- public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, double bodyValue)
+ [InlineData("9007199254740993")] // 2^53 + 1, just outside the safe range
+ [InlineData("-9007199254740993")] // -(2^53 + 1), just outside the safe range
+ [InlineData("100000000000000000000000000000000000000")] // far beyond decimal range
+ [InlineData("1e100")] // exponent form far beyond the safe range
+ public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue)
+ {
+ await StartAsync();
+ await InitializeWithDraftVersionAsync();
+
+ // Per SEP-2243 integer values MUST be within the JavaScript safe integer range.
+ // A matching header and body that are both outside the range must still be rejected.
+ var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{outOfRangeValue}},"verbose":false,"emptyVal":""}""");
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
+ request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "header_test");
+ request.Headers.Add("Mcp-Param-Region", "test");
+ request.Headers.Add("Mcp-Param-Priority", outOfRangeValue);
+ request.Headers.Add("Mcp-Param-Verbose", "false");
+ request.Headers.Add("Mcp-Param-EmptyVal", "");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("42", "42")] // "42" header vs 42 body -> exact integer match
+ [InlineData("42.0", "42")] // "42.0" header vs 42 body -> numeric equivalence
+ [InlineData("42", "42.0")] // "42" header vs 42.0 body (decimal form from another SDK) -> numeric equivalence
+ [InlineData("42", "4.2e1")] // "42" header vs 4.2e1 body (exponent form) -> numeric equivalence
+ [InlineData("420e-1", "42")] // "420e-1" header vs 42 body -> numeric equivalence
+ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
- var callJson = CallTool("header_test", $$$"""{"region":"test","priority":{{{bodyValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}}},"verbose":false,"emptyVal":""}""");
+ // bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and
+ // "4.2e1" are preserved in the body exactly as another SDK might serialize them.
+ var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{bodyValue}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
@@ -258,6 +375,34 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
+ [Theory]
+ [InlineData("42.5")] // fractional value for an integer parameter
+ [InlineData("12e-1")] // 1.2 in exponent form
+ [InlineData("42.0000000000000000000000000001")] // high-precision fraction that decimal would round to 42
+ public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue)
+ {
+ await StartAsync();
+ await InitializeWithDraftVersionAsync();
+
+ // For an integer-typed parameter a non-whole numeric value is invalid and must be rejected
+ // even when the header and body strings are byte-for-byte identical (it must not slip through
+ // the ordinal comparison).
+ var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{nonIntegerValue}},"verbose":false,"emptyVal":""}""");
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
+ request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "header_test");
+ request.Headers.Add("Mcp-Param-Region", "test");
+ request.Headers.Add("Mcp-Param-Priority", nonIntegerValue);
+ request.Headers.Add("Mcp-Param-Verbose", "false");
+ request.Headers.Add("Mcp-Param-EmptyVal", "");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
[Fact]
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
{
diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs
index 51db214ea..26de71b45 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs
@@ -59,10 +59,9 @@ public void EncodeValue_Boolean_ConvertsToLowercase(bool input, string expected)
[Theory]
[InlineData(42, "42")]
- [InlineData(3.14, "3.14")]
[InlineData(0, "0")]
[InlineData(-1, "-1")]
- public void EncodeValue_Number_ConvertsToString(object input, string expected)
+ public void EncodeValue_Integer_ConvertsToString(object input, string expected)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.Equal(expected, result);
@@ -106,10 +105,12 @@ public void DecodeValue_ValidBase64_Decodes()
}
[Fact]
- public void DecodeValue_CaseInsensitivePrefix_Decodes()
+ public void DecodeValue_CaseSensitivePrefix_ReturnsLiteralValue()
{
+ // Per SEP-2243: sentinel markers are case-sensitive and MUST appear exactly as shown (lowercase).
+ // An uppercase prefix should NOT be decoded as base64.
var result = McpHeaderEncoder.DecodeValue("=?BASE64?SGVsbG8=?=");
- Assert.Equal("Hello", result);
+ Assert.Equal("=?BASE64?SGVsbG8=?=", result);
}
[Fact]
@@ -119,6 +120,15 @@ public void DecodeValue_InvalidBase64_ReturnsNull()
Assert.Null(result);
}
+ [Fact]
+ public void DecodeValue_ValidBase64ButInvalidUtf8_ReturnsNull()
+ {
+ // "//4=" is valid Base64 that decodes to the bytes 0xFF 0xFE, which are not valid UTF-8.
+ // A strict decoder must reject this rather than substituting U+FFFD replacement characters.
+ var result = McpHeaderEncoder.DecodeValue("=?base64?//4=?=");
+ Assert.Null(result);
+ }
+
[Fact]
public void DecodeValue_MissingPrefix_ReturnsLiteralValue()
{
@@ -160,4 +170,34 @@ public void EncodeValue_EmbeddedTab_Base64Encodes()
var decoded = McpHeaderEncoder.DecodeValue(result);
Assert.Equal("col1\tcol2", decoded);
}
+
+ [Theory]
+ [InlineData("=?base64?literal?=")]
+ [InlineData("=?base64?SGVsbG8=?=")]
+ [InlineData("=?base64??=")]
+ public void EncodeValue_SentinelCollision_Base64Encodes(string input)
+ {
+ var result = McpHeaderEncoder.EncodeValue(input);
+ Assert.NotNull(result);
+ Assert.StartsWith("=?base64?", result);
+ Assert.EndsWith("?=", result);
+
+ // The encoded value must be different from the input to avoid ambiguity
+ Assert.NotEqual(input, result);
+
+ // Verify round-trip: decode must recover the original literal value
+ var decoded = McpHeaderEncoder.DecodeValue(result);
+ Assert.Equal(input, decoded);
+ }
+
+ [Theory]
+ [InlineData("=?BASE64?literal?=")] // Case-sensitive: uppercase prefix does not match sentinel
+ [InlineData("=?base64?start")] // Missing suffix: no sentinel match
+ [InlineData("end?=")] // Missing prefix: no sentinel match
+ [InlineData("plain-text")] // No sentinel pattern
+ public void EncodeValue_NonSentinelPattern_NotBase64Encoded(string input)
+ {
+ var result = McpHeaderEncoder.EncodeValue(input);
+ Assert.Equal(input, result);
+ }
}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs
new file mode 100644
index 000000000..ff3916d2a
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs
@@ -0,0 +1,218 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Client;
+
+///
+/// Tests for SEP-2243 x-mcp-header validation changes:
+/// - RFC 9110 tchar validation for header names
+/// - "number" type rejection (only integer/string/boolean allowed)
+/// - Nested property support for x-mcp-header annotations
+///
+public class McpHeaderExtractorValidationTests : ClientServerTestBase
+{
+ public McpHeaderExtractorValidationTests(ITestOutputHelper outputHelper)
+ : base(outputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ // Valid baseline tool
+ mcpServerBuilder.WithTools([McpServerTool.Create(
+ (string input) => $"echo {input}",
+ new() { Name = "ValidTool" })]);
+
+ // Tool with "number" type (should be rejected per updated SEP-2243)
+ var numberTool = McpServerTool.Create((string x) => x, new() { Name = "NumberTypeTool" });
+ numberTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "value": { "type": "number", "x-mcp-header": "Value" } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([numberTool]);
+
+ // Tool with "integer" type (should be accepted)
+ var integerTool = McpServerTool.Create((string x) => x, new() { Name = "IntegerTypeTool" });
+ integerTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "count": { "type": "integer", "x-mcp-header": "Count" } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([integerTool]);
+
+ // Tool with non-tchar header name (should be rejected)
+ var nonTcharTool = McpServerTool.Create((string x) => x, new() { Name = "BadTcharTool" });
+ nonTcharTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Region(1)" } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([nonTcharTool]);
+
+ // Tool with valid nested x-mcp-header
+ var nestedValidTool = McpServerTool.Create((string x) => x, new() { Name = "NestedValidTool" });
+ nestedValidTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "config": { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([nestedValidTool]);
+
+ // Tool with invalid nested x-mcp-header (colon in header name)
+ var nestedInvalidTool = McpServerTool.Create((string x) => x, new() { Name = "NestedInvalidTool" });
+ nestedInvalidTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "config": { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Invalid:Header" } } } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([nestedInvalidTool]);
+
+ // Tool with duplicate header names across nesting levels
+ var duplicateTool = McpServerTool.Create((string x) => x, new() { Name = "DuplicateHeaderTool" });
+ duplicateTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "topRegion": { "type": "string", "x-mcp-header": "Region" }, "nested": { "type": "object", "properties": { "innerRegion": { "type": "string", "x-mcp-header": "region" } } } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([duplicateTool]);
+
+ // Tool with nested "number" type (should be rejected)
+ var nestedNumberTool = McpServerTool.Create((string x) => x, new() { Name = "NestedNumberTool" });
+ nestedNumberTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "config": { "type": "object", "properties": { "threshold": { "type": "number", "x-mcp-header": "Threshold" } } } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([nestedNumberTool]);
+
+ // Tool with a nullable union type ["string", "null"] (should be accepted)
+ var nullableUnionTool = McpServerTool.Create((string x) => x, new() { Name = "NullableUnionTool" });
+ nullableUnionTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "region": { "type": ["string", "null"], "x-mcp-header": "Region" } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([nullableUnionTool]);
+
+ // Tool with a union type containing a disallowed type ["number", "null"] (should be rejected)
+ var numberUnionTool = McpServerTool.Create((string x) => x, new() { Name = "NumberUnionTool" });
+ numberUnionTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "value": { "type": ["number", "null"], "x-mcp-header": "Value" } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([numberUnionTool]);
+
+ // Tool with a "null"-only union type (should be rejected: no allowed primitive present)
+ var nullOnlyTool = McpServerTool.Create((string x) => x, new() { Name = "NullOnlyTool" });
+ nullOnlyTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "value": { "type": ["null"], "x-mcp-header": "Value" } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([nullOnlyTool]);
+
+ // Tool whose annotated property omits "type" (should be accepted: type is unknown, not invalid)
+ var missingTypeTool = McpServerTool.Create((string x) => x, new() { Name = "MissingTypeTool" });
+ missingTypeTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
+ { "type": "object", "properties": { "region": { "x-mcp-header": "Region" } } }
+ """).RootElement.Clone();
+ mcpServerBuilder.WithTools([missingTypeTool]);
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NumberType_ExcludesTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "ValidTool");
+ Assert.DoesNotContain(tools, t => t.Name == "NumberTypeTool");
+
+ Assert.Contains(MockLoggerProvider.LogMessages, log =>
+ log.LogLevel == LogLevel.Warning &&
+ log.Message.Contains("NumberTypeTool") &&
+ log.Message.Contains("excluded"));
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_IntegerType_AcceptsTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "IntegerTypeTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NonTcharHeaderName_ExcludesTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "ValidTool");
+ Assert.DoesNotContain(tools, t => t.Name == "BadTcharTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NestedValidHeader_AcceptsTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "NestedValidTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NestedInvalidHeader_ExcludesTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "ValidTool");
+ Assert.DoesNotContain(tools, t => t.Name == "NestedInvalidTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NestedDuplicateHeaders_ExcludesTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "ValidTool");
+ Assert.DoesNotContain(tools, t => t.Name == "DuplicateHeaderTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NestedNumberType_ExcludesTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "ValidTool");
+ Assert.DoesNotContain(tools, t => t.Name == "NestedNumberTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NullableUnionType_AcceptsTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "NullableUnionTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NumberUnionType_ExcludesTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "ValidTool");
+ Assert.DoesNotContain(tools, t => t.Name == "NumberUnionTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_NullOnlyUnionType_ExcludesTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "ValidTool");
+ Assert.DoesNotContain(tools, t => t.Name == "NullOnlyTool");
+ }
+
+ [Fact]
+ public async Task ListToolsAsync_MissingType_AcceptsTool()
+ {
+ await using var client = await CreateMcpClientForServer();
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Contains(tools, t => t.Name == "MissingTypeTool");
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs
index 694869af9..85374a589 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs
@@ -9,6 +9,9 @@ public class McpHeaderAttributeTests
[InlineData("TenantId")]
[InlineData("Priority")]
[InlineData("X-Custom")]
+ [InlineData("x!header")]
+ [InlineData("x#header")]
+ [InlineData("x~header")]
public void Constructor_ValidHeaderName_Succeeds(string name)
{
var attr = new McpHeaderAttribute(name);
@@ -27,6 +30,23 @@ public void Constructor_NameWithColon_Throws()
Assert.Throws(() => new McpHeaderAttribute("Region:Primary"));
}
+ [Theory]
+ [InlineData("Region(1)")]
+ [InlineData("path/to")]
+ [InlineData("key=value")]
+ [InlineData("name@host")]
+ [InlineData("with,comma")]
+ [InlineData("with;semi")]
+ [InlineData("with[bracket")]
+ [InlineData("with{brace")]
+ [InlineData("with\"quote")]
+ [InlineData("with\\backslash")]
+ [InlineData("with?question")]
+ public void Constructor_NonTcharCharacter_Throws(string name)
+ {
+ Assert.Throws(() => new McpHeaderAttribute(name));
+ }
+
[Fact]
public void Constructor_NullName_Throws()
{
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
index a283bf18c..200722276 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
@@ -1221,6 +1221,15 @@ public void Create_WithMcpHeaderOnNonPrimitiveType_ThrowsInvalidOperationExcepti
McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNonPrimitiveHeader))!));
}
+ [Fact]
+ public void Create_WithMcpHeaderOnUInt64Type_ThrowsInvalidOperationException()
+ {
+ // ulong is excluded per SEP-2243 because its domain extends beyond the JavaScript safe
+ // integer range (and beyond long), so it cannot be represented as a signed integer header.
+ Assert.Throws(() =>
+ McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithUInt64Header))!));
+ }
+
[Fact]
public void Create_WithMcpHeaderOnNumericType_AddsExtension()
{
@@ -1308,5 +1317,10 @@ public static string ToolWithNullableHeader(
[McpServerTool]
public static string ToolWithoutHeaders(string region, string query)
=> "result";
+
+ [McpServerTool]
+ public static string ToolWithUInt64Header(
+ [McpHeader("Count")] ulong count)
+ => "result";
}
}