Skip to content
Merged
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
213 changes: 176 additions & 37 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <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 Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -812,50 +849,152 @@ private static bool IsValidHeaderValue(string value) =>
value.AsSpan().IndexOfAnyExcept(s_validHeaderValueChars) < 0;

/// <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").
/// 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).
/// </summary>
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))
/// <summary>
/// Compares two decoded header values. For <c>integer</c>-typed parameters the values are
/// compared numerically (so cross-SDK forms such as <c>"42"</c> and <c>"42.0"</c> are treated
/// as equal) and validated against the JavaScript safe integer range per SEP-2243.
/// </summary>
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;
}

/// <summary>
/// Determines whether the property schema's <c>type</c> keyword declares an <c>integer</c> type,
/// either directly or as a member of a JSON Schema union array (e.g. <c>["integer", "null"]</c>).
/// </summary>
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;
}
}

/// <summary>
/// Classifies how a header/body string parses as a SEP-2243 integer value.
/// </summary>
private enum SafeIntegerParse
{
/// <summary>A whole number within the JavaScript safe integer range.</summary>
SafeInteger,

/// <summary>A numeric value whose magnitude is outside the safe integer range.</summary>
OutOfRange,

/// <summary>A numeric value that is not a whole number (e.g. <c>"42.5"</c>).</summary>
NonInteger,

/// <summary>The value is not a numeric literal at all.</summary>
NotNumeric,
}

/// <summary>
/// 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. <c>"42.0"</c>, <c>"4.2e1"</c>)
/// are accepted. <see cref="long.TryParse(string, System.Globalization.NumberStyles, IFormatProvider, out long)"/>
/// inspects the actual digits (so it rejects non-integers such as <c>"42.5"</c> without rounding)
/// and fails fast on overflow (so a huge literal such as <c>"1e1000000"</c> cannot allocate a
/// large number).
/// </summary>
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)
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
Loading