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
4 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>