Align x-mcp-header implementation with SEP-2243 spec clarifications (#1619)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
719eade5e0
commit
ed19286a08
12 changed files with 1035 additions and 116 deletions
|
|
@ -716,8 +716,41 @@ internal sealed class StreamableHttpHandler(
|
||||||
|
|
||||||
// Check that every x-mcp-header annotated parameter has a corresponding header,
|
// 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.
|
// 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())
|
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))
|
if (!property.Value.TryGetProperty("x-mcp-header", out var headerNameElement))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -776,8 +809,12 @@ internal sealed class StreamableHttpHandler(
|
||||||
if (expectedHeaderValue is not null)
|
if (expectedHeaderValue is not null)
|
||||||
{
|
{
|
||||||
var decodedExpected = McpHeaderEncoder.DecodeValue(expectedHeaderValue);
|
var decodedExpected = McpHeaderEncoder.DecodeValue(expectedHeaderValue);
|
||||||
if (!ValuesMatch(decodedActual, decodedExpected, property.Value))
|
switch (ValuesMatch(decodedActual, decodedExpected, property.Value))
|
||||||
{
|
{
|
||||||
|
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}'.";
|
errorMessage = $"Header mismatch: {fullHeaderName} header value does not match body argument '{property.Name}'.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -812,50 +849,152 @@ internal sealed class StreamableHttpHandler(
|
||||||
value.AsSpan().IndexOfAnyExcept(s_validHeaderValueChars) < 0;
|
value.AsSpan().IndexOfAnyExcept(s_validHeaderValueChars) < 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares two decoded header values, using numeric comparison for number-typed
|
/// The maximum magnitude for an integer that can be represented exactly by an IEEE 754
|
||||||
/// parameters to handle cross-SDK representation differences (e.g., "42" vs "42.0").
|
/// 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>
|
/// </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))
|
Match,
|
||||||
{
|
Mismatch,
|
||||||
return true;
|
IntegerOutOfRange,
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON Schema defines two numeric types: "number" (any numeric value including
|
/// <summary>
|
||||||
// decimals like 3.14) and "integer" (whole numbers only like 42). Both produce
|
/// Compares two decoded header values. For <c>integer</c>-typed parameters the values are
|
||||||
// JsonValueKind.Number in the JSON body and are sent as numeric strings in headers.
|
/// compared numerically (so cross-SDK forms such as <c>"42"</c> and <c>"42.0"</c> are treated
|
||||||
// We check for both because different SDKs may serialize them differently -
|
/// as equal) and validated against the JavaScript safe integer range per SEP-2243.
|
||||||
// e.g., a client might send header "42.0" for an "integer" body value of 42,
|
/// </summary>
|
||||||
// or header "42" for a "number" body value of 42.0. Without handling both types,
|
private static HeaderValueComparison ValuesMatch(string? actual, string? expected, System.Text.Json.JsonElement propertySchema)
|
||||||
// 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();
|
// 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);
|
||||||
|
|
||||||
// For "integer" type, prefer exact long comparison to preserve full precision
|
// A numeric value outside the safe integer range is always rejected.
|
||||||
// for values beyond double's ~15-17 significant digit limit.
|
if (actualResult == SafeIntegerParse.OutOfRange || expectedResult == SafeIntegerParse.OutOfRange)
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
return actualLong == expectedLong;
|
return HeaderValueComparison.IntegerOutOfRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For "number" type, or "integer" values in decimal format (e.g., cross-SDK "42.0" vs "42"),
|
if (actualResult == SafeIntegerParse.SafeInteger && expectedResult == SafeIntegerParse.SafeInteger)
|
||||||
// use double comparison with tolerance.
|
{
|
||||||
if (schemaType is "number" or "integer" &&
|
return actualValue == expectedValue ? HeaderValueComparison.Match : HeaderValueComparison.Mismatch;
|
||||||
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)
|
// 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 true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
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 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 SafeIntegerParse.NotNumeric;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
|
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,10 @@ public abstract partial class McpClient : McpSession
|
||||||
tools ??= new(toolResults.Tools.Count);
|
tools ??= new(toolResults.Tools.Count);
|
||||||
foreach (var tool in toolResults.Tools)
|
foreach (var tool in toolResults.Tools)
|
||||||
{
|
{
|
||||||
// Validate x-mcp-header annotations per SEP-2243.
|
// Validate x-mcp-header annotations per SEP-2243. The spec requires Streamable HTTP
|
||||||
// Clients MUST exclude tools with invalid annotations and SHOULD log a warning.
|
// 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))
|
if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason))
|
||||||
{
|
{
|
||||||
ToolRejected?.Invoke(tool, rejectionReason!);
|
ToolRejected?.Invoke(tool, rejectionReason!);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
#if NET
|
||||||
|
using System.Buffers;
|
||||||
|
#endif
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
|
@ -36,10 +40,35 @@ internal static class McpHeaderExtractor
|
||||||
return;
|
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())
|
foreach (var property in properties.EnumerateObject())
|
||||||
{
|
{
|
||||||
if (property.Value.ValueKind != JsonValueKind.Object ||
|
if (property.Value.ValueKind != JsonValueKind.Object)
|
||||||
!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
|
{
|
||||||
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +80,7 @@ internal static class McpHeaderExtractor
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for the corresponding argument value
|
// Look for the corresponding argument value
|
||||||
if (!arguments.Value.TryGetProperty(property.Name, out var argValue))
|
if (!arguments.TryGetProperty(property.Name, out var argValue))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +91,7 @@ internal static class McpHeaderExtractor
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var headerValue = McpHeaderEncoder.ConvertToHeaderValue(argValue);
|
var headerValue = ConvertArgumentToHeaderValue(property.Value, property.Name, argValue);
|
||||||
if (headerValue is not null)
|
if (headerValue is not null)
|
||||||
{
|
{
|
||||||
headers.Add($"{McpHttpHeaders.ParamPrefix}{headerName}", headerValue);
|
headers.Add($"{McpHttpHeaders.ParamPrefix}{headerName}", headerValue);
|
||||||
|
|
@ -70,6 +99,92 @@ internal static class McpHeaderExtractor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an argument value to its encoded header representation. When the property schema
|
||||||
|
/// declares an <c>integer</c> type, the value is canonicalized to its decimal string form
|
||||||
|
/// (e.g. a body value of <c>42.0</c> is emitted as <c>"42"</c>) per SEP-2243.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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. <c>42.0</c>, <c>4.2e1</c>)
|
||||||
|
/// are accepted; non-integers and out-of-range values are rejected.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a tool's <c>inputSchema</c> for valid <c>x-mcp-header</c> annotations.
|
/// Validates a tool's <c>inputSchema</c> for valid <c>x-mcp-header</c> annotations.
|
||||||
/// Returns <see langword="true"/> if the tool is valid; <see langword="false"/> with a reason if it should be rejected.
|
/// Returns <see langword="true"/> if the tool is valid; <see langword="false"/> with a reason if it should be rejected.
|
||||||
|
|
@ -86,12 +201,35 @@ internal static class McpHeaderExtractor
|
||||||
}
|
}
|
||||||
|
|
||||||
var headerNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
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())
|
foreach (var property in properties.EnumerateObject())
|
||||||
{
|
{
|
||||||
// Skip properties whose schema is not an object (e.g., boolean `true`/`false` schemas)
|
// Skip properties whose schema is not an object (e.g., boolean `true`/`false` schemas)
|
||||||
if (property.Value.ValueKind != JsonValueKind.Object ||
|
if (property.Value.ValueKind != JsonValueKind.Object)
|
||||||
!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
|
{
|
||||||
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -112,36 +250,154 @@ internal static class McpHeaderExtractor
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MUST contain only ASCII characters (0x21-0x7E) excluding space and colon
|
// MUST match HTTP field-name token syntax (1*tchar, RFC 9110 Section 5.1)
|
||||||
foreach (char c in headerName!)
|
// MUST NOT contain control characters including CR and LF
|
||||||
{
|
int invalidIdx = FindFirstNonTchar(headerName!);
|
||||||
if (c < 0x21 || c > 0x7E || c == ':')
|
if (invalidIdx >= 0)
|
||||||
{
|
{
|
||||||
|
char c = headerName![invalidIdx];
|
||||||
rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2}).";
|
rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2}).";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MUST be case-insensitively unique
|
// 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).";
|
rejectionReason = $"Tool '{tool.Name}': duplicate x-mcp-header name '{headerName}' (case-insensitive).";
|
||||||
return false;
|
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) &&
|
if (property.Value.TryGetProperty("type", out var typeElement) &&
|
||||||
typeElement.ValueKind == JsonValueKind.String)
|
!IsAllowedHeaderType(typeElement))
|
||||||
{
|
{
|
||||||
var typeName = typeElement.GetString();
|
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', and 'boolean' are allowed.";
|
||||||
if (typeName is not ("string" or "number" or "integer" or "boolean"))
|
|
||||||
{
|
|
||||||
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has non-primitive type '{typeName}'.";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
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())
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static int FindFirstNonTchar(string value)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
if (!IsTchar(value[i]))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,18 @@ namespace ModelContextProtocol.Protocol;
|
||||||
/// including Base64 encoding for values that cannot be safely transmitted as plain text.
|
/// including Base64 encoding for values that cannot be safely transmitted as plain text.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// Per SEP-2243 only primitive parameter types are supported: <c>string</c>, <c>integer</c>, and
|
||||||
|
/// <c>boolean</c>. The JSON Schema <c>number</c> type is not permitted, and integer values must be
|
||||||
|
/// within the JavaScript safe integer range (−2^53+1 to 2^53−1).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// Encoding rules:
|
/// Encoding rules:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><description>Plain ASCII values (0x20-0x7E): sent as-is</description></item>
|
/// <item><description>Plain ASCII values (0x20-0x7E): sent as-is</description></item>
|
||||||
/// <item><description>Values with leading/trailing whitespace: Base64 encoded with <c>=?base64?{value}?=</c> wrapper</description></item>
|
/// <item><description>Values with leading/trailing whitespace: Base64 encoded with <c>=?base64?{value}?=</c> wrapper</description></item>
|
||||||
/// <item><description>Non-ASCII characters: Base64 encoded</description></item>
|
/// <item><description>Non-ASCII characters: Base64 encoded</description></item>
|
||||||
/// <item><description>Control characters: Base64 encoded</description></item>
|
/// <item><description>Control characters: Base64 encoded</description></item>
|
||||||
|
/// <item><description>Plain ASCII values that themselves match the <c>=?base64?...?=</c> sentinel pattern: Base64 encoded to avoid ambiguity</description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
|
@ -28,6 +34,10 @@ public static class McpHeaderEncoder
|
||||||
private const string Base64Prefix = "=?base64?";
|
private const string Base64Prefix = "=?base64?";
|
||||||
private const string Base64Suffix = "?=";
|
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encodes a string parameter value for use in an HTTP header.
|
/// Encodes a string parameter value for use in an HTTP header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -58,26 +68,19 @@ public static class McpHeaderEncoder
|
||||||
public static string EncodeValue(bool value) => value ? "true" : "false";
|
public static string EncodeValue(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encodes a numeric parameter value for use in an HTTP header.
|
/// Encodes an integer parameter value for use in an HTTP header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The numeric value to encode.</param>
|
/// <param name="value">The integer value to encode.</param>
|
||||||
/// <returns>The decimal string representation of the value.</returns>
|
/// <returns>The decimal string representation of the value.</returns>
|
||||||
public static string EncodeValue(long value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
public static string EncodeValue(long value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encodes a numeric parameter value for use in an HTTP header.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">The numeric value to encode.</param>
|
|
||||||
/// <returns>The decimal string representation of the value.</returns>
|
|
||||||
public static string EncodeValue(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encodes a parameter value for use in an HTTP header.
|
/// Encodes a parameter value for use in an HTTP header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The value to encode. Supported types are string, numeric types, and boolean.</param>
|
/// <param name="value">The value to encode. Supported types are <c>string</c>, integer, and <c>boolean</c>.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// The encoded header value, or <see langword="null"/> if the value is <see langword="null"/>
|
/// The encoded header value, or <see langword="null"/> if the value is <see langword="null"/>
|
||||||
/// or is not a supported type (string, numeric, or boolean).
|
/// or is not a supported type (string, integer, or boolean).
|
||||||
/// </returns>
|
/// </returns>
|
||||||
public static string? EncodeValue(object? value)
|
public static string? EncodeValue(object? value)
|
||||||
{
|
{
|
||||||
|
|
@ -121,9 +124,9 @@ public static class McpHeaderEncoder
|
||||||
return headerValue;
|
return headerValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Base64 wrapper. The spec defines the prefix as lowercase "=?base64?"
|
// Check for Base64 wrapper. The spec requires the sentinel markers to be
|
||||||
// but we match case-insensitively for robustness against non-conforming senders.
|
// case-sensitive and exactly lowercase per SEP-2243.
|
||||||
if (headerValue.StartsWith(Base64Prefix, StringComparison.OrdinalIgnoreCase) &&
|
if (headerValue.StartsWith(Base64Prefix, StringComparison.Ordinal) &&
|
||||||
headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal))
|
headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var base64Content = headerValue.Substring(
|
var base64Content = headerValue.Substring(
|
||||||
|
|
@ -133,12 +136,16 @@ public static class McpHeaderEncoder
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bytes = Convert.FromBase64String(base64Content);
|
var bytes = Convert.FromBase64String(base64Content);
|
||||||
return Encoding.UTF8.GetString(bytes);
|
return s_strictUtf8.GetString(bytes);
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
catch (DecoderFallbackException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return headerValue;
|
return headerValue;
|
||||||
|
|
@ -201,9 +208,6 @@ public static class McpHeaderEncoder
|
||||||
uint n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
uint n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
long n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
long n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
ulong 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
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +225,14 @@ public static class McpHeaderEncoder
|
||||||
return true;
|
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)
|
foreach (char c in value)
|
||||||
{
|
{
|
||||||
// Valid HTTP header field value characters per SEP: visible ASCII (0x21-0x7E) and space (0x20).
|
// Valid HTTP header field value characters per SEP: visible ASCII (0x21-0x7E) and space (0x20).
|
||||||
|
|
|
||||||
|
|
@ -630,8 +630,8 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
if (!IsPrimitiveHeaderType(paramType))
|
if (!IsPrimitiveHeaderType(paramType))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Parameter '{param.Name}' on method '{method.Name}' has [McpHeader] but is not a primitive type. " +
|
$"Parameter '{param.Name}' on method '{method.Name}' has [McpHeader] but is not a supported type. " +
|
||||||
"Only string, numeric, and boolean types may be annotated with [McpHeader].");
|
"Only string, integer, and boolean types may be annotated with [McpHeader].");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate case-insensitive uniqueness
|
// Validate case-insensitive uniqueness
|
||||||
|
|
@ -673,6 +673,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
|
|
||||||
private static bool IsPrimitiveHeaderType(Type type)
|
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) ||
|
return type == typeof(string) ||
|
||||||
type == typeof(bool) ||
|
type == typeof(bool) ||
|
||||||
type == typeof(byte) ||
|
type == typeof(byte) ||
|
||||||
|
|
@ -681,10 +687,6 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
type == typeof(ushort) ||
|
type == typeof(ushort) ||
|
||||||
type == typeof(int) ||
|
type == typeof(int) ||
|
||||||
type == typeof(uint) ||
|
type == typeof(uint) ||
|
||||||
type == typeof(long) ||
|
type == typeof(long);
|
||||||
type == typeof(ulong) ||
|
|
||||||
type == typeof(float) ||
|
|
||||||
type == typeof(double) ||
|
|
||||||
type == typeof(decimal);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Server;
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -10,8 +12,8 @@ namespace ModelContextProtocol.Server;
|
||||||
/// HTTP header named <c>Mcp-Param-{Name}</c>.
|
/// HTTP header named <c>Mcp-Param-{Name}</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Only parameters with primitive types (string, number, boolean) may use this attribute.
|
/// Only parameters with primitive types (integer, string, boolean) may use this attribute.
|
||||||
/// The header name must contain only ASCII characters (0x21-0x7E, excluding space and colon)
|
/// 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.
|
/// and must be case-insensitively unique within the tool's input schema.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -38,7 +40,7 @@ public sealed class McpHeaderAttribute : Attribute
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="name">
|
/// <param name="name">
|
||||||
/// The name portion of the header. The full header name will be <c>Mcp-Param-{name}</c>.
|
/// 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>
|
/// </param>
|
||||||
/// <exception cref="ArgumentException">
|
/// <exception cref="ArgumentException">
|
||||||
/// The name is null, empty, or contains invalid characters.
|
/// The name is null, empty, or contains invalid characters.
|
||||||
|
|
@ -59,23 +61,20 @@ public sealed class McpHeaderAttribute : Attribute
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="name">The header name to validate.</param>
|
/// <param name="name">The header name to validate.</param>
|
||||||
/// <exception cref="ArgumentException">The name contains invalid characters.</exception>
|
/// <exception cref="ArgumentException">The name contains invalid characters.</exception>
|
||||||
internal static void ValidateHeaderName(string name)
|
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 == ':')
|
|
||||||
{
|
{
|
||||||
|
char c = name[idx];
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
$"Header name contains invalid character '{c}' (0x{(int)c:X2}). " +
|
$"Header name contains invalid character '{c}' (0x{(int)c:X2}). " +
|
||||||
"Only ASCII characters (0x21-0x7E) excluding colon are allowed.",
|
"Only HTTP token characters (tchar per RFC 9110 Section 5.6.2) are allowed.",
|
||||||
nameof(name));
|
nameof(name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,78 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
|
||||||
Assert.Equal("42", headers["Mcp-Param-Priority"]);
|
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<string, object?>
|
||||||
|
{
|
||||||
|
["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<McpException>(async () => await client.CallToolAsync(
|
||||||
|
"my_tool",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["region"] = "us-west-2",
|
||||||
|
["priority"] = JsonDocument.Parse(bodyValue).RootElement,
|
||||||
|
},
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
Assert.Empty(_capturedHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders()
|
public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
||||||
// Create a tool with x-mcp-header annotations in the schema.
|
// Create a tool with x-mcp-header annotations in the schema.
|
||||||
// We set InputSchema directly because TransformSchemaNode doesn't provide
|
// We set InputSchema directly because TransformSchemaNode doesn't provide
|
||||||
// property-level path context for lambda-based tool creation.
|
// 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()
|
private static readonly JsonSerializerOptions s_reflectionOptions = new()
|
||||||
{
|
{
|
||||||
|
|
@ -65,7 +65,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
||||||
{
|
{
|
||||||
var tool = McpServerTool.Create(
|
var tool = McpServerTool.Create(
|
||||||
[McpServerTool(Name = "header_test")]
|
[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}",
|
$"region={region},priority={priority},verbose={verbose},empty={emptyVal}",
|
||||||
new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions });
|
new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions });
|
||||||
|
|
||||||
|
|
@ -86,8 +86,93 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
||||||
return tool;
|
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
|
#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]
|
[Fact]
|
||||||
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
|
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
|
||||||
{
|
{
|
||||||
|
|
@ -209,15 +294,15 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Server_AcceptsLargeIntegerWithFullPrecision()
|
public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision()
|
||||||
{
|
{
|
||||||
await StartAsync();
|
await StartAsync();
|
||||||
await InitializeWithDraftVersionAsync();
|
await InitializeWithDraftVersionAsync();
|
||||||
|
|
||||||
// Use a large integer that would lose precision if converted through double
|
// The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without
|
||||||
// 2^53 + 1 = 9007199254740993 (cannot be represented exactly as double)
|
// losing precision through a double conversion.
|
||||||
const long largeInt = 9007199254740993L;
|
const long maxSafeInt = 9007199254740991L;
|
||||||
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{largeInt}},"verbose":false,"emptyVal":""}""");
|
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{maxSafeInt}},"verbose":false,"emptyVal":""}""");
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||||
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
|
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
|
||||||
|
|
@ -225,7 +310,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
||||||
request.Headers.Add("Mcp-Method", "tools/call");
|
request.Headers.Add("Mcp-Method", "tools/call");
|
||||||
request.Headers.Add("Mcp-Name", "header_test");
|
request.Headers.Add("Mcp-Name", "header_test");
|
||||||
request.Headers.Add("Mcp-Param-Region", "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-Verbose", "false");
|
||||||
request.Headers.Add("Mcp-Param-EmptyVal", "");
|
request.Headers.Add("Mcp-Param-EmptyVal", "");
|
||||||
|
|
||||||
|
|
@ -234,15 +319,47 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("42", 42)] // "42" header vs 42 body → exact integer match
|
[InlineData("9007199254740993")] // 2^53 + 1, just outside the safe range
|
||||||
[InlineData("42.0", 42)] // "42.0" header vs 42 body → numeric equivalence
|
[InlineData("-9007199254740993")] // -(2^53 + 1), just outside the safe range
|
||||||
[InlineData("42", 42.0)] // "42" header vs 42.0 body → numeric equivalence
|
[InlineData("100000000000000000000000000000000000000")] // far beyond decimal range
|
||||||
public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, double bodyValue)
|
[InlineData("1e100")] // exponent form far beyond the safe range
|
||||||
|
public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue)
|
||||||
{
|
{
|
||||||
await StartAsync();
|
await StartAsync();
|
||||||
await InitializeWithDraftVersionAsync();
|
await InitializeWithDraftVersionAsync();
|
||||||
|
|
||||||
var callJson = CallTool("header_test", $$$"""{"region":"test","priority":{{{bodyValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}}},"verbose":false,"emptyVal":""}""");
|
// 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();
|
||||||
|
|
||||||
|
// 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, "");
|
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||||
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
|
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
|
||||||
|
|
@ -258,6 +375,34 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
|
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,9 @@ public class McpHeaderEncoderTests
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(42, "42")]
|
[InlineData(42, "42")]
|
||||||
[InlineData(3.14, "3.14")]
|
|
||||||
[InlineData(0, "0")]
|
[InlineData(0, "0")]
|
||||||
[InlineData(-1, "-1")]
|
[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);
|
var result = McpHeaderEncoder.EncodeValue(input);
|
||||||
Assert.Equal(expected, result);
|
Assert.Equal(expected, result);
|
||||||
|
|
@ -106,10 +105,12 @@ public class McpHeaderEncoderTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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=?=");
|
var result = McpHeaderEncoder.DecodeValue("=?BASE64?SGVsbG8=?=");
|
||||||
Assert.Equal("Hello", result);
|
Assert.Equal("=?BASE64?SGVsbG8=?=", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -119,6 +120,15 @@ public class McpHeaderEncoderTests
|
||||||
Assert.Null(result);
|
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]
|
[Fact]
|
||||||
public void DecodeValue_MissingPrefix_ReturnsLiteralValue()
|
public void DecodeValue_MissingPrefix_ReturnsLiteralValue()
|
||||||
{
|
{
|
||||||
|
|
@ -160,4 +170,34 @@ public class McpHeaderEncoderTests
|
||||||
var decoded = McpHeaderEncoder.DecodeValue(result);
|
var decoded = McpHeaderEncoder.DecodeValue(result);
|
||||||
Assert.Equal("col1\tcol2", decoded);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,9 @@ public class McpHeaderAttributeTests
|
||||||
[InlineData("TenantId")]
|
[InlineData("TenantId")]
|
||||||
[InlineData("Priority")]
|
[InlineData("Priority")]
|
||||||
[InlineData("X-Custom")]
|
[InlineData("X-Custom")]
|
||||||
|
[InlineData("x!header")]
|
||||||
|
[InlineData("x#header")]
|
||||||
|
[InlineData("x~header")]
|
||||||
public void Constructor_ValidHeaderName_Succeeds(string name)
|
public void Constructor_ValidHeaderName_Succeeds(string name)
|
||||||
{
|
{
|
||||||
var attr = new McpHeaderAttribute(name);
|
var attr = new McpHeaderAttribute(name);
|
||||||
|
|
@ -27,6 +30,23 @@ public class McpHeaderAttributeTests
|
||||||
Assert.Throws<ArgumentException>(() => new McpHeaderAttribute("Region:Primary"));
|
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => new McpHeaderAttribute(name));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_NullName_Throws()
|
public void Constructor_NullName_Throws()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1221,6 +1221,15 @@ public partial class McpServerToolTests
|
||||||
McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNonPrimitiveHeader))!));
|
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<InvalidOperationException>(() =>
|
||||||
|
McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithUInt64Header))!));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_WithMcpHeaderOnNumericType_AddsExtension()
|
public void Create_WithMcpHeaderOnNumericType_AddsExtension()
|
||||||
{
|
{
|
||||||
|
|
@ -1308,5 +1317,10 @@ public partial class McpServerToolTests
|
||||||
[McpServerTool]
|
[McpServerTool]
|
||||||
public static string ToolWithoutHeaders(string region, string query)
|
public static string ToolWithoutHeaders(string region, string query)
|
||||||
=> "result";
|
=> "result";
|
||||||
|
|
||||||
|
[McpServerTool]
|
||||||
|
public static string ToolWithUInt64Header(
|
||||||
|
[McpHeader("Count")] ulong count)
|
||||||
|
=> "result";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue