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
5 changed files with 1035 additions and 116 deletions
|
|
@ -186,8 +186,10 @@ public abstract partial class McpClient : McpSession
|
|||
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!);
|
||||
|
|
|
|||
|
|
@ -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 @@ internal static class McpHeaderExtractor
|
|||
return;
|
||||
}
|
||||
|
||||
AddParameterHeadersFromProperties(headers, properties, arguments.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively extracts parameter values from properties at any nesting depth
|
||||
/// and adds them as HTTP headers.
|
||||
/// </summary>
|
||||
private static void AddParameterHeadersFromProperties(
|
||||
HttpRequestHeaders headers,
|
||||
JsonElement properties,
|
||||
JsonElement arguments)
|
||||
{
|
||||
foreach (var property in properties.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind != JsonValueKind.Object ||
|
||||
!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
|
||||
if (property.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into nested object properties
|
||||
if (property.Value.TryGetProperty("properties", out var nestedProperties) &&
|
||||
nestedProperties.ValueKind == JsonValueKind.Object &&
|
||||
arguments.TryGetProperty(property.Name, out var nestedArgs) &&
|
||||
nestedArgs.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
AddParameterHeadersFromProperties(headers, nestedProperties, nestedArgs);
|
||||
}
|
||||
|
||||
if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -51,7 +80,7 @@ internal static class McpHeaderExtractor
|
|||
}
|
||||
|
||||
// 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 @@ internal static class McpHeaderExtractor
|
|||
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 @@ 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>
|
||||
/// 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.
|
||||
|
|
@ -86,12 +201,35 @@ internal static class McpHeaderExtractor
|
|||
}
|
||||
|
||||
var headerNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
return ValidateProperties(tool, properties, headerNames, out rejectionReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively validates properties at any nesting depth for valid <c>x-mcp-header</c> annotations.
|
||||
/// </summary>
|
||||
private static bool ValidateProperties(Tool tool, JsonElement properties, HashSet<string> headerNames, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
|
||||
foreach (var property in properties.EnumerateObject())
|
||||
{
|
||||
// Skip properties whose schema is not an object (e.g., boolean `true`/`false` schemas)
|
||||
if (property.Value.ValueKind != JsonValueKind.Object ||
|
||||
!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
|
||||
if (property.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into nested object properties
|
||||
if (property.Value.TryGetProperty("properties", out var nestedProperties) &&
|
||||
nestedProperties.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!ValidateProperties(tool, nestedProperties, headerNames, out rejectionReason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -112,36 +250,154 @@ internal static class McpHeaderExtractor
|
|||
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 non-primitive type '{typeName}'.";
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </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:
|
||||
/// <list type="bullet">
|
||||
/// <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>Non-ASCII 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>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a string parameter value for use in an HTTP header.
|
||||
/// </summary>
|
||||
|
|
@ -58,26 +68,19 @@ public static class McpHeaderEncoder
|
|||
public static string EncodeValue(bool value) => value ? "true" : "false";
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a numeric parameter value for use in an HTTP header.
|
||||
/// Encodes an integer parameter value for use in an HTTP header.
|
||||
/// </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>
|
||||
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>
|
||||
/// Encodes a parameter value for use in an HTTP header.
|
||||
/// </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>
|
||||
/// 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>
|
||||
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 @@ public static class McpHeaderEncoder
|
|||
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).
|
||||
|
|
|
|||
|
|
@ -630,8 +630,8 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
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 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
|
||||
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 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -10,8 +12,8 @@ namespace ModelContextProtocol.Server;
|
|||
/// HTTP header named <c>Mcp-Param-{Name}</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only parameters with primitive types (string, number, boolean) may use this attribute.
|
||||
/// The header name must contain only ASCII characters (0x21-0x7E, excluding space and colon)
|
||||
/// Only parameters with primitive types (integer, string, boolean) may use this attribute.
|
||||
/// The header name must match HTTP field-name token syntax (tchar per RFC 9110 Section 5.6.2)
|
||||
/// and must be case-insensitively unique within the tool's input schema.
|
||||
/// </para>
|
||||
/// <para>
|
||||
|
|
@ -38,7 +40,7 @@ public sealed class McpHeaderAttribute : Attribute
|
|||
/// </summary>
|
||||
/// <param name="name">
|
||||
/// The name portion of the header. The full header name will be <c>Mcp-Param-{name}</c>.
|
||||
/// Must contain only ASCII characters (0x21-0x7E, excluding space and colon).
|
||||
/// Must match HTTP field-name token syntax (tchar per RFC 9110 Section 5.6.2).
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The name is null, empty, or contains invalid characters.
|
||||
|
|
@ -59,23 +61,20 @@ public sealed class McpHeaderAttribute : Attribute
|
|||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a header name contains only valid characters.
|
||||
/// Validates that a header name contains only valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2.
|
||||
/// </summary>
|
||||
/// <param name="name">The header name to validate.</param>
|
||||
/// <exception cref="ArgumentException">The name contains invalid characters.</exception>
|
||||
internal static void ValidateHeaderName(string name)
|
||||
{
|
||||
foreach (char c in name)
|
||||
int idx = McpHeaderExtractor.FindFirstNonTchar(name);
|
||||
if (idx >= 0)
|
||||
{
|
||||
// Valid token characters per RFC 9110: visible ASCII (0x21-0x7E) excluding delimiters.
|
||||
// Space (0x20) and colon (':') are explicitly prohibited.
|
||||
if (c < 0x21 || c > 0x7E || c == ':')
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Header name contains invalid character '{c}' (0x{(int)c:X2}). " +
|
||||
"Only ASCII characters (0x21-0x7E) excluding colon are allowed.",
|
||||
nameof(name));
|
||||
}
|
||||
char c = name[idx];
|
||||
throw new ArgumentException(
|
||||
$"Header name contains invalid character '{c}' (0x{(int)c:X2}). " +
|
||||
"Only HTTP token characters (tchar per RFC 9110 Section 5.6.2) are allowed.",
|
||||
nameof(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue