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
4 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue