Implementation for SEP-2243 Http Standardization (#1553)

Co-authored-by: Tarek Mahmoud Sayed <tarekms@microsoft.com>
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mike Kistler 2026-05-14 17:39:37 -05:00 • committed by GitHub
commit b8c4d951ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2854 additions and 28 deletions

View file

@ -176,6 +176,8 @@ public abstract partial class McpClient : McpSession
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
ToolCacheClearing?.Invoke();
List<McpClientTool>? tools = null;
ListToolsRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() };
do
@ -184,6 +186,15 @@ 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.
if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason))
{
ToolRejected?.Invoke(tool, rejectionReason!);
continue;
}
ToolDiscovered?.Invoke(tool);
tools.Add(new(this, tool, options?.JsonSerializerOptions));
}
@ -194,6 +205,21 @@ public abstract partial class McpClient : McpSession
return tools;
}
/// <summary>
/// Invoked when a tool definition is discovered from a <c>tools/list</c> response.
/// </summary>
internal Action<Tool>? ToolDiscovered;
/// <summary>
/// Invoked when a tool definition is rejected due to invalid <c>x-mcp-header</c> annotations.
/// </summary>
internal Action<Tool, string>? ToolRejected;
/// <summary>
/// Invoked before enumerating tools to clear any previously cached tool definitions.
/// </summary>
internal Action? ToolCacheClearing;
/// <summary>
/// Retrieves a list of available tools from the server.
/// </summary>

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Text.Json;
namespace ModelContextProtocol.Client;
@ -22,6 +23,7 @@ internal sealed partial class McpClientImpl : McpClient
private readonly McpSessionHandler _sessionHandler;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
private ServerCapabilities? _serverCapabilities;
private Implementation? _serverInfo;
@ -67,6 +69,10 @@ internal sealed partial class McpClientImpl : McpClient
incomingMessageFilter: null,
outgoingMessageFilter: null,
_logger);
ToolDiscovered = tool => _toolCache[tool.Name] = tool;
ToolRejected = (tool, reason) => LogToolRejected(tool.Name, reason);
ToolCacheClearing = () => _toolCache.Clear();
}
private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers)
@ -633,7 +639,22 @@ internal sealed partial class McpClientImpl : McpClient
/// <inheritdoc/>
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default)
=> _sessionHandler.SendRequestAsync(request, cancellationToken);
{
// For tools/call requests, attach the cached tool definition to the message context
// so the transport can add custom Mcp-Param-* headers based on x-mcp-header schema annotations.
if (request.Method == RequestMethods.ToolsCall &&
request.Params is System.Text.Json.Nodes.JsonObject paramsObj &&
paramsObj.TryGetPropertyValue("name", out var nameNode) &&
nameNode?.GetValue<string>() is { } toolName &&
_toolCache.TryGetValue(toolName, out var tool))
{
request.Context ??= new();
request.Context.Items ??= new Dictionary<string, object?>();
request.Context.Items[McpHttpHeaders.ToolContextKey] = tool;
}
return _sessionHandler.SendRequestAsync(request, cancellationToken);
}
/// <inheritdoc/>
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
@ -686,4 +707,7 @@ internal sealed partial class McpClientImpl : McpClient
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
private partial void LogClientSessionResumed(string endpointName);
[LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")]
private partial void LogToolRejected(string toolName, string reason);
}

View file

@ -0,0 +1,147 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Client;
/// <summary>
/// Extracts parameter values from tool call arguments and adds them as HTTP headers
/// based on <c>x-mcp-header</c> schema extensions.
/// </summary>
internal static class McpHeaderExtractor
{
private const string XMcpHeaderProperty = "x-mcp-header";
/// <summary>
/// Adds custom parameter headers to an HTTP request based on a tool's schema extensions.
/// </summary>
/// <param name="headers">The HTTP request headers to add to.</param>
/// <param name="tool">The tool definition containing the input schema with x-mcp-header annotations.</param>
/// <param name="arguments">The arguments being passed to the tool call.</param>
public static void AddParameterHeaders(
HttpRequestHeaders headers,
Tool tool,
JsonElement? arguments)
{
if (!arguments.HasValue || arguments.Value.ValueKind != JsonValueKind.Object)
{
return;
}
if (tool.InputSchema.ValueKind != JsonValueKind.Object ||
!tool.InputSchema.TryGetProperty("properties", out var properties) ||
properties.ValueKind != JsonValueKind.Object)
{
return;
}
foreach (var property in properties.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.Object ||
!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
{
continue;
}
var headerName = headerNameElement.GetString();
if (string.IsNullOrEmpty(headerName))
{
continue;
}
// Look for the corresponding argument value
if (!arguments.Value.TryGetProperty(property.Name, out var argValue))
{
continue;
}
// Null values → omit header per SEP
if (argValue.ValueKind == JsonValueKind.Null)
{
continue;
}
var headerValue = McpHeaderEncoder.ConvertToHeaderValue(argValue);
if (headerValue is not null)
{
headers.Add($"{McpHttpHeaders.ParamPrefix}{headerName}", headerValue);
}
}
}
/// <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.
/// </summary>
internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason)
{
rejectionReason = null;
if (tool.InputSchema.ValueKind != JsonValueKind.Object ||
!tool.InputSchema.TryGetProperty("properties", out var properties) ||
properties.ValueKind != JsonValueKind.Object)
{
return true;
}
var headerNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
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))
{
continue;
}
// x-mcp-header value must be a string
if (headerNameElement.ValueKind != JsonValueKind.String)
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' is not a string.";
return false;
}
var headerName = headerNameElement.GetString();
// MUST NOT be empty
if (string.IsNullOrEmpty(headerName))
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' is empty.";
return false;
}
// MUST contain only ASCII characters (0x21-0x7E) excluding space and colon
foreach (char c in headerName!)
{
if (c < 0x21 || c > 0x7E || c == ':')
{
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))
{
rejectionReason = $"Tool '{tool.Name}': duplicate x-mcp-header name '{headerName}' (case-insensitive).";
return false;
}
// MUST only be applied to primitive types (string, number, boolean)
if (property.Value.TryGetProperty("type", out var typeElement) &&
typeElement.ValueKind == JsonValueKind.String)
{
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;
}
}
}
return true;
}
}

View file

@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Net.Http.Headers;
using System.Net.ServerSentEvents;
using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol.Protocol;
using System.Threading.Channels;
using System.Net;
@ -91,6 +92,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
AddMcpRequestHeaders(httpRequestMessage.Headers, message);
var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false);
// We'll let the caller decide whether to throw or fall back given an unsuccessful response.
@ -431,17 +434,17 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
{
if (sessionId is not null)
{
headers.Add("Mcp-Session-Id", sessionId);
headers.Add(McpHttpHeaders.SessionId, sessionId);
}
if (protocolVersion is not null)
{
headers.Add("MCP-Protocol-Version", protocolVersion);
headers.Add(McpHttpHeaders.ProtocolVersion, protocolVersion);
}
if (lastEventId is not null)
{
headers.Add("Last-Event-ID", lastEventId);
headers.Add(McpHttpHeaders.LastEventId, lastEventId);
}
if (additionalHeaders is null)
@ -458,6 +461,78 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
}
}
/// <summary>
/// Adds standard MCP request headers (Mcp-Method, Mcp-Name) and custom parameter headers
/// (Mcp-Param-{Name}) to an HTTP request based on the JSON-RPC message being sent.
/// </summary>
internal static void AddMcpRequestHeaders(HttpRequestHeaders headers, JsonRpcMessage message)
{
string? method = message switch
{
JsonRpcRequest request => request.Method,
JsonRpcNotification notification => notification.Method,
_ => null,
};
if (method is null)
{
return;
}
headers.Add(McpHttpHeaders.Method, method);
// Add Mcp-Name header for methods that target a specific named resource
string? name = message switch
{
JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request
=> GetParamsStringProperty(request.Params, "name"),
JsonRpcRequest { Method: RequestMethods.ResourcesRead } request
=> GetParamsStringProperty(request.Params, "uri"),
_ => null,
};
if (name is not null)
{
headers.Add(McpHttpHeaders.Name, name);
}
// Add custom Mcp-Param-{Name} headers for tools/call requests with x-mcp-header annotations
if (method == RequestMethods.ToolsCall &&
message is JsonRpcRequest toolsCallRequest &&
toolsCallRequest.Context?.Items?.TryGetValue(McpHttpHeaders.ToolContextKey, out var toolObj) == true &&
toolObj is Tool tool)
{
var arguments = GetParamsArguments(toolsCallRequest.Params);
McpHeaderExtractor.AddParameterHeaders(headers, tool, arguments);
}
}
/// <summary>
/// Extracts a string property from the JSON-RPC params object.
/// </summary>
private static string? GetParamsStringProperty(JsonNode? paramsNode, string propertyName)
{
if (paramsNode is JsonObject obj && obj.TryGetPropertyValue(propertyName, out var value))
{
return value?.GetValue<string>();
}
return null;
}
/// <summary>
/// Extracts the arguments property from a tools/call params object as a JsonElement.
/// </summary>
private static JsonElement? GetParamsArguments(JsonNode? paramsNode)
{
if (paramsNode is JsonObject obj && obj.TryGetPropertyValue("arguments", out var argsNode) && argsNode is not null)
{
return JsonSerializer.Deserialize<JsonElement>(argsNode, McpJsonUtilities.JsonContext.Default.JsonElement);
}
return null;
}
/// <summary>
/// Tracks state across SSE stream connections.
/// </summary>

View file

@ -5,6 +5,26 @@ namespace ModelContextProtocol;
/// </summary>
public enum McpErrorCode
{
/// <summary>
/// Indicates that HTTP headers do not match the corresponding values in the request body,
/// or that required headers are missing or malformed.
/// </summary>
/// <remarks>
/// <para>
/// This error is returned when a Streamable HTTP request fails header validation. Validation failures include:
/// </para>
/// <list type="bullet">
/// <item><description>A required standard header (<c>Mcp-Method</c>, <c>Mcp-Name</c>) is missing.</description></item>
/// <item><description>A header value does not match the corresponding request body value.</description></item>
/// <item><description>A Base64-encoded header value cannot be decoded.</description></item>
/// <item><description>A header value contains invalid characters.</description></item>
/// </list>
/// <para>
/// This error code is in the JSON-RPC implementation-defined server error range (<c>-32000</c> to <c>-32099</c>).
/// </para>
/// </remarks>
HeaderMismatch = -32001,
/// <summary>
/// Indicates that the requested resource could not be found.
/// </summary>

View file

@ -41,6 +41,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
"2025-03-26",
"2025-06-18",
LatestProtocolVersion,
"DRAFT-2026-v1",
];
/// <summary>
@ -159,7 +160,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// completes its channel with a <see cref="ClientTransportClosedException"/>, the wrapped
/// <see cref="ClientCompletionDetails"/> is unwrapped. Otherwise, a default instance is returned.
/// </summary>
internal Task<ClientCompletionDetails> CompletionTask =>
internal Task<ClientCompletionDetails> CompletionTask =>
field ??= GetCompletionDetailsAsync(_transport.MessageReader.Completion);
/// <summary>

View file

@ -26,6 +26,7 @@
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\EncodingUtilities.cs" Link="EncodingUtilities.cs" />
<Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
<Compile Include="..\Common\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" />
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
</ItemGroup>

View file

@ -0,0 +1,243 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Encodes and decodes parameter values for use in MCP HTTP headers according to the
/// HTTP Standardization SEP.
/// </summary>
/// <remarks>
/// <para>
/// This encoder handles conversion of parameter values to HTTP header-safe strings,
/// including Base64 encoding for values that cannot be safely transmitted as plain text.
/// </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>
/// </list>
/// </para>
/// </remarks>
public static class McpHeaderEncoder
{
private const string Base64Prefix = "=?base64?";
private const string Base64Suffix = "?=";
/// <summary>
/// Encodes a string parameter value for use in an HTTP header.
/// </summary>
/// <param name="value">The string value to encode.</param>
/// <returns>
/// The encoded header value, or <see langword="null"/> if <paramref name="value"/> is <see langword="null"/>.
/// </returns>
public static string? EncodeValue(string? value)
{
if (value is null)
{
return null;
}
if (RequiresBase64Encoding(value))
{
return EncodeAsBase64(value);
}
return value;
}
/// <summary>
/// Encodes a boolean parameter value for use in an HTTP header.
/// </summary>
/// <param name="value">The boolean value to encode.</param>
/// <returns>The encoded header value (<c>"true"</c> or <c>"false"</c>).</returns>
public static string EncodeValue(bool value) => value ? "true" : "false";
/// <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(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>
/// <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).
/// </returns>
public static string? EncodeValue(object? value)
{
if (value is null)
{
return null;
}
// Route to typed overloads for known types
if (value is string s)
{
return EncodeValue(s);
}
if (value is bool b)
{
return EncodeValue(b);
}
var stringValue = ConvertToString(value);
if (stringValue is null)
{
return null;
}
return stringValue;
}
/// <summary>
/// Decodes a header value that may be Base64-encoded according to SEP rules.
/// </summary>
/// <param name="headerValue">The header value to decode.</param>
/// <returns>
/// The decoded string value, or <see langword="null"/> if decoding fails.
/// If the value is not Base64-encoded, returns the original value.
/// </returns>
public static string? DecodeValue(string? headerValue)
{
if (headerValue is null || headerValue.Length == 0)
{
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) &&
headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal))
{
var base64Content = headerValue.Substring(
Base64Prefix.Length,
headerValue.Length - Base64Prefix.Length - Base64Suffix.Length);
try
{
var bytes = Convert.FromBase64String(base64Content);
return Encoding.UTF8.GetString(bytes);
}
catch (FormatException)
{
return null;
}
}
return headerValue;
}
/// <summary>
/// Converts a <see cref="JsonElement"/> value to an encoded header value string.
/// </summary>
/// <param name="element">The JSON element to convert.</param>
/// <returns>The encoded header value, or <see langword="null"/> if the element is not a supported primitive type.</returns>
public static string? ConvertToHeaderValue(JsonElement element)
{
object? value = element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.GetRawText(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null
};
return EncodeValue(value);
}
/// <summary>
/// Converts a <see cref="JsonNode"/> value to an encoded header value string.
/// </summary>
/// <param name="node">The JSON node to convert.</param>
/// <returns>The encoded header value, or <see langword="null"/> if the node is not a <see cref="JsonValue"/> or is not a supported primitive type.</returns>
public static string? ConvertToHeaderValue(JsonNode node)
{
if (node is not JsonValue jsonValue)
{
return null;
}
object? value = jsonValue.GetValueKind() switch
{
JsonValueKind.String => jsonValue.GetValue<string>(),
JsonValueKind.Number => jsonValue.ToJsonString(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null
};
return EncodeValue(value);
}
private static string? ConvertToString(object value)
{
return value switch
{
string s => s,
bool b => b ? "true" : "false",
byte n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
sbyte n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
short n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
ushort n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
int n => n.ToString(System.Globalization.CultureInfo.InvariantCulture),
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
};
}
private static bool RequiresBase64Encoding(string value)
{
if (value.Length == 0)
{
return false;
}
// Check for leading/trailing whitespace (space or tab)
if (value[0] is ' ' or '\t' || value[^1] is ' ' or '\t')
{
return true;
}
foreach (char c in value)
{
// Valid HTTP header field value characters per SEP: visible ASCII (0x21-0x7E) and space (0x20).
// All control characters (0x00-0x1F, 0x7F), including tab, must be Base64-encoded.
if (c < 0x20 || c > 0x7E)
{
return true;
}
}
return false;
}
private static string EncodeAsBase64(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var base64 = Convert.ToBase64String(bytes);
return $"{Base64Prefix}{base64}{Base64Suffix}";
}
}

View file

@ -124,6 +124,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
Icons = options?.Icons,
};
// Add x-mcp-header extensions to the input schema based on McpHeaderAttribute on parameters
if (function.UnderlyingMethod is { } method)
{
tool.InputSchema = AddMcpHeaderExtensions(tool.InputSchema, method);
}
if (options is not null)
{
if (options.Title is not null ||
@ -600,4 +606,85 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
IsError = allErrorContent && hasAny
};
}
/// <summary>
/// Post-processes the input schema to add <c>x-mcp-header</c> extensions based on
/// <see cref="McpHeaderAttribute"/> on method parameters.
/// </summary>
private static JsonElement AddMcpHeaderExtensions(JsonElement inputSchema, MethodInfo method)
{
// Collect parameters with McpHeaderAttribute
var headerParams = new List<(string ParameterName, string HeaderName, ParameterInfo Parameter)>();
var headerNamesSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var param in method.GetParameters())
{
var attr = param.GetCustomAttribute<McpHeaderAttribute>();
if (attr is null)
{
continue;
}
// Validate primitive type only
var paramType = Nullable.GetUnderlyingType(param.ParameterType) ?? param.ParameterType;
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].");
}
// Validate case-insensitive uniqueness
if (!headerNamesSet.Add(attr.Name))
{
throw new InvalidOperationException(
$"Duplicate x-mcp-header name '{attr.Name}' (case-insensitive) found on method '{method.Name}'. " +
"Header names must be case-insensitively unique within a tool's input schema.");
}
headerParams.Add((param.Name!, attr.Name, param));
}
if (headerParams.Count == 0)
{
return inputSchema;
}
// Parse the schema to a mutable JsonNode, add extensions, and convert back
var schemaNode = JsonNode.Parse(inputSchema.GetRawText());
if (schemaNode is not JsonObject schemaObj ||
!schemaObj.TryGetPropertyValue("properties", out var propertiesNode) ||
propertiesNode is not JsonObject propertiesObj)
{
return inputSchema;
}
foreach (var (parameterName, headerName, _) in headerParams)
{
if (propertiesObj.TryGetPropertyValue(parameterName, out var propNode) &&
propNode is JsonObject propObj)
{
propObj["x-mcp-header"] = headerName;
}
}
return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
}
private static bool IsPrimitiveHeaderType(Type type)
{
return type == typeof(string) ||
type == typeof(bool) ||
type == typeof(byte) ||
type == typeof(sbyte) ||
type == typeof(short) ||
type == typeof(ushort) ||
type == typeof(int) ||
type == typeof(uint) ||
type == typeof(long) ||
type == typeof(ulong) ||
type == typeof(float) ||
type == typeof(double) ||
type == typeof(decimal);
}
}

View file

@ -0,0 +1,81 @@
namespace ModelContextProtocol.Server;
/// <summary>
/// Indicates that a tool parameter should be mirrored as an HTTP header in client requests.
/// </summary>
/// <remarks>
/// <para>
/// When applied to a parameter, the SDK will include an <c>x-mcp-header</c> extension property
/// in the parameter's JSON schema. Clients will then mirror this parameter's value into an
/// 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)
/// and must be case-insensitively unique within the tool's input schema.
/// </para>
/// <para>
/// This enables network infrastructure such as load balancers, proxies, and gateways to make
/// routing decisions based on tool parameter values without parsing the JSON-RPC request body.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [McpServerTool]
/// public static string ExecuteSql(
/// [McpHeader("Region")] string region,
/// string query)
/// {
/// // The client will add header: Mcp-Param-Region: {region value}
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
public sealed class McpHeaderAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="McpHeaderAttribute"/> class.
/// </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).
/// </param>
/// <exception cref="ArgumentException">
/// The name is null, empty, or contains invalid characters.
/// </exception>
public McpHeaderAttribute(string name)
{
Throw.IfNullOrWhiteSpace(name);
ValidateHeaderName(name);
Name = name;
}
/// <summary>
/// Gets the name portion of the header.
/// </summary>
/// <remarks>
/// The full header name sent by clients will be <c>Mcp-Param-{Name}</c>.
/// </remarks>
public string Name { get; }
/// <summary>
/// Validates that a header name contains only valid characters.
/// </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)
{
// 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));
}
}
}
}