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:
parent
3118fcd6cb
commit
b8c4d951ec
10 changed files with 2854 additions and 28 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
147
src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
Normal file
147
src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
243
src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
Normal file
243
src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
81
src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
Normal file
81
src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue