From 54f44ba0c5552a143ceafb2471cb188a4f5a40b6 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 6 Aug 2026 17:27:00 -0700 Subject: [PATCH 1/6] Relax legacy per-request metadata validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../McpSessionHandler.cs | 67 --- .../Protocol/JsonRpcMessageContext.cs | 20 +- .../Server/McpServerImpl.cs | 436 ++++++++++++------ .../RawHttpConformanceTests.cs | 32 ++ .../Client/McpClientMetaTests.cs | 47 ++ .../Server/McpServerTests.cs | 49 +- .../Server/NegotiatedProtocolVersionTests.cs | 102 +++- .../Server/TaskProtocolGatingTests.cs | 14 +- 8 files changed, 481 insertions(+), 286 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 61a1872f2..d0b3ed842 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -401,14 +401,6 @@ private static async Task GetCompletionDetailsAsync(Tas private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { - // Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any - // filters run so they (and downstream handlers) can read client info / capabilities / - // protocol version / log level without re-parsing. - if (_isServer && message is JsonRpcRequest incomingRequest) - { - PopulateContextFromMeta(incomingRequest); - } - Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); @@ -544,65 +536,6 @@ await SendMessageAsync(new JsonRpcResponse return result; } - /// - /// Reads the 2026-07-28 protocol's per-request _meta fields off the request and projects them onto - /// so they're available without re-parsing throughout the pipeline. - /// - /// - /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, - /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field - /// that's already set on the context (e.g., - /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone - /// unless explicitly overwritten by a non-null value parsed here. - /// - internal static void PopulateContextFromMeta(JsonRpcRequest request) - { - if (request.Params is not JsonObject paramsObj) - { - return; - } - - if (paramsObj["_meta"] is not JsonObject metaObj) - { - return; - } - - var context = request.Context ??= new JsonRpcMessageContext(); - - if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion && - protocolVersion.TryGetValue(out string? protocolVersionValue)) - { - // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already - // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with - // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), - // which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather - // than mistaking it for an initialize-handshake server and falling back to initialize. - if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) - { - throw new McpProtocolException( - $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", - McpErrorCode.HeaderMismatch); - } - - context.ProtocolVersion = protocolVersionValue; - } - - if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) - { - context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); - } - - if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) - { - context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); - } - - if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) - { - context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); - } - } - /// /// Injects the 2026-07-28 protocol's per-request _meta fields into an outgoing request. /// Protocol version and client info overwrite any existing values; client capabilities are merged diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index def9b89e4..8162af3a6 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -88,13 +88,12 @@ public sealed class JsonRpcMessageContext public string? RoutingName { get; set; } /// - /// Gets or sets the protocol version from the transport-level header (e.g. Mcp-Protocol-Version) - /// that accompanied this JSON-RPC message. + /// Gets or sets the authoritative protocol version for this JSON-RPC message. /// /// - /// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the initialize - /// handshake because each request creates a new server instance. This property allows the transport layer - /// to flow the protocol version header so the server can determine client capabilities. + /// The transport may populate this from a header such as Mcp-Protocol-Version. For modern revisions, + /// the server validates and projects the matching per-request _meta value. A known legacy version in + /// _meta is advisory and does not establish or change the negotiated session version. /// public string? ProtocolVersion { get; set; } @@ -104,7 +103,8 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). When the request was made under the 2026-07-28 or later revision, - /// the server uses this in lieu of the value previously captured during the initialize handshake. + /// the server uses this in lieu of the value previously captured during the initialize handshake. A legacy request + /// may also carry this field for forward compatibility; stateful legacy sessions continue to use their initialized identity. /// public Implementation? ClientInfo { get; set; } @@ -114,7 +114,9 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client - /// capabilities from previous requests; the authoritative value is the one declared on each request. + /// capabilities from previous modern requests; the authoritative value is the one declared on each request. + /// A legacy request may also carry this field for forward compatibility, but stateful legacy sessions continue + /// to use the capabilities negotiated during initialization. /// public ClientCapabilities? ClientCapabilities { get; set; } @@ -124,8 +126,8 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Replaces the legacy - /// RPC. When absent, the server MUST NOT emit log notifications - /// for the request. + /// RPC. When absent from a modern request, the server MUST NOT emit + /// log notifications for the request. Legacy requests continue to use their negotiated logging behavior. /// public LoggingLevel? LogLevel { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2ce838713..19480066b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -33,14 +34,6 @@ internal sealed partial class McpServerImpl : McpServer private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); - private static readonly string[] s_perRequestMetadataKeys = - [ - MetaKeys.ProtocolVersion, - MetaKeys.ClientInfo, - MetaKeys.ClientCapabilities, - MetaKeys.LogLevel, - ]; - // Track MRTR handler tasks using the same inFlightCount + TCS pattern as // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. private int _mrtrInFlightCount = 1; @@ -160,9 +153,9 @@ void Register(McpServerPrimitiveCollection? collection, /// /// Wraps so that, for every JSON-RPC request, a built-in filter first - /// synchronizes server-side state (, ) - /// from the per-request _meta values projected onto and - /// validates the per-request protocol version, before delegating to the user-supplied incoming filters. + /// classifies and projects per-request _meta, synchronizes server-side state + /// (, ), and validates protocol + /// boundaries before delegating to user-supplied incoming filters. /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so the protocol @@ -170,115 +163,44 @@ void Register(McpServerPrimitiveCollection? collection, /// capabilities and client info are consumed request-scoped by and are /// not read from server-wide state by request handlers. The shared write below is /// best-effort and used only to derive the session endpoint name for logging/telemetry. For initialize-handshake - /// clients the per-request values are absent and the built-in filter is a no-op (the values were captured during - /// the initialize handler). + /// clients, known legacy protocol-version metadata and auxiliary per-request values are advisory compatibility + /// data. Stateful sessions continue to use the values captured during initialization, while stateless requests + /// may consume well-formed projected values from their request context. Modern envelopes remain strictly parsed. /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) => { - if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest) - { - ValidateInitializeRequestBoundary(initializeRequest); - } - else if (message is JsonRpcRequest request) + if (message is JsonRpcRequest request) { - var context = request.Context; - bool endpointNameNeedsRefresh = false; - bool hasProtocolVersionMeta = HasMetaKey(request, MetaKeys.ProtocolVersion); - bool hasReservedPerRequestMeta = TryGetPerRequestMetadataKey(request, out var reservedPerRequestMetaKey); - - if (context?.ProtocolVersion is { } protocolVersion) + if (request.Method == RequestMethods.Initialize) { - bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; - if (protocolVersionAlreadyEstablished) - { - SetNegotiatedProtocolVersion(protocolVersion); - } - - // Per SEP-2575, the server MUST reject any request whose per-request - // _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions - // with an UnsupportedProtocolVersionError (-32022) carrying the supported list. - if (!_supportedProtocolVersions.Contains(protocolVersion)) - { - var supportedVersions = - hasProtocolVersionMeta && _perRequestMetadataProtocolVersions.Length > 0 ? - _perRequestMetadataProtocolVersions : - _supportedProtocolVersions; - - throw new UnsupportedProtocolVersionException( - requested: protocolVersion, - supported: supportedVersions); - } - - if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) - { - ValidateRequiredPerRequestMetadata( - protocolVersion, - hasProtocolVersionMeta, - context.ClientCapabilities is not null); - } - else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) - { - if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta) - { - throw new UnsupportedProtocolVersionException( - requested: protocolVersion, - supported: _perRequestMetadataProtocolVersions, - message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata."); - } - - if (hasReservedPerRequestMeta) - { - ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey); - } - } - - if (!protocolVersionAlreadyEstablished) - { - SetNegotiatedProtocolVersion(protocolVersion); - } + ProjectInitializeRequestMetadata(request); + ValidateInitializeRequestBoundary(request); } - else if (_negotiatedProtocolVersion is null) + else { - if (request.Method == RequestMethods.ServerDiscover) - { - throw new McpProtocolException( - $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", - McpErrorCode.InvalidParams); - } - - if (hasReservedPerRequestMeta) + ReadRequestMetadata(request); + ValidateRequestMethodBoundary(request); + + var context = request.Context; + bool useRequestScopedClientInfo = + !HasStatefulTransport() || + McpProtocolVersions.RequiresPerRequestMetadata(context?.ProtocolVersion ?? _negotiatedProtocolVersion); + if (useRequestScopedClientInfo && + context?.ClientInfo is { } clientInfo && + (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || + !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { - ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey); + // Modern handlers resolve client info request-scoped through DestinationBoundMcpServer. This + // shared write is only for endpoint logging. Stateless legacy servers are created per request, + // so retaining the request identity here also makes it available through McpServer.ClientInfo. + // Stateful legacy sessions keep the identity established by initialize. + _clientInfo = clientInfo; + UpdateEndpointNameWithClientInfo(); + _sessionHandler.EndpointName = _endpointName; } } - else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) - { - ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey); - } - - ValidateRequestMethodBoundary(request); - - if (context?.ClientInfo is { } clientInfo && - (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || - !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) - { - // This shared write is best-effort and used only to derive the session endpoint name for - // logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions: - // DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from - // the per-request _meta so concurrent requests never observe each other's values. Under a - // draft stateful session with differing per-request client info, the last writer wins here, - // which only affects the logged endpoint name and never the request-scoped values handlers see. - _clientInfo = clientInfo; - endpointNameNeedsRefresh = true; - } - - if (endpointNameNeedsRefresh) - { - UpdateEndpointNameWithClientInfo(); - _sessionHandler.EndpointName = _endpointName; - } } else if (message is JsonRpcNotification notification) { @@ -291,6 +213,261 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner return next => metaReadingFilter(inner(next)); } + private void ReadRequestMetadata(JsonRpcRequest request) + { + JsonObject? meta = GetRequestMeta(request); + string? metadataProtocolVersion = GetProtocolVersionMeta(meta, out bool hasProtocolVersionMeta); + string? transportProtocolVersion = request.Context?.ProtocolVersion; + + ValidateProtocolVersionMatch(transportProtocolVersion, metadataProtocolVersion); + + bool establishedModernProtocol = McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion); + bool transportClaimsModernProtocol = + transportProtocolVersion is not null && + !McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion); + bool metadataClaimsModernProtocol = + hasProtocolVersionMeta && + !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion); + bool serverRequiresModernProtocol = + _initializeHandshakeProtocolVersions.Length == 0 && + _perRequestMetadataProtocolVersions.Length > 0; + + if (establishedModernProtocol || + transportClaimsModernProtocol || + metadataClaimsModernProtocol || + serverRequiresModernProtocol) + { + string protocolVersionForError = + metadataProtocolVersion ?? + transportProtocolVersion ?? + _negotiatedProtocolVersion ?? + _perRequestMetadataProtocolVersions[0]; + + if (!hasProtocolVersionMeta) + { + if (transportProtocolVersion is not null && + !_supportedProtocolVersions.Contains(transportProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: transportProtocolVersion, + supported: _supportedProtocolVersions); + } + + ThrowMissingPerRequestMetadata(protocolVersionForError, MetaKeys.ProtocolVersion); + } + + if (!_supportedProtocolVersions.Contains(metadataProtocolVersion!)) + { + throw new UnsupportedProtocolVersionException( + requested: metadataProtocolVersion!, + supported: _perRequestMetadataProtocolVersions.Length > 0 + ? _perRequestMetadataProtocolVersions + : _supportedProtocolVersions); + } + + bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; + if (protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(metadataProtocolVersion!); + } + + ValidateRequiredPerRequestMetadata( + metadataProtocolVersion!, + hasProtocolVersionMeta, + meta?.ContainsKey(MetaKeys.ClientCapabilities) is true); + ProjectModernMetadata(request, meta!); + + if (!protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(metadataProtocolVersion!); + } + + return; + } + + // A transport-level legacy version remains authoritative. A known legacy value in _meta is + // forward-compatible metadata only and neither establishes nor changes the session version. + if (transportProtocolVersion is not null) + { + if (!_supportedProtocolVersions.Contains(transportProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: transportProtocolVersion, + supported: _supportedProtocolVersions); + } + + SetNegotiatedProtocolVersion(transportProtocolVersion); + } + + if (_negotiatedProtocolVersion is null && request.Method == RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", + McpErrorCode.InvalidParams); + } + + ProjectLegacyMetadata(request, meta); + } + + private static void ProjectInitializeRequestMetadata(JsonRpcRequest request) + { + JsonObject? meta = GetRequestMeta(request); + string? metadataProtocolVersion = GetProtocolVersionMeta(meta, out bool hasProtocolVersionMeta); + string? transportProtocolVersion = request.Context?.ProtocolVersion; + + ValidateProtocolVersionMatch(transportProtocolVersion, metadataProtocolVersion); + + if (hasProtocolVersionMeta && + !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion)) + { + (request.Context ??= new()).ProtocolVersion = metadataProtocolVersion; + return; + } + + ProjectLegacyMetadata(request, meta); + } + + private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta) + { + var context = request.Context ??= new(); + context.ProtocolVersion = GetProtocolVersionMeta(meta, out _); + context.ClientInfo = meta[MetaKeys.ClientInfo] is JsonNode clientInfoNode + ? DeserializeModernMetadata( + clientInfoNode, + McpJsonUtilities.JsonContext.Default.Implementation, + MetaKeys.ClientInfo) + : null; + context.ClientCapabilities = meta[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode + ? DeserializeModernMetadata( + clientCapabilitiesNode, + McpJsonUtilities.JsonContext.Default.ClientCapabilities, + MetaKeys.ClientCapabilities) + : throw InvalidMetadata(MetaKeys.ClientCapabilities); + context.LogLevel = meta[MetaKeys.LogLevel] is JsonNode logLevelNode + ? DeserializeModernMetadata( + logLevelNode, + McpJsonUtilities.JsonContext.Default.LoggingLevel, + MetaKeys.LogLevel) + : null; + } + + private static void ProjectLegacyMetadata(JsonRpcRequest request, JsonObject? meta) + { + if (meta is null) + { + return; + } + + var context = request.Context ??= new(); + + // These keys are not defined by legacy revisions. Project valid values for forward-compatible + // filters and stateless handlers, but leave malformed values opaque as required by legacy _meta. + if (TryDeserializeLegacyMetadata( + meta, + MetaKeys.ClientInfo, + McpJsonUtilities.JsonContext.Default.Implementation, + out Implementation? clientInfo)) + { + context.ClientInfo = clientInfo; + } + + if (TryDeserializeLegacyMetadata( + meta, + MetaKeys.ClientCapabilities, + McpJsonUtilities.JsonContext.Default.ClientCapabilities, + out ClientCapabilities? clientCapabilities)) + { + context.ClientCapabilities = clientCapabilities; + } + + if (TryDeserializeLegacyMetadata( + meta, + MetaKeys.LogLevel, + McpJsonUtilities.JsonContext.Default.LoggingLevel, + out LoggingLevel logLevel)) + { + context.LogLevel = logLevel; + } + } + + private static bool TryDeserializeLegacyMetadata( + JsonObject meta, + string key, + JsonTypeInfo typeInfo, + [NotNullWhen(true)] out T? value) + { + value = default; + if (meta[key] is not JsonNode node) + { + return false; + } + + try + { + value = JsonSerializer.Deserialize(node, typeInfo); + return value is not null; + } + catch (JsonException) + { + return false; + } + } + + private static T DeserializeModernMetadata(JsonNode node, JsonTypeInfo typeInfo, string key) + { + try + { + T? value = JsonSerializer.Deserialize(node, typeInfo); + return value is not null ? value : throw new JsonException(); + } + catch (JsonException ex) + { + throw new McpProtocolException( + $"The per-request metadata key '_meta/{key}' has an invalid value.", + ex, + McpErrorCode.InvalidParams); + } + } + + private static McpProtocolException InvalidMetadata(string key) => + new($"The per-request metadata key '_meta/{key}' has an invalid value.", McpErrorCode.InvalidParams); + + private static JsonObject? GetRequestMeta(JsonRpcRequest request) => + request.Params is JsonObject paramsObj ? paramsObj["_meta"] as JsonObject : null; + + private static string? GetProtocolVersionMeta(JsonObject? meta, out bool hasProtocolVersionMeta) + { + hasProtocolVersionMeta = meta?.ContainsKey(MetaKeys.ProtocolVersion) is true; + if (!hasProtocolVersionMeta) + { + return null; + } + + if (meta![MetaKeys.ProtocolVersion] is JsonValue value && + value.TryGetValue(out string? protocolVersion)) + { + return protocolVersion; + } + + throw InvalidMetadata(MetaKeys.ProtocolVersion); + } + + private static void ValidateProtocolVersionMatch( + string? transportProtocolVersion, + string? metadataProtocolVersion) + { + if (transportProtocolVersion is not null && + metadataProtocolVersion is not null && + !string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal) && + (!McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion) || + !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion))) + { + throw new McpProtocolException( + $"Header mismatch: the per-request _meta protocol version '{metadataProtocolVersion}' does not match the MCP-Protocol-Version header value '{transportProtocolVersion}'.", + McpErrorCode.HeaderMismatch); + } + } + private static void ValidateRequiredPerRequestMetadata( string protocolVersion, bool hasProtocolVersionMeta, @@ -314,33 +491,6 @@ private static void ThrowMissingPerRequestMetadata(string protocolVersion, strin $"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.", McpErrorCode.InvalidParams); - private static void ThrowReservedPerRequestMetadata(string? requestedProtocolVersion, string key) => - throw new McpProtocolException( - requestedProtocolVersion is null - ? $"The reserved per-request metadata key '_meta/{key}' requires a protocol version that uses per-request metadata." - : $"The reserved per-request metadata key '_meta/{key}' is not valid with protocol version '{requestedProtocolVersion}'.", - McpErrorCode.InvalidRequest); - - private static bool TryGetPerRequestMetadataKey(JsonRpcRequest request, out string key) - { - foreach (var candidate in s_perRequestMetadataKeys) - { - if (HasMetaKey(request, candidate)) - { - key = candidate; - return true; - } - } - - key = ""; - return false; - } - - private static bool HasMetaKey(JsonRpcRequest request, string key) => - request.Params is JsonObject paramsObj && - paramsObj["_meta"] is JsonObject metaObj && - metaObj.ContainsKey(key); - /// /// Adds the server identity to every successful result on per-request-metadata protocol revisions. /// The filter runs before application filters so they can inspect or intentionally remove the metadata. @@ -390,22 +540,6 @@ private void ValidateInitializeRequestBoundary(JsonRpcRequest request) message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake."); } - if (TryGetPerRequestMetadataKey(request, out var key)) - { - ThrowReservedPerRequestMetadata(TryGetStringParam(request, "protocolVersion"), key); - } - } - - private static string? TryGetStringParam(JsonRpcRequest request, string propertyName) - { - if (request.Params is JsonObject paramsObj && - paramsObj[propertyName] is JsonValue value && - value.TryGetValue(out string? result)) - { - return result; - } - - return null; } private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 8520f929c..1a604c871 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -432,6 +432,26 @@ public async Task DownlevelToolsList_On2025_11_25_OmitsResultTypeAndCacheHints() Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); } + [Fact] + public async Task Legacy2025Post_WithAuxiliaryPerRequestMetadata_Succeeds() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""legacy_meta_probe"",""arguments"":{}," + + @"""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{""sampling"":{}}}}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal( + "chatgpt|chatgpt|request-sampling|no-stateless-backchannel", + json["result"]!["content"]![0]!["text"]!.GetValue()); + } + [Fact] public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405() { @@ -500,6 +520,18 @@ public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvali [McpServerToolType] private sealed class CapabilityTools { + [McpServerTool(Name = "legacy_meta_probe")] + public static string LegacyMetaProbe(RequestContext context) + { + var requestContext = context.JsonRpcRequest.Context; + return string.Join( + '|', + requestContext?.ClientInfo?.Name, + context.Server.ClientInfo?.Name, + requestContext?.ClientCapabilities?.Sampling is null ? "no-request-sampling" : "request-sampling", + context.Server.ClientCapabilities is null ? "no-stateless-backchannel" : "stateless-backchannel"); + } + [McpServerTool(Name = "requires_sampling")] public static string RequiresSampling() => throw new MissingRequiredClientCapabilityException( diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 3afbb52eb..37349fab2 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -137,6 +137,53 @@ public async Task ToolCallWithMetaFields() Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializedSessionState() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + Assert.Equal("request-client", context.JsonRpcRequest.Context?.ClientInfo?.Name); + Assert.NotNull(context.JsonRpcRequest.Context?.ClientCapabilities?.Sampling); + + Assert.Equal("initialized-client", context.Server.ClientInfo?.Name); + Assert.NotNull(context.Server.ClientCapabilities?.Elicitation); + Assert.Null(context.Server.ClientCapabilities?.Sampling); + + return "ok"; + }, + new() { Name = "legacy_meta_tool" })); + + var clientOptions = new McpClientOptions + { + ProtocolVersion = LatestStableVersion, + ClientInfo = new Implementation { Name = "initialized-client", Version = "1.0.0" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + new CallToolRequestParams + { + Name = "legacy_meta_tool", + Meta = new JsonObject + { + [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode( + new Implementation { Name = "request-client", Version = "2.0.0" }, + McpJsonUtilities.DefaultOptions), + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal("ok", Assert.IsType(Assert.Single(result.Content)).Text); + } + [Fact] public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index e54f40dcb..657218067 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -293,7 +293,7 @@ await Can_Handle_Requests( } [Fact] - public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() + public async Task LegacyTransportProtocolVersion_RemainsAuthoritativeOverMetadata() { var ct = TestContext.Current.CancellationToken; await using var transport = new TestServerTransport(); @@ -303,15 +303,10 @@ public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVer await using var server = McpServer.Create(transport, options, LoggerFactory); var runTask = server.RunAsync(ct); - var rejectedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var acceptedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); transport.OnMessageSent = message => { - if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1") - { - rejectedResponse.TrySetResult(error); - } - else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2") + if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1") { acceptedResponse.TrySetResult(message); } @@ -320,16 +315,12 @@ public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVer await transport.SendClientMessageAsync(new JsonRpcRequest { Id = new RequestId(1), - Method = RequestMethods.ToolsList, + Method = RequestMethods.Ping, Params = new JsonObject { ["_meta"] = new JsonObject { - [MetaKeys.ClientInfo] = new JsonObject - { - ["name"] = "test-client", - ["version"] = "1.0.0", - }, + [MetaKeys.ProtocolVersion] = McpProtocolVersions.March2025ProtocolVersion, }, }, Context = new JsonRpcMessageContext @@ -339,35 +330,9 @@ await transport.SendClientMessageAsync(new JsonRpcRequest }, }, ct); - var error = await rejectedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); - Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); - Assert.Null(server.NegotiatedProtocolVersion); - - var clientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }; - var clientCapabilities = new ClientCapabilities(); - await transport.SendClientMessageAsync(new JsonRpcRequest - { - Id = new RequestId(2), - Method = RequestMethods.ToolsList, - Params = new JsonObject - { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, - [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.DefaultOptions), - [MetaKeys.ClientCapabilities] = new JsonObject(), - }, - }, - Context = new JsonRpcMessageContext - { - ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, - ClientInfo = clientInfo, - ClientCapabilities = clientCapabilities, - }, - }, ct); - - await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); - Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, server.NegotiatedProtocolVersion); + Assert.IsType( + await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, server.NegotiatedProtocolVersion); await transport.DisposeAsync(); await runTask; diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index d8cadeb61..a7cd6a39b 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -25,6 +25,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl private readonly Pipe _serverToClient = new(); private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); private readonly ServiceProvider _services; + private readonly McpServer _server; private readonly Task _serverTask; private readonly StreamWriter _writer; private readonly StreamReader _reader; @@ -41,8 +42,8 @@ public NegotiatedProtocolVersionTests(ITestOutputHelper testOutputHelper) .WithTools(); _services = serviceCollection.BuildServiceProvider(validateScopes: true); - var server = _services.GetRequiredService(); - _serverTask = server.RunAsync(_cts.Token); + _server = _services.GetRequiredService(); + _serverTask = _server.RunAsync(_cts.Token); _writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true }; _reader = new StreamReader(_serverToClient.Reader.AsStream()); @@ -69,16 +70,17 @@ public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterCha } [Fact] - public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize() + public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory() { var ct = TestContext.Current.CancellationToken; - var error = Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); - Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); - Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + Assert.IsType( + await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); - // The rejected initialize-handshake _meta request must not have established session state. + // The advisory legacy value must not block a subsequent modern request from selecting its era. Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); } [Fact] @@ -142,7 +144,7 @@ public async Task Initialize_WithPerRequestMetadataProtocolVersion_IsRejected() } [Fact] - public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() + public async Task Initialize_WithAuxiliaryPerRequestMetadata_IsAccepted() { var ct = TestContext.Current.CancellationToken; @@ -162,13 +164,93 @@ public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() ["name"] = "per-request-meta-client", ["version"] = "1.0.0", }, + [MetaKeys.ClientCapabilities] = new JsonObject + { + ["sampling"] = new JsonObject(), + }, }, }, McpJsonUtilities.DefaultOptions), }; - var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.March2025ProtocolVersion, ct)); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_RejectsModernProtocolVersionClaim() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var error = Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); - Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", + }, + }, + }; + + Assert.IsType(await SendAndReceiveAsync(request, ct)); + } + + [Fact] + public async Task ModernRequest_RejectsMalformedRequiredMetadata() + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientCapabilities] = "not-an-object", + }, + }, + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 6fa9fa215..af4002e43 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -123,23 +123,23 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_IgnoresPerRequestCapability() { await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy - // request. The server rejects reserved per-request metadata before it can affect behavior. - var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsTaskAsync( + // A forward-compatible client may carry SEP-2575 capabilities on a legacy request. The + // metadata is tolerated, but it cannot opt the legacy session into modern-only tasks. + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "forged"), Meta = CreateForgedTaskOptInMeta(), - }, ct)); + }, ct); - Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode); - Assert.Contains(ClientCapabilitiesMetaKey, ex.Message); + Assert.False(result.IsTask); + Assert.NotNull(result.Result); } [Fact] From 551356aff9bd86ea688a1a7d1698b97d229a03f8 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 6 Aug 2026 17:28:11 -0700 Subject: [PATCH 2/6] Fix modern logging capability advertisement Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Server/McpServerImpl.cs | 43 ++++++++++++++----- .../RawHttpConformanceTests.cs | 3 ++ .../Server/McpServerTests.cs | 1 + 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 19480066b..96e1403bf 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -645,13 +645,17 @@ private void SetNegotiatedProtocolVersion(string protocolVersion) public ServerCapabilities ServerCapabilities { get; } /// - /// Returns the to advertise in a specific response, suppressing the - /// listChanged flags the server has no way to honor. + /// Returns the to advertise in a specific response, suppressing + /// capabilities that are not available on that response's protocol path. /// /// /// when the client this response targets can receive */list_changed /// notifications over a subscriptions/listen stream. /// + /// + /// for legacy initialize responses that support logging/setLevel; + /// for modern discover responses, where that method is unavailable. + /// /// /// A stateless HTTP server has no session-wide channel to push unsolicited */list_changed /// notifications. It can only deliver them over a subscriptions/listen stream, which requires both @@ -659,11 +663,15 @@ private void SetNegotiatedProtocolVersion(string protocolVersion) /// to own that stream (the built-in stateless /// handler grants no notifications). When neither the transport is stateful nor that stream can carry /// them, the listChanged flags are dropped so the server never advertises a capability it cannot - /// deliver. Everything else (for example resources.subscribe) is preserved. + /// deliver. The deprecated logging capability is likewise omitted from modern discovery because this SDK + /// rejects the legacy logging/setLevel method on that path. Everything else is preserved. /// - private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged) + private ServerCapabilities GetAdvertisedCapabilities( + bool listenStreamCanDeliverListChanged, + bool includeDeprecatedLogging) { - if (HasStatefulTransport() || listenStreamCanDeliverListChanged) + bool includeListChanged = HasStatefulTransport() || listenStreamCanDeliverListChanged; + if (includeListChanged && includeDeprecatedLogging) { return ServerCapabilities; } @@ -673,14 +681,24 @@ private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliver return new ServerCapabilities { Experimental = ServerCapabilities.Experimental, - Logging = ServerCapabilities.Logging, + Logging = includeDeprecatedLogging ? ServerCapabilities.Logging : null, Completions = ServerCapabilities.Completions, Extensions = ServerCapabilities.Extensions, - Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null }, + Prompts = ServerCapabilities.Prompts is null + ? null + : includeListChanged + ? ServerCapabilities.Prompts + : new PromptsCapability { ListChanged = null }, Resources = ServerCapabilities.Resources is { } resources - ? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null } + ? includeListChanged + ? resources + : new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null } : null, - Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null }, + Tools = ServerCapabilities.Tools is null + ? null + : includeListChanged + ? ServerCapabilities.Tools + : new ToolsCapability { ListChanged = null }, }; } @@ -839,7 +857,9 @@ private void ConfigureInitialize(McpServerOptions options) // The initialize handshake only serves pre-2026-07-28 clients, which cannot open a // subscriptions/listen stream, so a stateless server has no way to deliver list-changed // notifications to them regardless of any custom handler. - Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false), + Capabilities = GetAdvertisedCapabilities( + listenStreamCanDeliverListChanged: false, + includeDeprecatedLogging: true), // resultType is a 2026-07-28 result field. The initialize handshake is only available on // 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw @@ -872,7 +892,8 @@ private void ConfigureDiscover(McpServerOptions options) // author supplied a custom handler to own that stream (the built-in stateless handler // grants nothing, so it cannot). Capabilities = GetAdvertisedCapabilities( - listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null), + listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null, + includeDeprecatedLogging: false), Instructions = options.ServerInstructions, // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to // the safest values (immediately stale, not shareable) so existing servers keep diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 1a604c871..51ea922c5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -132,6 +132,9 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + var capabilities = json["result"]!["capabilities"]!.AsObject(); + Assert.False(capabilities.ContainsKey("logging")); + Assert.True(capabilities.ContainsKey("tools")); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 657218067..3c0d15a1f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -289,6 +289,7 @@ await Can_Handle_Requests( Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024-11-05", result.ProtocolVersion); Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); + Assert.True(Assert.IsType(response)["capabilities"]!.AsObject().ContainsKey("logging")); }); } From 94059d740ef8f845174b694edf46fef9b706cd1b Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 3 Sep 2026 17:42:27 -0700 Subject: [PATCH 3/6] Expose request-scoped client capabilities Separate client capability visibility from whether the transport can safely issue server-to-client requests, preserving stateless sampling, roots, and elicitation guards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Protocol/JsonRpcMessageContext.cs | 3 +- .../Server/DestinationBoundMcpServer.cs | 17 ++-- .../Server/McpServer.Methods.cs | 41 ++++++--- .../Server/McpServer.cs | 11 +-- .../Server/McpServerImpl.cs | 2 + .../OutgoingRequestInterceptingMcpServer.cs | 2 + .../RawHttpConformanceTests.cs | 22 ++++- .../StatelessServerTests.cs | 85 +++++++++++++++++-- 8 files changed, 148 insertions(+), 35 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 8162af3a6..73e55b8c8 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -116,7 +116,8 @@ public sealed class JsonRpcMessageContext /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client /// capabilities from previous modern requests; the authoritative value is the one declared on each request. /// A legacy request may also carry this field for forward compatibility, but stateful legacy sessions continue - /// to use the capabilities negotiated during initialization. + /// to use the capabilities negotiated during initialization. Consequently, this low-level observed metadata + /// may differ from the effective capabilities exposed by the request-scoped . /// public ClientCapabilities? ClientCapabilities { get; set; } diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 7aab34826..2fa9d7fdc 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -23,14 +23,6 @@ public override ClientCapabilities? ClientCapabilities { get { - // In stateless transport mode, a single request does not have a persistent bidirectional channel. - // Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the - // capability gates rely on a null ClientCapabilities value to report that unsupported-state path. - if (!server.HasStatefulTransport()) - { - return null; - } - // On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request) // and must not be inferred from prior requests. Missing per-request capabilities therefore means // "no declared capabilities for this request", represented by an empty object. A fresh instance is @@ -41,12 +33,21 @@ public override ClientCapabilities? ClientCapabilities return _requestClientCapabilities ?? new ClientCapabilities(); } + // A stateless legacy request has no initialized session. Expose well-formed forward-compatible + // metadata to the handler without copying it into shared server state. + if (!server.HasStatefulTransport()) + { + return _requestClientCapabilities; + } + // Legacy protocol behavior uses session-scoped capabilities established during initialize (or // pre-populated migration data), so ignore per-request values and return the server session state. return server.ClientCapabilities; } } + internal override bool SupportsServerToClientRequests => server.SupportsServerToClientRequests; + public override Implementation? ClientInfo { get diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index a9a5dddfb..ad1ca37f1 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -25,6 +25,8 @@ public abstract partial class McpServer : McpSession internal virtual Func>? OutgoingRequestInterceptor => null; + internal virtual bool SupportsServerToClientRequests => true; + /// /// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor. /// @@ -555,26 +557,36 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty private void ThrowIfSamplingUnsupported() { - if (ClientCapabilities?.Sampling is null) + if (OutgoingRequestInterceptor is not null) { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Sampling is not supported in stateless mode."); - } + return; + } + if (!SupportsServerToClientRequests) + { + throw new InvalidOperationException("Sampling is not supported in stateless mode."); + } + + if (ClientCapabilities?.Sampling is null) + { throw new InvalidOperationException("Client does not support sampling."); } } private void ThrowIfRootsUnsupported() { - if (ClientCapabilities?.Roots is null) + if (OutgoingRequestInterceptor is not null) { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Roots are not supported in stateless mode."); - } + return; + } + + if (!SupportsServerToClientRequests) + { + throw new InvalidOperationException("Roots are not supported in stateless mode."); + } + if (ClientCapabilities?.Roots is null) + { throw new InvalidOperationException("Client does not support roots."); } } @@ -615,12 +627,17 @@ private async ValueTask SendRequestViaInterceptorAsync /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier), these /// capabilities are established once during initialization and are session-scoped: they are available both - /// on the root and on the server exposed to request handlers. + /// on the root and on the server exposed to request handlers. A stateless legacy + /// request has no initialized session; if it carries forward-compatible client-capability metadata, that + /// value is available on the request-scoped server. /// /// /// On the 2026-07-28 revision and later (SEP-2575) there is no initialize handshake; the client @@ -32,12 +34,11 @@ protected McpServer() /// the Server property of the passed to a handler; on the /// root (for example one constructed manually over a /// ) it is . - /// It is also in stateless transport mode, where server-to-client requests are - /// unsupported. /// /// - /// Server implementations can check these capabilities to determine which features - /// are available when interacting with the client. + /// This property reports capabilities declared by the client. Their presence does not guarantee that the + /// transport supports server-to-client requests. Methods such as sampling, roots, and elicitation reject + /// those requests when the transport cannot safely deliver them, including in stateless mode. /// /// public abstract ClientCapabilities? ClientCapabilities { get; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 96e1403bf..cbae60ccb 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -705,6 +705,8 @@ private ServerCapabilities GetAdvertisedCapabilities( /// public override ClientCapabilities? ClientCapabilities => _clientCapabilities; + internal override bool SupportsServerToClientRequests => HasStatefulTransport(); + /// public override Implementation? ClientInfo => _clientInfo; diff --git a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs index 93b7e5e73..5c39d8913 100644 --- a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs @@ -11,6 +11,8 @@ internal sealed class OutgoingRequestInterceptingMcpServer( { internal override Func>? OutgoingRequestInterceptor => interceptor; + internal override bool SupportsServerToClientRequests => true; + public override string? SessionId => server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 51ea922c5..a1b161e72 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -451,7 +451,7 @@ public async Task Legacy2025Post_WithAuxiliaryPerRequestMetadata_Succeeds() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal( - "chatgpt|chatgpt|request-sampling|no-stateless-backchannel", + "chatgpt|chatgpt|request-sampling|server-sampling|no-stateless-backchannel", json["result"]!["content"]![0]!["text"]!.GetValue()); } @@ -524,15 +524,31 @@ public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvali private sealed class CapabilityTools { [McpServerTool(Name = "legacy_meta_probe")] - public static string LegacyMetaProbe(RequestContext context) + public static async Task LegacyMetaProbe( + RequestContext context, + CancellationToken cancellationToken) { var requestContext = context.JsonRpcRequest.Context; + string backchannel; + try + { + await context.Server.ElicitAsync( + new ElicitRequestParams { Message = "test" }, + cancellationToken); + backchannel = "stateless-backchannel"; + } + catch (InvalidOperationException ex) when (ex.Message == "Elicitation is not supported in stateless mode.") + { + backchannel = "no-stateless-backchannel"; + } + return string.Join( '|', requestContext?.ClientInfo?.Name, context.Server.ClientInfo?.Name, requestContext?.ClientCapabilities?.Sampling is null ? "no-request-sampling" : "request-sampling", - context.Server.ClientCapabilities is null ? "no-stateless-backchannel" : "stateless-backchannel"); + context.Server.ClientCapabilities?.Sampling is null ? "no-server-sampling" : "server-sampling", + backchannel); } [McpServerTool(Name = "requires_sampling")] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 092f8f256..0009f1b34 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -9,6 +10,7 @@ using System.Net; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -154,6 +156,21 @@ public async Task ElicitRequest_Fails_WithInvalidOperationException() Assert.Equal("Server to client requests are not supported in stateless mode.", Assert.IsType(toolContent).Text); } + [Fact] + public async Task OutgoingRequestInterceptor_BypassesCapabilitiesAndTransportSupport() + { + await StartAsync(); + await using var client = await ConnectMcpClientAsync(); + + var toolResponse = await client.CallToolAsync( + "testOutgoingRequestInterceptor", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "intercepted|cancel", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + [Fact] public async Task UnsolicitedNotification_Fails_WithInvalidOperationException() { @@ -575,8 +592,8 @@ public static async Task TestSamplingErrors(McpServer server) { const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode."; - // Even when the client has sampling support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Sampling); var asSamplingChatClientEx = Assert.Throws(() => server.AsSamplingChatClient()); Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message); @@ -596,8 +613,8 @@ public static async Task TestRootsErrors(McpServer server) { const string expectedRootsErrorMessage = "Roots are not supported in stateless mode."; - // Even when the client has roots support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Roots); var requestRootsEx = Assert.Throws(() => server.RequestRootsAsync(new())); Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message); @@ -614,8 +631,8 @@ public static async Task TestElicitationErrors(McpServer server) { const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode."; - // Even when the client has elicitation support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Elicitation); var requestElicitationEx = await Assert.ThrowsAsync(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask()); Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message); @@ -627,6 +644,57 @@ public static async Task TestElicitationErrors(McpServer server) return ex.Message; } + [McpServerTool(Name = "testOutgoingRequestInterceptor")] + public static async Task TestOutgoingRequestInterceptor( + McpServer server, + CancellationToken cancellationToken) + { + Assert.Null(server.ClientCapabilities?.Sampling); + Assert.Null(server.ClientCapabilities?.Elicitation); + int interceptorCalls = 0; + +#pragma warning disable MCPEXP002 + McpServer interceptedServer = server.WithOutgoingRequestInterceptor((method, _, _) => + { + interceptorCalls++; + return new ValueTask(method switch + { + RequestMethods.SamplingCreateMessage => JsonSerializer.SerializeToNode( + new CreateMessageResult + { + Content = [new TextContentBlock { Text = "intercepted" }], + Model = "intercepted-model", + Role = Role.Assistant, + StopReason = "endTurn", + }, + McpJsonUtilities.DefaultOptions), + RequestMethods.ElicitationCreate => JsonSerializer.SerializeToNode( + new ElicitResult { Action = "cancel" }, + McpJsonUtilities.DefaultOptions), + _ => throw new InvalidOperationException($"Unexpected intercepted method '{method}'."), + }); + }); +#pragma warning restore MCPEXP002 + + ChatResponse samplingResponse = await interceptedServer.AsSamplingChatClient().GetResponseAsync( + [new ChatMessage(ChatRole.User, "test")], + cancellationToken: cancellationToken); + ElicitResult elicitationResponse = + await interceptedServer.ElicitAsync( + "test", + new RequestOptions + { + JsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }, + cancellationToken: cancellationToken); + + Assert.Equal(2, interceptorCalls); + return $"{samplingResponse.Text}|{elicitationResponse.Action}"; + } + [McpServerTool(Name = "testScope")] public static string? TestScope(ScopedService scopedService) => scopedService.State; @@ -635,6 +703,11 @@ public class ScopedService public string? State { get; set; } } + public sealed class TestElicitationForm + { + public string? Value { get; set; } + } + private class SynchronousProgress(Action handler) : IProgress { public void Report(T value) => handler(value); From 606765fd2d81ac78e1b7d52b70dffadbb04162d4 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 3 Sep 2026 18:51:55 -0700 Subject: [PATCH 4/6] Treat future metadata as opaque for legacy requests Skip parsing reserved per-request metadata once legacy semantics are authoritative, while retaining strict modern and metadata-only validation. Keep modern client capabilities request-scoped and independently gate stateless server-to-client requests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 8 + .../Protocol/JsonRpcMessageContext.cs | 13 +- .../Server/DestinationBoundMcpServer.cs | 9 +- .../Server/McpServer.Methods.cs | 15 -- .../Server/McpServer.cs | 4 +- .../Server/McpServerImpl.cs | 141 ++++-------------- .../OutgoingRequestInterceptingMcpServer.cs | 2 - .../RawHttpConformanceTests.cs | 51 +++---- .../StatelessServerTests.cs | 76 ++-------- .../Client/McpClientMetaTests.cs | 8 +- .../Server/NegotiatedProtocolVersionTests.cs | 45 ++++-- 11 files changed, 115 insertions(+), 257 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index f0b0b1a12..cba4382f5 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -836,6 +836,14 @@ private static bool ValidateProtocolVersionEnvelope( return false; } + if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader) || + (string.IsNullOrEmpty(protocolVersionHeader) && + message is JsonRpcRequest { Method: RequestMethods.Initialize })) + { + errorDetail = null; + return true; + } + bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 73e55b8c8..0b56caa29 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -92,8 +92,8 @@ public sealed class JsonRpcMessageContext /// /// /// The transport may populate this from a header such as Mcp-Protocol-Version. For modern revisions, - /// the server validates and projects the matching per-request _meta value. A known legacy version in - /// _meta is advisory and does not establish or change the negotiated session version. + /// the server validates and projects the matching per-request _meta value. Under an established legacy + /// revision, future reserved metadata remains opaque and does not establish or change the negotiated session version. /// public string? ProtocolVersion { get; set; } @@ -103,8 +103,8 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). When the request was made under the 2026-07-28 or later revision, - /// the server uses this in lieu of the value previously captured during the initialize handshake. A legacy request - /// may also carry this field for forward compatibility; stateful legacy sessions continue to use their initialized identity. + /// the server uses this in lieu of the value previously captured during the initialize handshake. Future reserved + /// metadata remains opaque under legacy revisions, which continue to use their initialized identity. /// public Implementation? ClientInfo { get; set; } @@ -115,9 +115,8 @@ public sealed class JsonRpcMessageContext /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client /// capabilities from previous modern requests; the authoritative value is the one declared on each request. - /// A legacy request may also carry this field for forward compatibility, but stateful legacy sessions continue - /// to use the capabilities negotiated during initialization. Consequently, this low-level observed metadata - /// may differ from the effective capabilities exposed by the request-scoped . + /// Future reserved metadata remains opaque under legacy revisions, which continue to use the capabilities + /// negotiated during initialization. /// public ClientCapabilities? ClientCapabilities { get; set; } diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 2fa9d7fdc..06a8777d1 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -33,15 +33,8 @@ public override ClientCapabilities? ClientCapabilities return _requestClientCapabilities ?? new ClientCapabilities(); } - // A stateless legacy request has no initialized session. Expose well-formed forward-compatible - // metadata to the handler without copying it into shared server state. - if (!server.HasStatefulTransport()) - { - return _requestClientCapabilities; - } - // Legacy protocol behavior uses session-scoped capabilities established during initialize (or - // pre-populated migration data), so ignore per-request values and return the server session state. + // pre-populated migration data). Future reserved metadata remains opaque under legacy semantics. return server.ClientCapabilities; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index ad1ca37f1..2e51f51c5 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -557,11 +557,6 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty private void ThrowIfSamplingUnsupported() { - if (OutgoingRequestInterceptor is not null) - { - return; - } - if (!SupportsServerToClientRequests) { throw new InvalidOperationException("Sampling is not supported in stateless mode."); @@ -575,11 +570,6 @@ private void ThrowIfSamplingUnsupported() private void ThrowIfRootsUnsupported() { - if (OutgoingRequestInterceptor is not null) - { - return; - } - if (!SupportsServerToClientRequests) { throw new InvalidOperationException("Roots are not supported in stateless mode."); @@ -627,11 +617,6 @@ private async ValueTask SendRequestViaInterceptorAsync /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier), these /// capabilities are established once during initialization and are session-scoped: they are available both - /// on the root and on the server exposed to request handlers. A stateless legacy - /// request has no initialized session; if it carries forward-compatible client-capability metadata, that - /// value is available on the request-scoped server. + /// on the root and on the server exposed to request handlers. /// /// /// On the 2026-07-28 revision and later (SEP-2575) there is no initialize handshake; the client diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index cbae60ccb..9ec4387b0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -153,7 +152,7 @@ void Register(McpServerPrimitiveCollection? collection, /// /// Wraps so that, for every JSON-RPC request, a built-in filter first - /// classifies and projects per-request _meta, synchronizes server-side state + /// classifies and projects modern per-request _meta, synchronizes server-side state /// (, ), and validates protocol /// boundaries before delegating to user-supplied incoming filters. /// @@ -162,10 +161,9 @@ void Register(McpServerPrimitiveCollection? collection, /// version and client capabilities MUST be populated per-request. Client info is optional. Per-request client /// capabilities and client info are consumed request-scoped by and are /// not read from server-wide state by request handlers. The shared write below is - /// best-effort and used only to derive the session endpoint name for logging/telemetry. For initialize-handshake - /// clients, known legacy protocol-version metadata and auxiliary per-request values are advisory compatibility - /// data. Stateful sessions continue to use the values captured during initialization, while stateless requests - /// may consume well-formed projected values from their request context. Modern envelopes remain strictly parsed. + /// best-effort and used only to derive the session endpoint name for logging/telemetry. Under initialize-handshake + /// revisions, reserved per-request metadata is opaque and is not parsed or projected. Modern envelopes remain + /// strictly parsed. /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { @@ -175,7 +173,6 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner { if (request.Method == RequestMethods.Initialize) { - ProjectInitializeRequestMetadata(request); ValidateInitializeRequestBoundary(request); } else @@ -184,18 +181,13 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner ValidateRequestMethodBoundary(request); var context = request.Context; - bool useRequestScopedClientInfo = - !HasStatefulTransport() || - McpProtocolVersions.RequiresPerRequestMetadata(context?.ProtocolVersion ?? _negotiatedProtocolVersion); - if (useRequestScopedClientInfo && + if (McpProtocolVersions.RequiresPerRequestMetadata(context?.ProtocolVersion ?? _negotiatedProtocolVersion) && context?.ClientInfo is { } clientInfo && (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { - // Modern handlers resolve client info request-scoped through DestinationBoundMcpServer. This - // shared write is only for endpoint logging. Stateless legacy servers are created per request, - // so retaining the request identity here also makes it available through McpServer.ClientInfo. - // Stateful legacy sessions keep the identity established by initialize. + // Modern handlers resolve client info request-scoped through DestinationBoundMcpServer. + // This shared write is only for endpoint logging. _clientInfo = clientInfo; UpdateEndpointNameWithClientInfo(); _sessionHandler.EndpointName = _endpointName; @@ -215,9 +207,31 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner private void ReadRequestMetadata(JsonRpcRequest request) { + string? transportProtocolVersion = request.Context?.ProtocolVersion; + + // An established legacy session or supported legacy transport header selects initialize-handshake + // semantics. Continue validating an authoritative transport version, but treat future body metadata + // as opaque under the negotiated legacy revision. + if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) || + McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion)) + { + if (transportProtocolVersion is not null) + { + if (!_supportedProtocolVersions.Contains(transportProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: transportProtocolVersion, + supported: _supportedProtocolVersions); + } + + SetNegotiatedProtocolVersion(transportProtocolVersion); + } + + return; + } + JsonObject? meta = GetRequestMeta(request); string? metadataProtocolVersion = GetProtocolVersionMeta(meta, out bool hasProtocolVersionMeta); - string? transportProtocolVersion = request.Context?.ProtocolVersion; ValidateProtocolVersionMatch(transportProtocolVersion, metadataProtocolVersion); @@ -285,20 +299,6 @@ transportProtocolVersion is not null && return; } - // A transport-level legacy version remains authoritative. A known legacy value in _meta is - // forward-compatible metadata only and neither establishes nor changes the session version. - if (transportProtocolVersion is not null) - { - if (!_supportedProtocolVersions.Contains(transportProtocolVersion)) - { - throw new UnsupportedProtocolVersionException( - requested: transportProtocolVersion, - supported: _supportedProtocolVersions); - } - - SetNegotiatedProtocolVersion(transportProtocolVersion); - } - if (_negotiatedProtocolVersion is null && request.Method == RequestMethods.ServerDiscover) { throw new McpProtocolException( @@ -306,25 +306,6 @@ transportProtocolVersion is not null && McpErrorCode.InvalidParams); } - ProjectLegacyMetadata(request, meta); - } - - private static void ProjectInitializeRequestMetadata(JsonRpcRequest request) - { - JsonObject? meta = GetRequestMeta(request); - string? metadataProtocolVersion = GetProtocolVersionMeta(meta, out bool hasProtocolVersionMeta); - string? transportProtocolVersion = request.Context?.ProtocolVersion; - - ValidateProtocolVersionMatch(transportProtocolVersion, metadataProtocolVersion); - - if (hasProtocolVersionMeta && - !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion)) - { - (request.Context ??= new()).ProtocolVersion = metadataProtocolVersion; - return; - } - - ProjectLegacyMetadata(request, meta); } private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta) @@ -351,68 +332,6 @@ private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject met : null; } - private static void ProjectLegacyMetadata(JsonRpcRequest request, JsonObject? meta) - { - if (meta is null) - { - return; - } - - var context = request.Context ??= new(); - - // These keys are not defined by legacy revisions. Project valid values for forward-compatible - // filters and stateless handlers, but leave malformed values opaque as required by legacy _meta. - if (TryDeserializeLegacyMetadata( - meta, - MetaKeys.ClientInfo, - McpJsonUtilities.JsonContext.Default.Implementation, - out Implementation? clientInfo)) - { - context.ClientInfo = clientInfo; - } - - if (TryDeserializeLegacyMetadata( - meta, - MetaKeys.ClientCapabilities, - McpJsonUtilities.JsonContext.Default.ClientCapabilities, - out ClientCapabilities? clientCapabilities)) - { - context.ClientCapabilities = clientCapabilities; - } - - if (TryDeserializeLegacyMetadata( - meta, - MetaKeys.LogLevel, - McpJsonUtilities.JsonContext.Default.LoggingLevel, - out LoggingLevel logLevel)) - { - context.LogLevel = logLevel; - } - } - - private static bool TryDeserializeLegacyMetadata( - JsonObject meta, - string key, - JsonTypeInfo typeInfo, - [NotNullWhen(true)] out T? value) - { - value = default; - if (meta[key] is not JsonNode node) - { - return false; - } - - try - { - value = JsonSerializer.Deserialize(node, typeInfo); - return value is not null; - } - catch (JsonException) - { - return false; - } - } - private static T DeserializeModernMetadata(JsonNode node, JsonTypeInfo typeInfo, string key) { try diff --git a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs index 5c39d8913..93b7e5e73 100644 --- a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs @@ -11,8 +11,6 @@ internal sealed class OutgoingRequestInterceptingMcpServer( { internal override Func>? OutgoingRequestInterceptor => interceptor; - internal override bool SupportsServerToClientRequests => true; - public override string? SessionId => server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index a1b161e72..78be6ba9b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -387,11 +387,13 @@ public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBo } [Fact] - public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() + public async Task InitializeHandshake_IgnoresFutureMetadata_OnDefaultServer() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -435,14 +437,19 @@ public async Task DownlevelToolsList_On2025_11_25_OmitsResultTypeAndCacheHints() Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); } - [Fact] - public async Task Legacy2025Post_WithAuxiliaryPerRequestMetadata_Succeeds() + [Theory] + [InlineData(@"""2025-11-25""")] + [InlineData(@"""2026-07-28""")] + [InlineData(@"""9999-99-99""")] + [InlineData("{}")] + public async Task Legacy2025Post_IgnoresFuturePerRequestMetadata(string protocolVersionJson) { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""legacy_meta_probe"",""arguments"":{}," + - @"""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + "," + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{""sampling"":{}}}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); @@ -450,9 +457,7 @@ public async Task Legacy2025Post_WithAuxiliaryPerRequestMetadata_Succeeds() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.Equal( - "chatgpt|chatgpt|request-sampling|server-sampling|no-stateless-backchannel", - json["result"]!["content"]![0]!["text"]!.GetValue()); + Assert.Equal("future-metadata-ignored", json["result"]!["content"]![0]!["text"]!.GetValue()); } [Fact] @@ -524,31 +529,15 @@ public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvali private sealed class CapabilityTools { [McpServerTool(Name = "legacy_meta_probe")] - public static async Task LegacyMetaProbe( - RequestContext context, - CancellationToken cancellationToken) + public static string LegacyMetaProbe(RequestContext context) { var requestContext = context.JsonRpcRequest.Context; - string backchannel; - try - { - await context.Server.ElicitAsync( - new ElicitRequestParams { Message = "test" }, - cancellationToken); - backchannel = "stateless-backchannel"; - } - catch (InvalidOperationException ex) when (ex.Message == "Elicitation is not supported in stateless mode.") - { - backchannel = "no-stateless-backchannel"; - } - - return string.Join( - '|', - requestContext?.ClientInfo?.Name, - context.Server.ClientInfo?.Name, - requestContext?.ClientCapabilities?.Sampling is null ? "no-request-sampling" : "request-sampling", - context.Server.ClientCapabilities?.Sampling is null ? "no-server-sampling" : "server-sampling", - backchannel); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, requestContext?.ProtocolVersion); + Assert.Null(requestContext?.ClientInfo); + Assert.Null(requestContext?.ClientCapabilities); + Assert.Null(context.Server.ClientInfo); + Assert.Null(context.Server.ClientCapabilities); + return "future-metadata-ignored"; } [McpServerTool(Name = "requires_sampling")] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 0009f1b34..42c05f257 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -10,7 +9,6 @@ using System.Net; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization.Metadata; using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -157,17 +155,21 @@ public async Task ElicitRequest_Fails_WithInvalidOperationException() } [Fact] - public async Task OutgoingRequestInterceptor_BypassesCapabilitiesAndTransportSupport() + public async Task ClientCapabilities_AreAvailableFromInjectedServer() { await StartAsync(); - await using var client = await ConnectMcpClientAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.RootsHandler = (_, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); var toolResponse = await client.CallToolAsync( - "testOutgoingRequestInterceptor", + "getClientCapabilities", cancellationToken: TestContext.Current.CancellationToken); Assert.Equal( - "intercepted|cancel", + "sampling|roots|elicitation", Assert.IsType(Assert.Single(toolResponse.Content)).Text); } @@ -644,56 +646,13 @@ public static async Task TestElicitationErrors(McpServer server) return ex.Message; } - [McpServerTool(Name = "testOutgoingRequestInterceptor")] - public static async Task TestOutgoingRequestInterceptor( - McpServer server, - CancellationToken cancellationToken) - { - Assert.Null(server.ClientCapabilities?.Sampling); - Assert.Null(server.ClientCapabilities?.Elicitation); - int interceptorCalls = 0; - -#pragma warning disable MCPEXP002 - McpServer interceptedServer = server.WithOutgoingRequestInterceptor((method, _, _) => - { - interceptorCalls++; - return new ValueTask(method switch - { - RequestMethods.SamplingCreateMessage => JsonSerializer.SerializeToNode( - new CreateMessageResult - { - Content = [new TextContentBlock { Text = "intercepted" }], - Model = "intercepted-model", - Role = Role.Assistant, - StopReason = "endTurn", - }, - McpJsonUtilities.DefaultOptions), - RequestMethods.ElicitationCreate => JsonSerializer.SerializeToNode( - new ElicitResult { Action = "cancel" }, - McpJsonUtilities.DefaultOptions), - _ => throw new InvalidOperationException($"Unexpected intercepted method '{method}'."), - }); - }); -#pragma warning restore MCPEXP002 - - ChatResponse samplingResponse = await interceptedServer.AsSamplingChatClient().GetResponseAsync( - [new ChatMessage(ChatRole.User, "test")], - cancellationToken: cancellationToken); - ElicitResult elicitationResponse = - await interceptedServer.ElicitAsync( - "test", - new RequestOptions - { - JsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - }, - }, - cancellationToken: cancellationToken); - - Assert.Equal(2, interceptorCalls); - return $"{samplingResponse.Text}|{elicitationResponse.Action}"; - } + [McpServerTool(Name = "getClientCapabilities")] + public static string GetClientCapabilities(McpServer server) => + string.Join( + '|', + server.ClientCapabilities?.Sampling is null ? "no-sampling" : "sampling", + server.ClientCapabilities?.Roots is null ? "no-roots" : "roots", + server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation"); [McpServerTool(Name = "testScope")] public static string? TestScope(ScopedService scopedService) => scopedService.State; @@ -703,11 +662,6 @@ public class ScopedService public string? State { get; set; } } - public sealed class TestElicitationForm - { - public string? Value { get; set; } - } - private class SynchronousProgress(Action handler) : IProgress { public void Report(T value) => handler(value); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 37349fab2..94558a60e 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -143,8 +143,8 @@ public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializ Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( (RequestContext context) => { - Assert.Equal("request-client", context.JsonRpcRequest.Context?.ClientInfo?.Name); - Assert.NotNull(context.JsonRpcRequest.Context?.ClientCapabilities?.Sampling); + Assert.Null(context.JsonRpcRequest.Context?.ClientInfo); + Assert.Null(context.JsonRpcRequest.Context?.ClientCapabilities); Assert.Equal("initialized-client", context.Server.ClientInfo?.Name); Assert.NotNull(context.Server.ClientCapabilities?.Elicitation); @@ -297,9 +297,9 @@ public async Task RootServer_UnderJuly2026Protocol_HasNoClientCapabilities_ButHa ClientCapabilities? handlerObservedCapabilities = null; Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( - (RequestContext context) => + (McpServer server) => { - handlerObservedCapabilities = context.Server.ClientCapabilities; + handlerObservedCapabilities = server.ClientCapabilities; return "ok"; }, new() { Name = "capability_probe_tool" })); diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index a7cd6a39b..4ab40eee0 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -83,6 +83,28 @@ public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory() Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); } + [Fact] + public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = new JsonObject(), + }, + }, + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal); + } + [Fact] public async Task PerRequestMetadata_ServesRequestMissingClientInfo() { @@ -144,7 +166,7 @@ public async Task Initialize_WithPerRequestMetadataProtocolVersion_IsRejected() } [Fact] - public async Task Initialize_WithAuxiliaryPerRequestMetadata_IsAccepted() + public async Task Initialize_IgnoresFutureReservedMetadata() { var ct = TestContext.Current.CancellationToken; @@ -159,15 +181,10 @@ public async Task Initialize_WithAuxiliaryPerRequestMetadata_IsAccepted() ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, Meta = new JsonObject { - [MetaKeys.ClientInfo] = new JsonObject - { - ["name"] = "per-request-meta-client", - ["version"] = "1.0.0", - }, - [MetaKeys.ClientCapabilities] = new JsonObject - { - ["sampling"] = new JsonObject(), - }, + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", }, }, McpJsonUtilities.DefaultOptions), }; @@ -190,17 +207,15 @@ public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMeta } [Fact] - public async Task LegacySession_RejectsModernProtocolVersionClaim() + public async Task LegacySession_IgnoresModernProtocolVersionMetadata() { var ct = TestContext.Current.CancellationToken; Assert.IsType( await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); - var error = Assert.IsType( + Assert.IsType( await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); - Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); - Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); } @@ -220,6 +235,7 @@ public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata() { ["_meta"] = new JsonObject { + [MetaKeys.ProtocolVersion] = new JsonObject(), [MetaKeys.ClientInfo] = "not-an-object", [MetaKeys.ClientCapabilities] = "not-an-object", [MetaKeys.LogLevel] = "not-a-level", @@ -274,7 +290,6 @@ public async Task SubscriptionsListen_WithInitializeProtocolVersion_IsRejected() } [Theory] - [InlineData("initialize")] [InlineData("ping")] [InlineData("logging/setLevel")] [InlineData("resources/subscribe")] From 45528226e1fd6a2e54e9c91a65eff1394d60647e Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 8 Sep 2026 09:02:11 -0700 Subject: [PATCH 5/6] Make the interceptor's delivery capability explicit An outgoing-request interceptor replaces the session's server-to-client channel, so it can deliver sampling, roots, and elicitation requests even when the underlying transport cannot. That is what lets background task execution park a request in an IMcpTaskStore and have the client answer it on a later, unrelated request, which does not depend on session affinity. OutgoingRequestInterceptingMcpServer relied on inheriting the base SupportsServerToClientRequests default to get this, while forwarding every other member to the wrapped server. Make the override explicit so the behavior is stated rather than implied, and add stateless coverage for AsSamplingChatClient() and the generic ElicitAsync(), which are the two APIs that check the capability guard eagerly instead of routing through the interceptor short-circuit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../OutgoingRequestInterceptingMcpServer.cs | 5 ++ .../StatelessServerTests.cs | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs index 93b7e5e73..e32a1607b 100644 --- a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs @@ -17,6 +17,11 @@ internal sealed class OutgoingRequestInterceptingMcpServer( public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities; + // The interceptor replaces the session's server-to-client channel, so it can deliver requests even + // when the underlying transport cannot (for example a stateless HTTP request, where a background task + // parks the request in an IMcpTaskStore and the client answers it on a later, unrelated request). + internal override bool SupportsServerToClientRequests => true; + public override Implementation? ClientInfo => server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 42c05f257..a95d989c5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -9,6 +10,7 @@ using System.Net; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -173,6 +175,24 @@ public async Task ClientCapabilities_AreAvailableFromInjectedServer() Assert.IsType(Assert.Single(toolResponse.Content)).Text); } + [Fact] + public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode() + { + await StartAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); + + var toolResponse = await client.CallToolAsync( + "testInterceptedRequests", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "intercepted-sample|Seattle", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + [Fact] public async Task UnsolicitedNotification_Fails_WithInvalidOperationException() { @@ -654,6 +674,31 @@ public static string GetClientCapabilities(McpServer server) => server.ClientCapabilities?.Roots is null ? "no-roots" : "roots", server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation"); + [McpServerTool(Name = "testInterceptedRequests")] + public static async Task TestInterceptedRequests(McpServer server) + { + // Background task execution replaces the server-to-client channel with an interceptor that parks the + // request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not + // depend on session affinity, which is why these requests are allowed through in stateless mode. +#pragma warning disable MCPEXP002 + var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch + { + RequestMethods.SamplingCreateMessage => + """{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""", + RequestMethods.ElicitationCreate => + """{"action":"accept","content":{"city":"Seattle"}}""", + _ => throw new UnreachableException(), + }))); +#pragma warning restore MCPEXP002 + + var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?"); + var elicitResult = await intercepted.ElicitAsync( + "Which city?", + options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options }); + + return $"{samplingResponse.Text}|{elicitResult.Content?.City}"; + } + [McpServerTool(Name = "testScope")] public static string? TestScope(ScopedService scopedService) => scopedService.State; @@ -667,3 +712,12 @@ private class SynchronousProgress(Action handler) : IProgress public void Report(T value) => handler(value); } } + +public class CityForm +{ + public string? City { get; set; } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CityForm))] +internal partial class StatelessInterceptorJsonContext : JsonSerializerContext; From 346a626c8c934636e85341071ca1e406e8332883 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 8 Sep 2026 17:21:49 -0700 Subject: [PATCH 6/6] Simplify protocol-era metadata validation Reject initialize after a modern session is established, while preserving fallback after a failed discovery probe. Let headerless requests reach the core era decision and tolerate unreadable metadata when no modern version is established, with a diagnostic warning for that ambiguous case. Remove unreachable era checks, redundant helpers, and duplicate version parsing. Preserve strict modern metadata validation, error precedence, and delayed version establishment for malformed first requests. Expand regression coverage for malformed values, authoritative transport versions, modern-only servers, and warning behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 26 +-- .../Server/McpServerImpl.cs | 165 ++++++------------ .../RawHttpConformanceTests.cs | 88 +++++++++- .../Server/McpServerTests.cs | 34 +++- .../Server/NegotiatedProtocolVersionTests.cs | 107 ++++++++++-- .../Server/RawStreamConformanceTests.cs | 36 +++- 6 files changed, 297 insertions(+), 159 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index cba4382f5..c2b6d2294 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -836,31 +836,17 @@ private static bool ValidateProtocolVersionEnvelope( return false; } - if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader) || - (string.IsNullOrEmpty(protocolVersionHeader) && - message is JsonRpcRequest { Method: RequestMethods.Initialize })) + // Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve + // the version from the session or metadata, falling back to legacy handling when neither selects one. + if (string.IsNullOrEmpty(protocolVersionHeader) || + McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader)) { errorDetail = null; return true; } - bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); - - if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && - !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta)) - { - errorDetail = null; - return true; - } - - if (string.IsNullOrEmpty(protocolVersionHeader)) - { - errorDetail = CreateHeaderMismatchError( - $"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version."); - return false; - } - - if (!hasProtocolVersionMeta) + // Header validity was checked first, so only supported modern headers reach this point. + if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta)) { // Notifications are exempt from the required-_meta rejection: they are fire-and-forget // (a JSON-RPC error response has no recipient), and rejecting them silently drops diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 9ec4387b0..2d02d8b2c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -230,34 +230,26 @@ private void ReadRequestMetadata(JsonRpcRequest request) return; } - JsonObject? meta = GetRequestMeta(request); - string? metadataProtocolVersion = GetProtocolVersionMeta(meta, out bool hasProtocolVersionMeta); - - ValidateProtocolVersionMatch(transportProtocolVersion, metadataProtocolVersion); - - bool establishedModernProtocol = McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion); - bool transportClaimsModernProtocol = - transportProtocolVersion is not null && - !McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion); - bool metadataClaimsModernProtocol = - hasProtocolVersionMeta && - !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion); - bool serverRequiresModernProtocol = - _initializeHandshakeProtocolVersions.Length == 0 && - _perRequestMetadataProtocolVersions.Length > 0; - - if (establishedModernProtocol || - transportClaimsModernProtocol || - metadataClaimsModernProtocol || - serverRequiresModernProtocol) - { - string protocolVersionForError = - metadataProtocolVersion ?? - transportProtocolVersion ?? - _negotiatedProtocolVersion ?? - _perRequestMetadataProtocolVersions[0]; - - if (!hasProtocolVersionMeta) + JsonObject? meta = request.Params is JsonObject paramsObj ? paramsObj["_meta"] as JsonObject : null; + // An unreadable value cannot select a protocol; only the modern path requires a usable version. + string? metadataProtocolVersion = + meta?[MetaKeys.ProtocolVersion] is JsonValue value && value.TryGetValue(out string? version) ? version : null; + + if (transportProtocolVersion is not null && + metadataProtocolVersion is not null && + !string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"Header mismatch: the per-request _meta protocol version '{metadataProtocolVersion}' does not match the MCP-Protocol-Version header value '{transportProtocolVersion}'.", + McpErrorCode.HeaderMismatch); + } + + if (McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) || + transportProtocolVersion is not null || + (metadataProtocolVersion is not null && !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion)) || + _initializeHandshakeProtocolVersions.Length == 0) + { + if (metadataProtocolVersion is null) { if (transportProtocolVersion is not null && !_supportedProtocolVersions.Contains(transportProtocolVersion)) @@ -267,51 +259,60 @@ transportProtocolVersion is not null && supported: _supportedProtocolVersions); } - ThrowMissingPerRequestMetadata(protocolVersionForError, MetaKeys.ProtocolVersion); + throw MissingPerRequestMetadata( + transportProtocolVersion ?? _negotiatedProtocolVersion ?? _perRequestMetadataProtocolVersions[0], + MetaKeys.ProtocolVersion); } - if (!_supportedProtocolVersions.Contains(metadataProtocolVersion!)) + if (!_supportedProtocolVersions.Contains(metadataProtocolVersion)) { throw new UnsupportedProtocolVersionException( - requested: metadataProtocolVersion!, + requested: metadataProtocolVersion, supported: _perRequestMetadataProtocolVersions.Length > 0 ? _perRequestMetadataProtocolVersions : _supportedProtocolVersions); } + // Reject version changes before parsing, but establish a new version only after parsing succeeds. bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; if (protocolVersionAlreadyEstablished) { - SetNegotiatedProtocolVersion(metadataProtocolVersion!); + SetNegotiatedProtocolVersion(metadataProtocolVersion); } - ValidateRequiredPerRequestMetadata( - metadataProtocolVersion!, - hasProtocolVersionMeta, - meta?.ContainsKey(MetaKeys.ClientCapabilities) is true); - ProjectModernMetadata(request, meta!); + ProjectModernMetadata(request, meta!, metadataProtocolVersion); if (!protocolVersionAlreadyEstablished) { - SetNegotiatedProtocolVersion(metadataProtocolVersion!); + SetNegotiatedProtocolVersion(metadataProtocolVersion); } return; } - if (_negotiatedProtocolVersion is null && request.Method == RequestMethods.ServerDiscover) + if (request.Method == RequestMethods.ServerDiscover) { throw new McpProtocolException( $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", McpErrorCode.InvalidParams); } + // With no established era, warn in case a modern peer accidentally fell back to legacy handling. + if (metadataProtocolVersion is null && meta?.ContainsKey(MetaKeys.ProtocolVersion) is true) + { + LogIgnoredUnreadableProtocolVersionMetadata(_endpointName, request.Method); + } } - private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta) + private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta, string protocolVersion) { + if (!meta.ContainsKey(MetaKeys.ClientCapabilities)) + { + throw MissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + } + var context = request.Context ??= new(); - context.ProtocolVersion = GetProtocolVersionMeta(meta, out _); + context.ProtocolVersion = protocolVersion; context.ClientInfo = meta[MetaKeys.ClientInfo] is JsonNode clientInfoNode ? DeserializeModernMetadata( clientInfoNode, @@ -351,62 +352,8 @@ private static T DeserializeModernMetadata(JsonNode node, JsonTypeInfo typ private static McpProtocolException InvalidMetadata(string key) => new($"The per-request metadata key '_meta/{key}' has an invalid value.", McpErrorCode.InvalidParams); - private static JsonObject? GetRequestMeta(JsonRpcRequest request) => - request.Params is JsonObject paramsObj ? paramsObj["_meta"] as JsonObject : null; - - private static string? GetProtocolVersionMeta(JsonObject? meta, out bool hasProtocolVersionMeta) - { - hasProtocolVersionMeta = meta?.ContainsKey(MetaKeys.ProtocolVersion) is true; - if (!hasProtocolVersionMeta) - { - return null; - } - - if (meta![MetaKeys.ProtocolVersion] is JsonValue value && - value.TryGetValue(out string? protocolVersion)) - { - return protocolVersion; - } - - throw InvalidMetadata(MetaKeys.ProtocolVersion); - } - - private static void ValidateProtocolVersionMatch( - string? transportProtocolVersion, - string? metadataProtocolVersion) - { - if (transportProtocolVersion is not null && - metadataProtocolVersion is not null && - !string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal) && - (!McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion) || - !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion))) - { - throw new McpProtocolException( - $"Header mismatch: the per-request _meta protocol version '{metadataProtocolVersion}' does not match the MCP-Protocol-Version header value '{transportProtocolVersion}'.", - McpErrorCode.HeaderMismatch); - } - } - - private static void ValidateRequiredPerRequestMetadata( - string protocolVersion, - bool hasProtocolVersionMeta, - bool hasClientCapabilitiesMeta) - { - if (!hasProtocolVersionMeta) - { - ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion); - } - - // clientInfo is optional: requests whose _meta omits it are served, not rejected. - - if (!hasClientCapabilitiesMeta) - { - ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); - } - } - - private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) => - throw new McpProtocolException( + private static McpProtocolException MissingPerRequestMetadata(string protocolVersion, string key) => + new( $"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.", McpErrorCode.InvalidParams); @@ -441,12 +388,17 @@ private JsonRpcMessageFilter PrependServerInfoFilter(JsonRpcMessageFilter inner, private void ValidateInitializeRequestBoundary(JsonRpcRequest request) { - // Per-request-metadata revisions (SEP-2575) removed the initialize handshake entirely: - // the request is for a method the server does not implement on that revision. - if (McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion)) + // Modern revisions removed initialize. An established modern session is authoritative even when + // the new request has no version header; a failed discovery probe does not establish a version. + string? modernProtocolVersion = + McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion) ? request.Context!.ProtocolVersion : + McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ? _negotiatedProtocolVersion : + null; + + if (modernProtocolVersion is not null) { throw new McpProtocolException( - $"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.", + $"Method '{RequestMethods.Initialize}' is not available on protocol version '{modernProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.", McpErrorCode.MethodNotFound); } @@ -458,7 +410,6 @@ private void ValidateInitializeRequestBoundary(JsonRpcRequest request) supported: _initializeHandshakeProtocolVersions, message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake."); } - } private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) @@ -761,11 +712,8 @@ private void ConfigureInitialize(McpServerOptions options) string negotiatedProtocolVersion = protocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; - // The initialize handshake is authoritative: it may supersede a protocol version - // a prior server/discover probe established on the same connection (the dual-path - // fallback path a permissive client takes against an unknown server). Unlike the - // per-request 2026-07-28 version - which SetNegotiatedProtocolVersion locks once negotiated - - // initialize force-sets the version. + // initialize may supersede a legacy transport version. ValidateInitializeRequestBoundary + // prevents it from downgrading an established modern session. _negotiatedProtocolVersion = negotiatedProtocolVersion; _sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion; @@ -2603,6 +2551,9 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] private partial void MrtrHandlerError(Exception exception); + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} ignored an unreadable '_meta/io.modelcontextprotocol/protocolVersion' value on '{Method}' and selected initialize-handshake semantics. The client may not be spec-compliant.")] + private partial void LogIgnoredUnreadableProtocolVersionMetadata(string endpointName, string method); + [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")] private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 78be6ba9b..bc5eec57b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -331,12 +331,19 @@ public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_Retu Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); } - [Fact] - public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602() + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson) { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; + var versionProperty = protocolVersionJson is null ? "" : @"""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + ","; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{" + versionProperty + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); @@ -352,8 +359,24 @@ public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_M Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } + [Theory] + [InlineData("{}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":{}}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":""2025-11-25""}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task ModernOnlyServer_HeaderlessRequest_StillRequiresModernMetadata(string metaJson, McpErrorCode expectedError) + { + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":" + metaJson + "}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)expectedError, json["error"]!["code"]!.GetValue()); + } + [Fact] - public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() + public async Task MissingProtocolVersionHeader_WithModernMetadata_IsServedFromBodyMetadata() { await StartAsync(); @@ -363,10 +386,61 @@ public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatc request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + // The 2025-03-26 header default only applies when the server has "no other way to identify the + // version". The body metadata is another way, so the transport does not reject the request on the + // strength of a reserved _meta value. stdio, which has no header at all, behaves identically. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); - Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + Assert.NotNull(json["result"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithUnsupportedMetadata_ReturnsUnsupportedProtocolVersion_Minus32022() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal("9999-99-99", json["error"]!["data"]!["requested"]!.GetValue()); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithMalformedMetadata_IsIgnored() + { + await StartAsync(); + + // No header and no established era means legacy handling, where the reserved namespace is opaque. + // A value we cannot read is not a version claim, so it must not turn a legacy request into an error. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":{}}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithLegacyMetadata_IsIgnored() + { + await StartAsync(); + + // A legacy reserved value on a headerless request stays opaque, which is the ChatGPT shape from #1783. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2025-06-18""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 3c0d15a1f..0ab19a373 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -293,8 +293,16 @@ await Can_Handle_Requests( }); } - [Fact] - public async Task LegacyTransportProtocolVersion_RemainsAuthoritativeOverMetadata() + [Theory] + [InlineData("2025-11-25", "\"2025-03-26\"", null)] + [InlineData("2025-11-25", "\"2026-07-28\"", null)] + [InlineData("2025-11-25", "{}", null)] + [InlineData("2026-07-28", "\"2025-11-25\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "\"9999-99-99\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "{}", McpErrorCode.InvalidParams)] + [InlineData("9999-99-99", "{}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task TransportProtocolVersion_IsValidatedBeforeBodyMetadata( + string transportVersion, string metadataVersionJson, McpErrorCode? expectedError) { var ct = TestContext.Current.CancellationToken; await using var transport = new TestServerTransport(); @@ -304,12 +312,12 @@ public async Task LegacyTransportProtocolVersion_RemainsAuthoritativeOverMetadat await using var server = McpServer.Create(transport, options, LoggerFactory); var runTask = server.RunAsync(ct); - var acceptedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); transport.OnMessageSent = message => { if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1") { - acceptedResponse.TrySetResult(message); + response.TrySetResult(message); } }; @@ -321,19 +329,27 @@ await transport.SendClientMessageAsync(new JsonRpcRequest { ["_meta"] = new JsonObject { - [MetaKeys.ProtocolVersion] = McpProtocolVersions.March2025ProtocolVersion, + [MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson), }, }, Context = new JsonRpcMessageContext { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + ProtocolVersion = transportVersion, ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, }, }, ct); - Assert.IsType( - await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct)); - Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, server.NegotiatedProtocolVersion); + var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + if (expectedError is { } errorCode) + { + Assert.Equal((int)errorCode, Assert.IsType(message).Error.Code); + Assert.Null(server.NegotiatedProtocolVersion); + } + else + { + Assert.IsType(message); + Assert.Equal(transportVersion, server.NegotiatedProtocolVersion); + } await transport.DisposeAsync(); await runTask; diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index 4ab40eee0..b4bf440ac 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -36,6 +36,7 @@ public NegotiatedProtocolVersionTests(ITestOutputHelper testOutputHelper) var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); serviceCollection.AddSingleton(XunitLoggerProvider); + serviceCollection.AddSingleton(MockLoggerProvider); serviceCollection .AddMcpServer() .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) @@ -77,14 +78,20 @@ public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory() Assert.IsType( await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); Assert.Null(_server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); // The advisory legacy value must not block a subsequent modern request from selecting its era. Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); } - [Fact] - public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsRejected() + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsIgnored(string protocolVersionJson) { var ct = TestContext.Current.CancellationToken; var request = new JsonRpcRequest @@ -92,6 +99,33 @@ public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsRejected() Id = new RequestId(1), Method = RequestMethods.ToolsList, Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson), + }, + }, + }; + + // An unreadable value leaves the request on the legacy path without establishing a version. + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + + var warning = Assert.Single( + MockLoggerProvider.LogMessages, + m => m.LogLevel == LogLevel.Warning && m.Message.Contains(MetaKeys.ProtocolVersion, StringComparison.Ordinal)); + Assert.Contains(RequestMethods.ToolsList, warning.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task MalformedProtocolVersionMetadata_OnServerDiscover_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject { ["_meta"] = new JsonObject { @@ -100,9 +134,52 @@ public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsRejected() }, }; + // server/discover exists only on per-request-metadata revisions, so it cannot fall back to legacy + // handling. This is what keeps a modern client from silently getting legacy treatment on a transport + // with no protocol version header. + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task ModernSession_RejectsMissingOrMalformedProtocolVersionMetadata(string? protocolVersionJson) + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); + + var meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + if (protocolVersionJson is not null) + { + meta[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson); + } + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = meta, + }, + }; + + // Once the era is established the metadata is required, so an unreadable value is an error rather + // than a reason to drop back to legacy handling. var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); } [Fact] @@ -134,6 +211,7 @@ await RoundTripAsync( Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); } [Fact] @@ -191,6 +269,7 @@ public async Task Initialize_IgnoresFutureReservedMetadata() Assert.IsType(await SendAndReceiveAsync(request, ct)); Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); } [Fact] @@ -244,29 +323,37 @@ public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata() }; Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); } - [Fact] - public async Task ModernRequest_RejectsMalformedRequiredMetadata() + [Theory] + [InlineData(MetaKeys.ClientCapabilities, "\"not-an-object\"")] + [InlineData(MetaKeys.ClientCapabilities, "null")] + [InlineData(MetaKeys.ClientCapabilities, "[]")] + [InlineData(MetaKeys.ClientInfo, "\"not-an-object\"")] + [InlineData(MetaKeys.LogLevel, "\"not-a-level\"")] + public async Task ModernRequest_RejectsMalformedMetadata_WithoutEstablishingVersion(string key, string valueJson) { var ct = TestContext.Current.CancellationToken; + var meta = PerRequestMetadata(); + meta[key] = JsonNode.Parse(valueJson); var request = new JsonRpcRequest { Id = new RequestId(1), Method = RequestMethods.ToolsList, Params = new JsonObject { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, - [MetaKeys.ClientCapabilities] = "not-an-object", - }, + ["_meta"] = meta, }, }; var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); - Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); + Assert.Contains(key, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); + + Assert.IsType( + await RoundTripInitializeAsync(id: 2, McpProtocolVersions.November2025ProtocolVersion, ct)); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index c068d227e..a41adfc22 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -166,14 +166,14 @@ public async Task InitializeHandshake_StillWorks_OnJuly2026ProtocolDefaultServer } [Fact] - public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() + public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-path servers must accept 2026-07-28 per-request metadata and initialize-handshake traffic - // on the same connection. The exact mix below is what a permissive client running against an unknown - // server would emit while probing. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + // Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails. + // This is the sequence a permissive client emits against an unknown server: the failed probe never + // establishes a version, so initialize is still available. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"); var discover = await ReadAsync(); - Assert.NotNull(discover["result"]); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue()); await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); @@ -186,5 +186,29 @@ public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucce var call = await ReadAsync(); Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); } + + [Theory] + [InlineData("tools/list")] + [InlineData("server/discover")] + public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod) + { + // A modern request locks the session's protocol version. initialize declares nothing on stdio, so + // without this gate it would force the session back to a handshake version and make the modern + // _meta on in-flight requests opaque. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + var established = await ReadAsync(); + Assert.NotNull(established["result"]); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); + var init = await ReadAsync(); + Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue()); + + // The session is still modern: a request carrying full per-request metadata keeps working. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," + + July2026ProtocolMetaFragment() + "}}"); + var call = await ReadAsync(); + Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue()); + } } #endif