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..96e1403bf 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) @@ -511,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 @@ -525,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; } @@ -539,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 }, }; } @@ -705,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 @@ -738,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 8520f929c..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. @@ -432,6 +435,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 +523,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..3c0d15a1f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -289,11 +289,12 @@ 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")); }); } [Fact] - public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() + public async Task LegacyTransportProtocolVersion_RemainsAuthoritativeOverMetadata() { var ct = TestContext.Current.CancellationToken; await using var transport = new TestServerTransport(); @@ -303,15 +304,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 +316,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 +331,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]