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