From 30e1d90e20d91ebf92fa013b95dfb97634f8acd1 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 2 Jul 2026 15:22:11 -0700 Subject: [PATCH 1/4] Tighten draft protocol boundary handling Rename protocol version groups around their wire mechanisms and enforce stricter initialize-vs-per-request metadata boundaries across client, server, and HTTP transports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Common/McpHttpHeaders.cs | 62 ++++- .../StreamableHttpHandler.cs | 112 ++++++-- .../Client/McpClientImpl.cs | 28 +- .../Client/McpClientOptions.cs | 2 +- .../StreamableHttpClientSessionTransport.cs | 10 +- .../McpSessionHandler.cs | 14 +- .../Protocol/InitializeRequestParams.cs | 10 +- .../Protocol/InitializeResult.cs | 6 +- .../Server/McpServerImpl.cs | 261 +++++++++++++++++- .../Server/McpServerOptions.cs | 13 +- .../HttpHeaderConformanceTests.cs | 61 ++-- .../July2026ProtocolHttpFallbackTests.cs | 53 ++++ .../July2026ProtocolHttpHandlerTests.cs | 10 +- .../MrtrProtocolTests.cs | 47 +++- .../RawHttpConformanceTests.cs | 54 +++- .../StreamableHttpServerConformanceTests.cs | 50 ++-- .../Client/July2026ProtocolFallbackTests.cs | 26 ++ .../Client/McpClientTests.cs | 4 +- .../Client/McpRequestHeadersTests.cs | 4 +- .../ClientIntegrationTests.cs | 1 + ...rverBuilderExtensionsRequestFilterTests.cs | 2 +- .../Server/NegotiatedProtocolVersionTests.cs | 196 ++++++++++++- .../Server/TaskProtocolGatingTests.cs | 13 +- 23 files changed, 889 insertions(+), 150 deletions(-) diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 31800e2fa..a9b65658e 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -23,11 +23,39 @@ internal static class McpHttpHeaders /// /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. - /// It is the default version for the legacy initialize and session-resume code paths, and the - /// version the server advertises when a peer requests an unsupported version on the legacy handshake. + /// It is the default version for the initialize and session-resume code paths, and the version + /// the server advertises when a peer requests an unsupported version on the initialize handshake. /// public const string November2025ProtocolVersion = "2025-11-25"; + /// + /// Protocol versions that still use the initialize handshake. + /// + internal static readonly string[] InitializeHandshakeProtocolVersions = + [ + "2024-11-05", + "2025-03-26", + "2025-06-18", + November2025ProtocolVersion, + ]; + + /// + /// Protocol versions that use per-request metadata instead of the initialize handshake. + /// + internal static readonly string[] PerRequestMetadataProtocolVersions = + [ + July2026ProtocolVersion, + ]; + + /// + /// All protocol versions supported by this implementation. + /// + internal static readonly string[] SupportedProtocolVersions = + [ + .. InitializeHandshakeProtocolVersions, + .. PerRequestMetadataProtocolVersions, + ]; + /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; @@ -80,12 +108,38 @@ internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) => !string.IsNullOrEmpty(protocolVersion) && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; + /// + /// Returns if the given protocol version is supported by this implementation. + /// + internal static bool IsSupportedProtocolVersion(string? protocolVersion) + => protocolVersion is not null && SupportedProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version is available through the + /// initialize handshake. + /// + internal static bool SupportsInitializeHandshake(string? protocolVersion) + => protocolVersion is not null && InitializeHandshakeProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version requires the handshake-free + /// per-request metadata path. + /// + internal static bool RequiresPerRequestMetadata(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); + /// /// Returns if the given protocol version requires standard MCP request headers /// (Mcp-Method, Mcp-Name). /// - public static bool SupportsStandardHeaders(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); + public static bool RequiresStandardHeaders(string? protocolVersion) + => RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the given protocol version supports Streamable HTTP sessions. + /// + internal static bool SupportsHttpSessions(string? protocolVersion) + => !RequiresPerRequestMetadata(protocolVersion); /// /// Returns if the negotiated protocol version reports unresolvable diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 3224ceda8..ba9a9ddbc 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -13,6 +13,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -32,24 +33,15 @@ internal sealed class StreamableHttpHandler( /// /// All protocol versions supported by this implementation. - /// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core. /// - private static readonly HashSet s_supportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + private static readonly string[] s_supportedProtocolVersions = McpHttpHeaders.SupportedProtocolVersions; /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-era /// client falls back to a legacy initialize handshake instead of retrying the 2026-07-28 version. /// - private static readonly string[] s_sessionSupportingProtocolVersions = - [.. s_supportedProtocolVersions.Where(static v => !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(v))]; + private static readonly string[] s_sessionSupportingProtocolVersions = McpHttpHeaders.InitializeHandshakeProtocolVersions; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -106,6 +98,12 @@ await WriteJsonRpcErrorAsync(context, return; } + if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError)) + { + await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest); + return; + } + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch); @@ -143,7 +141,7 @@ public async Task HandleGetRequestAsync(HttpContext context) // The 2026-07-28 revision (SEP-2575) removes the standalone HTTP GET endpoint for unsolicited // server-to-client messages; clients use subscriptions/listen (POST) instead. Because Streamable HTTP // no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { await WriteJsonRpcErrorAsync(context, "Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", @@ -259,7 +257,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context) // Starting with the 2026-07-28 revision, Streamable HTTP has no sessions to terminate (SEP-2567), // so the DELETE is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { await WriteJsonRpcErrorAsync(context, "Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", @@ -373,7 +371,7 @@ await WriteJsonRpcErrorAsync(context, // The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567) // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { if (!string.IsNullOrEmpty(sessionId)) { @@ -433,10 +431,10 @@ await WriteJsonRpcErrorAsync(context, /// never carry an Mcp-Session-Id and never perform the legacy initialize handshake /// (SEP-2575 + SEP-2567). /// - private static bool IsJuly2026OrLaterProtocol(HttpContext context) + private static bool RequiresPerRequestMetadataProtocol(HttpContext context) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - return McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(protocolVersionHeader); + return McpHttpHeaders.RequiresPerRequestMetadata(protocolVersionHeader); } private async ValueTask StartNewSessionAsync(HttpContext context) @@ -684,6 +682,86 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW return true; } + /// + /// Validates that HTTP requests using per-request metadata declare the same protocol version in both + /// the MCP-Protocol-Version header and body _meta envelope. + /// + private static bool ValidateProtocolVersionEnvelope( + HttpContext context, + JsonRpcMessage message, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + { + if (message is not (JsonRpcRequest or JsonRpcNotification)) + { + errorDetail = null; + return true; + } + + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); + + if (!McpHttpHeaders.RequiresPerRequestMetadata(protocolVersionHeader) && + !McpHttpHeaders.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) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The body _meta/{MetaKeys.ProtocolVersion} field is required when the {McpProtocolVersionHeaderName} header declares a per-request metadata protocol version."); + return false; + } + + if (!string.Equals(protocolVersionHeader, protocolVersionMeta, StringComparison.Ordinal)) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body _meta/{MetaKeys.ProtocolVersion} value '{protocolVersionMeta}'."); + return false; + } + + errorDetail = null; + return true; + } + + private static JsonRpcErrorDetail CreateHeaderMismatchError(string message) => new() + { + Code = (int)McpErrorCode.HeaderMismatch, + Message = message, + }; + + private static bool TryGetProtocolVersionMeta(JsonRpcMessage message, [NotNullWhen(true)] out string? protocolVersion) + { + var parameters = message switch + { + JsonRpcRequest request => request.Params, + JsonRpcNotification notification => notification.Params, + _ => null, + }; + + string? value = null; + if (parameters is JsonObject paramsObj && + paramsObj["_meta"] is JsonObject metaObj && + metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue && + protocolVersionValue.TryGetValue(out value) && + !string.IsNullOrEmpty(value)) + { + protocolVersion = value; + return true; + } + + protocolVersion = null; + return false; + } + /// /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to @@ -725,7 +803,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess { // Only validate for protocol versions that support standard headers. var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - if (!McpHttpHeaders.SupportsStandardHeaders(protocolVersion)) + if (!McpHttpHeaders.RequiresStandardHeaders(protocolVersion)) { errorMessage = null; return true; diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 39f6579e3..ddf55e5a5 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -297,7 +297,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // prefers the 2026-07-28 revision and automatically falls back to the legacy initialize // handshake when the server doesn't support it. The legacy branch below runs only when // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). - if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion)) + if (_options.ProtocolVersion is null || McpHttpHeaders.RequiresPerRequestMetadata(_options.ProtocolVersion)) { string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; @@ -333,8 +333,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) catch (UnsupportedProtocolVersionException ex) { // Spec-recognized modern-server signal: -32022 with data.supported[]. The server is - // modern but doesn't speak our preferred version. Retry with a mutually supported - // version from data.supported[] instead of falling back to legacy initialize. + // refusing our preferred version; if it only supports legacy versions on this path, + // fall back to initialize with the highest mutually supported legacy version. fallbackToLegacy = true; serverSupportedVersions = (IList)ex.Supported; } @@ -352,6 +352,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. throw; } + catch (McpProtocolException ex) when ( + ex.ErrorCode == McpErrorCode.InvalidRequest && + ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal)) + { + // Local transport validation: a 2026-07-28+ response must not carry HTTP session state. + // This is not evidence of a legacy server, so do not fall back to initialize. + throw; + } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. @@ -375,8 +383,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) { // Server is reachable and supports server/discover, but doesn't support the - // 2026-07-28 version. Fall back to legacy initialize with the highest - // mutually-supported version from supportedVersions[]. + // preferred version. Fall back to legacy initialize with the highest + // mutually-supported legacy version from supportedVersions[]. fallbackToLegacy = true; serverSupportedVersions = discoverResult.SupportedVersions; } @@ -388,7 +396,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _sessionHandler.NegotiatedProtocolVersion = null; string fallbackVersion = serverSupportedVersions? - .Where(McpSessionHandler.SupportedProtocolVersions.Contains) + .Where(McpHttpHeaders.InitializeHandshakeProtocolVersions.Contains) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault() ?? McpHttpHeaders.November2025ProtocolVersion; @@ -479,10 +487,8 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella _serverInstructions = initializeResponse.Instructions; // When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it. - // When the user pinned the 2026-07-28 version but we fell back (e.g., legacy server rejected - // server/discover), or when no version was pinned, accept any supported response. This is the - // spec-mandated behavior: a 2026-07-28 client must be able to downgrade to whatever - // version the server advertises. + // When no version was pinned, accept any supported legacy response. initialize cannot negotiate + // the 2026-07-28 and later protocol revisions. bool isResponseProtocolValid; if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) { @@ -490,7 +496,7 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella } else { - isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + isResponseProtocolValid = McpHttpHeaders.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion); } if (!isResponseProtocolValid) { diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cefa6..b79264afd 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -55,7 +55,7 @@ public sealed class McpClientOptions /// When (the default), the client prefers the latest revision (2026-07-28), /// which removed the initialize handshake and Streamable HTTP sessions. It probes with /// server/discover and automatically falls back to the legacy initialize handshake, - /// downgrading to any version the server advertises, when the server does not support that revision. + /// downgrading to a legacy version the server advertises, when the server does not support that revision. /// /// /// When non-, this value is both the requested version and the minimum the client diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 26ffdce65..9e1bd5a2c 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -214,10 +214,18 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes throw new McpException($"Streamable HTTP POST response completed without a reply to request with ID: {rpcRequest.Id}"); } + if (McpHttpHeaders.RequiresPerRequestMetadata(protocolVersionForRequest) && + response.Headers.Contains(McpHttpHeaders.SessionId)) + { + throw new McpProtocolException( + $"The server returned '{McpHttpHeaders.SessionId}' on a response using protocol version '{protocolVersionForRequest}', but that protocol revision does not support HTTP sessions.", + McpErrorCode.InvalidRequest); + } + if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) { // We've successfully initialized! Copy session-id and protocol version, then start GET request if any. - if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) + if (response.Headers.TryGetValues(McpHttpHeaders.SessionId, out var sessionIdValues)) { SessionId = sessionIdValues.FirstOrDefault(); } diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index a0122c9a4..30e800ecb 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -29,18 +29,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent."); /// - /// All protocol versions supported by this implementation. The version constants live on + /// All protocol versions supported by this implementation. The era-specific lists live on /// so the shared source file is the single source of truth. - /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. /// - internal static readonly string[] SupportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + internal static readonly string[] SupportedProtocolVersions = McpHttpHeaders.SupportedProtocolVersions; /// /// Checks if the given protocol version supports priming events. @@ -162,7 +154,7 @@ public McpSessionHandler( (request, jsonRpcRequest, cancellationToken) => { string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; - if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(perRequestVersion)) + if (McpHttpHeaders.RequiresPerRequestMetadata(perRequestVersion)) { throw new McpProtocolException( $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs index 468754e96..c48b9eb9a 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs @@ -22,16 +22,18 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeRequestParams : RequestParams { /// - /// Gets or sets the version of the Model Context Protocol that the client wants to use. + /// Gets or sets the legacy Model Context Protocol version that the client wants to use with + /// the initialize handshake. /// /// /// /// Protocol version is specified using a date-based versioning scheme in the format "YYYY-MM-DD". - /// The client and server must agree on a protocol version to communicate successfully. + /// The client and server must agree on a legacy protocol version to communicate successfully. /// /// - /// During initialization, the server will check if it supports this requested version. If there's a - /// mismatch, the server will reject the connection with a version mismatch error. + /// During initialization, the server will check if it supports this requested legacy version. Protocol + /// revisions starting with 2026-07-28 do not use initialize; clients select them with + /// server/discover and per-request metadata instead. /// /// /// See the protocol specification for version details. diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs index e79113687..8643fb2a3 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs @@ -22,12 +22,12 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeResult : Result { /// - /// Gets or sets the version of the Model Context Protocol that the server will use for this session. + /// Gets or sets the legacy Model Context Protocol version that the server will use for this session. /// /// /// - /// This is the protocol version the server has agreed to use, which should match the client's - /// requested version. If there's a mismatch, the client should throw an exception to prevent + /// This is the legacy protocol version the server has agreed to use, which should match the client's + /// requested legacy version. If there's a mismatch, the client should throw an exception to prevent /// communication issues due to incompatible protocol versions. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..80b412d01 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -32,6 +32,14 @@ internal sealed partial class McpServerImpl : McpServer 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; @@ -166,12 +174,21 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner { JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) => { - if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context) + if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest) + { + ValidateInitializeRequestBoundary(initializeRequest); + } + else 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 (context?.ProtocolVersion is { } protocolVersion) { + bool protocolVersionRecorded = false; + // 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. @@ -182,10 +199,59 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner supported: McpSessionHandler.SupportedProtocolVersions); } - SetNegotiatedProtocolVersion(protocolVersion); + if (McpHttpHeaders.RequiresPerRequestMetadata(protocolVersion)) + { + ValidateRequiredPerRequestMetadata( + protocolVersion, + hasProtocolVersionMeta, + context.ClientInfo is not null, + context.ClientCapabilities is not null); + } + else if (McpHttpHeaders.SupportsInitializeHandshake(protocolVersion)) + { + if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: McpHttpHeaders.PerRequestMetadataProtocolVersions, + message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata."); + } + + if (hasReservedPerRequestMeta) + { + SetNegotiatedProtocolVersion(protocolVersion); + protocolVersionRecorded = true; + ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey); + } + } + + if (!protocolVersionRecorded) + { + SetNegotiatedProtocolVersion(protocolVersion); + } } + else if (_negotiatedProtocolVersion is null) + { + if (request.Method == RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", + McpErrorCode.InvalidRequest); + } + + if (hasReservedPerRequestMeta) + { + ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey); + } + } + else if (McpHttpHeaders.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) + { + ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey); + } + + ValidateRequestMethodBoundary(request); - if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport()) + if (context?.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport()) { // Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL // capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate @@ -198,7 +264,7 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner _clientCapabilities = clientCapabilities; } - if (context.ClientInfo is { } clientInfo && + if (context?.ClientInfo is { } clientInfo && (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { @@ -212,6 +278,10 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner _sessionHandler.EndpointName = _endpointName; } } + else if (message is JsonRpcNotification notification) + { + ValidateNotificationBoundary(notification); + } await next(message, cancellationToken).ConfigureAwait(false); }; @@ -219,6 +289,124 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner return next => metaReadingFilter(inner(next)); } + private static void ValidateRequiredPerRequestMetadata( + string protocolVersion, + bool hasProtocolVersionMeta, + bool hasClientInfoMeta, + bool hasClientCapabilitiesMeta) + { + if (!hasProtocolVersionMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion); + } + + if (!hasClientInfoMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientInfo); + } + + if (!hasClientCapabilitiesMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + } + } + + private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) => + throw new McpProtocolException( + $"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.", + McpErrorCode.InvalidRequest); + + 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); + + private static void ValidateInitializeRequestBoundary(JsonRpcRequest request) + { + if (request.Context?.ProtocolVersion is { } protocolVersion && + !McpHttpHeaders.SupportsInitializeHandshake(protocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: McpHttpHeaders.InitializeHandshakeProtocolVersions, + 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 void ValidateNotificationBoundary(JsonRpcNotification notification) + { + if (notification.Method == NotificationMethods.InitializedNotification && + McpHttpHeaders.RequiresPerRequestMetadata(notification.Context?.ProtocolVersion ?? _negotiatedProtocolVersion)) + { + throw new McpProtocolException( + $"The notification '{NotificationMethods.InitializedNotification}' is only valid after the initialize handshake.", + McpErrorCode.InvalidRequest); + } + } + + private void ValidateRequestMethodBoundary(JsonRpcRequest request) + { + bool usesPerRequestMetadata = IsJuly2026OrLaterProtocolRequest(request); + + if (!usesPerRequestMetadata && + request.Method is RequestMethods.SubscriptionsListen + or RequestMethods.TasksGet + or RequestMethods.TasksUpdate + or RequestMethods.TasksCancel) + { + throw new McpProtocolException( + $"The method '{request.Method}' requires a newer protocol revision that supports per-request metadata; " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + + if (usesPerRequestMetadata && request.Method == RequestMethods.LoggingSetLevel) + { + throw new McpProtocolException( + $"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.", + McpErrorCode.MethodNotFound); + } + } + /// public override string? SessionId => _sessionTransport.SessionId; @@ -361,26 +549,54 @@ private void ConfigureInitialize(McpServerOptions options) UpdateEndpointNameWithClientInfo(); _sessionHandler.EndpointName = _endpointName; - // Negotiate a protocol version. If the server options provide one, use that. - // Otherwise, try to use whatever the client requested as long as it's supported. - // If it's not supported, fall back to the latest supported version. + // Negotiate a legacy protocol version. initialize is not available in the 2026-07-28 + // and later protocol revisions, so those versions must use server/discover with + // per-request _meta instead. string? protocolVersion = options.ProtocolVersion; - protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && - McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? - clientProtocolVersion : - McpHttpHeaders.November2025ProtocolVersion; + if (protocolVersion is { } configuredProtocolVersion && + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(configuredProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + configuredProtocolVersion, + McpHttpHeaders.InitializeHandshakeProtocolVersions, + $"Protocol version '{configuredProtocolVersion}' is not available through the legacy initialize handshake."); + } + + if (protocolVersion is null) + { + if (request?.ProtocolVersion is string clientProtocolVersion) + { + if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(clientProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + clientProtocolVersion, + McpHttpHeaders.InitializeHandshakeProtocolVersions, + $"Protocol version '{clientProtocolVersion}' is not available through the legacy initialize handshake."); + } + + protocolVersion = McpHttpHeaders.SupportsInitializeHandshake(clientProtocolVersion) ? + clientProtocolVersion : + McpHttpHeaders.November2025ProtocolVersion; + } + else + { + protocolVersion = McpHttpHeaders.November2025ProtocolVersion; + } + } + + string negotiatedProtocolVersion = protocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; // The legacy initialize handshake is authoritative: it may supersede a protocol version // a prior server/discover probe established on the same connection (the dual-era // 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. - _negotiatedProtocolVersion = protocolVersion; - _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + _negotiatedProtocolVersion = negotiatedProtocolVersion; + _sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion; return new InitializeResult { - ProtocolVersion = protocolVersion, + ProtocolVersion = negotiatedProtocolVersion, Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), @@ -440,6 +656,14 @@ private void ConfigureSubscriptions(McpServerOptions options) _requestHandlers.Set(RequestMethods.SubscriptionsListen, async (request, jsonRpcRequest, cancellationToken) => { + if (!IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{RequestMethods.SubscriptionsListen}' requires a newer protocol revision that supports per-request subscriptions; " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + var requested = request?.Notifications ?? new SubscriptionsListenNotifications(); // A stateless session (Streamable HTTP with no session) cannot deliver out-of-band @@ -1565,6 +1789,13 @@ private void ConfigureLogging(McpServerOptions options) RequestMethods.LoggingSetLevel, (request, jsonRpcRequest, cancellationToken) => { + if (IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{jsonRpcRequest.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.", + McpErrorCode.MethodNotFound); + } + // Store the provided level. if (request is not null) { diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index eb99913d5..a52fe7cab 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -30,14 +30,15 @@ public sealed class McpServerOptions public ServerCapabilities? Capabilities { get; set; } /// - /// Gets or sets the protocol version supported by this server, using a date-based versioning scheme. + /// Gets or sets the legacy protocol version used by the initialize handshake, using a date-based versioning scheme. /// /// - /// The protocol version defines which features and message formats this server supports. - /// This uses a date-based versioning scheme in the format "YYYY-MM-DD". - /// If , the server will advertise to the client the version requested - /// by the client if that version is known to be supported, and otherwise will advertise the latest - /// version supported by the server. + /// The protocol version defines which legacy features and message formats this server uses for + /// sessions established through initialize. This uses a date-based versioning scheme in the + /// format "YYYY-MM-DD". If , the server negotiates the legacy version requested + /// by the client if that version is known to be supported, and otherwise uses the latest supported + /// legacy version. Protocol revisions starting with 2026-07-28 do not use initialize; + /// clients select them with server/discover and per-request metadata instead. /// public string? ProtocolVersion { get; set; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 4da16a3eb..d590b003b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -114,7 +114,7 @@ private static McpServerTool CreateUnionHeaderTestTool() public async Task Server_AcceptsUnionIntegerCanonicalForm() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body // carries the decimal form 42.0. The server must treat the union type as integer and match. @@ -135,7 +135,7 @@ public async Task Server_AcceptsUnionIntegerCanonicalForm() public async Task Server_RejectsUnionIntegerOutsideSafeRange() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); var callJson = CallTool("union_test", """{"priority":9007199254740993}"""); @@ -154,7 +154,7 @@ public async Task Server_RejectsUnionIntegerOutsideSafeRange() public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100". var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}"""); @@ -177,7 +177,7 @@ public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values // and compare the trimmed value to the request body. @@ -201,7 +201,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); @@ -224,7 +224,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call with an empty string param that has an x-mcp-header. // The header should be present with an empty value, matching the body's empty string. @@ -248,7 +248,7 @@ public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call where the body has a non-empty value but the header is empty var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}"""); @@ -271,7 +271,7 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Encode a value with a newline control character using Base64 var valueWithNewline = "line1\nline2"; @@ -297,7 +297,7 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without // losing precision through a double conversion. @@ -326,7 +326,7 @@ public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243 integer values MUST be within the JavaScript safe integer range. // A matching header and body that are both outside the range must still be rejected. @@ -355,7 +355,7 @@ public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and // "4.2e1" are preserved in the body exactly as another SDK might serialize them. @@ -382,7 +382,7 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // For an integer-typed parameter a non-whole numeric value is invalid and must be rejected // even when the header and body strings are byte-for-byte identical (it must not slip through @@ -407,7 +407,7 @@ public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(strin public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Header says "99" but body says priority:42 — must reject even with numeric comparison var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}"""); @@ -433,7 +433,7 @@ public async Task Server_SkipsHeaderValidation_ForLegacyVersion() await InitializeWithLegacyVersionAsync(); // With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched - var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includeModernMeta: false); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); @@ -451,7 +451,7 @@ public async Task Server_SkipsHeaderValidation_ForLegacyVersion() public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers // instead of properly base64-encoding non-ASCII values. @@ -561,30 +561,29 @@ public void Client_EncodeValue_Boolean_EncodesCorrectly() [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) + public void RequiresStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpHttpHeaders.RequiresStandardHeaders(version)); } #endregion #region Helpers - private async Task InitializeWithJuly2026ProtocolVersionAsync() + private async Task ProbeWithJuly2026ProtocolVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a - // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 - // so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } private async Task InitializeWithLegacyVersionAsync() @@ -602,22 +601,24 @@ private async Task InitializeWithLegacyVersionAsync() private long _lastRequestId = 1; - private string CallTool(string toolName, string arguments = "{}") + private string CallTool(string toolName, string arguments = "{}", bool includeModernMeta = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$$""" - {"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}} - """; + var meta = includeModernMeta + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""TestClient"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"tools/call\",\"params\":{\"name\":\"" + + toolName + "\",\"arguments\":" + arguments + meta + "}}"; } private static string InitializeRequest => """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion } - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 02bfba288..ed8da12a9 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -300,4 +300,57 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); Assert.False(initializeReceived); } + + [Fact] + public async Task Client_OnModernResponseWithMcpSessionId_RejectsSessionState() + { + var ct = TestContext.Current.CancellationToken; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "bad-modern-server", Version = "1.0" }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.Headers[McpHttpHeaders.SessionId] = "unexpected-session"; + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + Assert.Contains(McpHttpHeaders.SessionId, exception.Message, StringComparison.Ordinal); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index 9cce9b0db..fe7933ec0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -57,7 +57,7 @@ public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() // On a stateless server, server/discover succeeds without creating a session. var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -78,7 +78,7 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -108,7 +108,7 @@ public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProto HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -196,4 +196,8 @@ public async Task Delete_WithSessionId_IsRejected() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"July2026HttpHandlerTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 68076e292..0fa99b166 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -337,7 +337,7 @@ static string (RequestContext context) => // the response headers arrive instead of waiting for the SSE stream to close. var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null) { - Content = JsonContent(CallTool("backcompat-roots-tool")), + Content = JsonContent(CallTool("backcompat-roots-tool", includeModernMeta: false)), }; callRequest.Content.Headers.Add("Mcp-Method", "tools/call"); callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool"); @@ -456,16 +456,49 @@ private Task PostJsonRpcAsync(string json) private long _lastRequestId = 1; - private string Request(string method, string parameters = "{}") + private string Request(string method, string parameters = "{}", bool includeModernMeta = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$""" - {"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}} - """; + var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject(); + if (includeModernMeta) + { + AddJuly2026ProtocolMeta(paramsObj); + } + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + ["params"] = paramsObj, + }; + + return request.ToJsonString(); + } + + private static void AddJuly2026ProtocolMeta(JsonObject paramsObj) + { + if (paramsObj["_meta"] is not JsonObject meta) + { + meta = []; + paramsObj["_meta"] = meta; + } + + meta[MetaKeys.ProtocolVersion] = McpHttpHeaders.July2026ProtocolVersion; + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "MrtrTestClient", + ["version"] = "1.0", + }; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } } - private string CallTool(string toolName, string arguments = "{}") => + private string CallTool(string toolName, string arguments = "{}", bool includeModernMeta = true) => Request("tools/call", $$""" {"name":"{{toolName}}","arguments":{{arguments}}} - """); + """, includeModernMeta); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 050bd428b..d9ed7832e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -182,6 +182,59 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020() + { + 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"":{}}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + 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); + + Assert.Equal(HttpStatusCode.BadRequest, 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); + } + + [Fact] + public async Task Initialize_WithModernProtocolHeaderAndLegacyBody_ReturnsHeaderMismatch_Minus32020() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", RequestMethods.Initialize); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); } @@ -216,4 +269,3 @@ public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Retur Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } } - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 1ff58f978..7cd79b410 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -921,12 +921,12 @@ public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server. await StartAsync(stateless: true); - // Initialize with the 2026-07-28 protocol version to enable header validation - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + // Probe with the 2026-07-28 protocol version to enable header validation. + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includeModernMeta: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header @@ -938,11 +938,11 @@ public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includeModernMeta: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method @@ -954,11 +954,11 @@ public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includeModernMeta: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -983,25 +983,25 @@ public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync() + private async Task CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); var rpcResponse = await AssertSingleSseResponseAsync(response); - AssertServerInfo(rpcResponse); + AssertDiscoverServerInfo(rpcResponse); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id. - // Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion @@ -1073,10 +1073,14 @@ private string Request(string method, string parameters = "{}") """; } - private string CallTool(string toolName, string arguments = "{}") => - Request("tools/call", $$""" - {"name":"{{toolName}}","arguments":{{arguments}}} - """); + private string CallTool(string toolName, string arguments = "{}", bool includeModernMeta = false) + { + var meta = includeModernMeta + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""IntegrationTestClient"",""version"":""1.0.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return Request("tools/call", "{\"name\":\"" + toolName + "\",\"arguments\":" + arguments + meta + "}"); + } private string CallToolWithProgressToken(string toolName, string arguments = "{}") => Request("tools/call", $$$""" @@ -1096,6 +1100,14 @@ private static InitializeResult AssertServerInfo(JsonRpcResponse rpcResponse) return initializeResult; } + private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse) + { + var discoverResult = AssertType(rpcResponse.Result); + Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name); + Assert.Equal("73", discoverResult.ServerInfo.Version); + return discoverResult; + } + private static CallToolResult AssertEchoResponse(JsonRpcResponse rpcResponse) { var callToolResponse = AssertType(rpcResponse.Result); diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index f2bc24cb5..b51b33d76 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -34,6 +34,7 @@ public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngrad Assert.True(transport.ServerDiscoverProbed); Assert.True(transport.LegacyInitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); } @@ -50,9 +51,29 @@ public async Task Client_OnInvalidParams_FallsBackTo_Initialize() loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.LegacyInitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); } + [Fact] + public async Task Client_OnLegacyFallback_RejectsModernInitializeResponse() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: McpHttpHeaders.July2026ProtocolVersion); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); + Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServer() { @@ -137,6 +158,7 @@ public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredPro Assert.True(transport.ServerDiscoverProbed); Assert.True(transport.LegacyInitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. @@ -189,6 +211,8 @@ private sealed class LegacyServerTestTransport( public bool LegacyInitializeReceived { get; private set; } + public string? LegacyInitializeProtocolVersion { get; private set; } + public Task ConnectAsync(CancellationToken cancellationToken = default) { ITransport transport = new TransportChannel(_incomingToClient, this); @@ -224,6 +248,8 @@ private void HandleOutgoingMessage(JsonRpcMessage message) case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: LegacyInitializeReceived = true; + var initializeRequest = JsonSerializer.Deserialize(initReq.Params, McpJsonUtilities.DefaultOptions); + LegacyInitializeProtocolVersion = initializeRequest?.ProtocolVersion; _ = WriteAsync(new JsonRpcResponse { Id = initReq.Id, diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 4dda7bc38..ce17e9bd2 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -483,7 +483,7 @@ private sealed class SynchronousProgress(Action callb [Fact] public async Task AsClientLoggerProvider_MessagesSentToClient() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion }); ILoggerProvider loggerProvider = Server.AsClientLoggerProvider(); Assert.Throws("categoryName", () => loggerProvider.CreateLogger(null!)); @@ -765,7 +765,7 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task SetLoggingLevelAsync_WithRequestParams_SetsLevel() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion }); // Should not throw await client.SetLoggingLevelAsync( diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 5868c3c63..0db595e8d 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -28,8 +28,8 @@ public void McpErrorCode_HeaderMismatch_HasCorrectValue() [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_ReturnsExpected(string? version, bool expected) + public void RequiresStandardHeaders_ReturnsExpected(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpHttpHeaders.RequiresStandardHeaders(version)); } } diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 7690a75f9..e1ef50efa 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -558,6 +558,7 @@ public async Task SetLoggingLevel_ReceivesLoggingMessages(string clientId) TaskCompletionSource receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs index 0c4783b28..00cede9a5 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs @@ -280,7 +280,7 @@ public async Task AddUnsubscribeFromResourcesFilter_Logs_When_UnsubscribeFromRes [Fact] public async Task AddSetLoggingLevelFilter_Logs_When_SetLoggingLevel_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion }); await client.SetLoggingLevelAsync(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index 29ae7823f..2ed44f251 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -68,24 +68,210 @@ public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterCha Assert.IsType(await RoundTripAsync(id: 4, McpHttpHeaders.July2026ProtocolVersion, ct)); } - private async Task RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken) + [Fact] + public async Task PerRequestProtocolVersion_RejectsLegacyVersionBeforeInitialize() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType(await RoundTripAsync(id: 1, McpHttpHeaders.November2025ProtocolVersion, ct)); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); + Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + + // The rejected legacy _meta request must not have established session state. + Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); + } + + [Fact] + public async Task PerRequestProtocolVersion_RejectsModernRequestMissingRequiredMetadata() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripAsync( + id: 1, + McpHttpHeaders.July2026ProtocolVersion, + ct, + includeClientInfo: false)); + + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ServerDiscover_WithoutPerRequestMetadata_IsRejectedBeforeInitialize() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Initialize_WithModernProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpHttpHeaders.July2026ProtocolVersion, ct)); + + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); + Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + Meta = new JsonObject + { + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "modern-meta-client", + ["version"] = "1.0.0", + }, + }, + }, McpJsonUtilities.DefaultOptions), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SubscriptionsListen_WithInitializeProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpHttpHeaders.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.SubscriptionsListen, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.MethodNotFound, error.Error.Code); + Assert.Contains(RequestMethods.SubscriptionsListen, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task LoggingSetLevel_WithPerRequestMetadataProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.LoggingSetLevel, + Params = new JsonObject + { + ["level"] = "info", + ["_meta"] = ModernMeta(), + }, + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.MethodNotFound, error.Error.Code); + Assert.Contains(RequestMethods.LoggingSetLevel, error.Error.Message, StringComparison.Ordinal); + } + + private async Task RoundTripAsync( + long id, + string protocolVersion, + CancellationToken cancellationToken, + bool includeClientInfo = true, + bool includeClientCapabilities = true) { // tools/list is available under both the legacy and 2026-07-28 revisions (unlike ping/initialize, // which the 2026-07-28 protocol removed), so it exercises the version guard rather than the // per-method availability gate. + var meta = new JsonObject + { + [MetaKeys.ProtocolVersion] = protocolVersion, + }; + + if (McpHttpHeaders.RequiresPerRequestMetadata(protocolVersion)) + { + if (includeClientInfo) + { + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }; + } + + if (includeClientCapabilities) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } + } + var request = new JsonRpcRequest { Id = new RequestId(id), Method = RequestMethods.ToolsList, Params = new JsonObject { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = protocolVersion, - }, + ["_meta"] = meta, }, }; + return await SendAndReceiveAsync(request, cancellationToken); + } + + private static JsonObject ModernMeta() => new() + { + [MetaKeys.ProtocolVersion] = McpHttpHeaders.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + + private async Task RoundTripInitializeAsync(long id, string protocolVersion, CancellationToken cancellationToken) + { + var request = new JsonRpcRequest + { + Id = new RequestId(id), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = protocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + return await SendAndReceiveAsync(request, cancellationToken); + } + + private async Task SendAndReceiveAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); #if NET await _writer.WriteLineAsync(json.AsMemory(), cancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 7f9790526..2f1974ce0 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -127,24 +127,23 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata() { 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 must still refuse to create a task because the per-request protocol - // version is not the 2026-07-28 protocol. - var result = await client.CallToolRawAsync( + // request. The server rejects reserved per-request metadata before it can affect behavior. + var ex = await Assert.ThrowsAsync(async () => await client.CallToolRawAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "forged"), Meta = CreateForgedTaskOptInMeta(), - }, ct); + }, ct)); - Assert.False(result.IsTask); - Assert.NotNull(result.Result); + Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode); + Assert.Contains(ClientCapabilitiesMetaKey, ex.Message); } [Fact] From e500ba0e584df1000cc90bb4332f6db8b83e9e79 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 2 Jul 2026 17:17:03 -0700 Subject: [PATCH 2/4] Tighten per-request protocol boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/mrtr/mrtr.md | 4 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- docs/concepts/stateless/stateless.md | 16 +-- docs/concepts/transports/transports.md | 2 +- .../HttpServerTransportOptions.cs | 6 +- .../StreamableHttpHandler.cs | 60 ++++++---- .../Client/McpClientImpl.cs | 60 +++++----- .../Client/McpClientOptions.cs | 24 ++-- .../StreamableHttpClientSessionTransport.cs | 20 +--- .../McpSessionHandler.cs | 4 +- .../Protocol/DiscoverResult.cs | 7 +- .../Protocol/JsonRpcMessage.cs | 2 +- .../Server/McpServerImpl.cs | 74 ++++++++---- .../Server/McpServerOptions.cs | 37 ++++-- tests/Common/Utils/NodeHelpers.cs | 2 +- .../HttpHeaderConformanceTests.cs | 18 +-- .../July2026ProtocolHttpFallbackTests.cs | 112 ++++++++++++++---- .../July2026ProtocolHttpHandlerTests.cs | 30 ++--- .../July2026ProtocolStatefulFallbackTests.cs | 12 +- .../MrtrProtocolTests.cs | 14 +-- .../RawHttpConformanceTests.cs | 61 ++++++++-- .../StreamableHttpServerConformanceTests.cs | 14 +-- .../Client/July2026ProtocolConnectionTests.cs | 24 ++-- .../Client/July2026ProtocolFallbackTests.cs | 78 ++++++------ .../Client/McpRequestHeadersTests.cs | 1 + .../Protocol/CacheableResultWarningTests.cs | 28 ++--- .../Server/McpServerTests.cs | 6 +- .../Server/NegotiatedProtocolVersionTests.cs | 20 ++-- .../Server/RawStreamConformanceTests.cs | 15 +-- 31 files changed, 457 insertions(+), 300 deletions(-) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 60530d85d..4e14a2d1e 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 5044e6d51..e0d4f8d0f 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -33,7 +33,7 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support it. Servers accept `2026-07-28` automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. ```csharp // Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. @@ -283,4 +283,4 @@ The SDK supports `InputRequiredException` across two protocol revisions and two Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on `2026-07-28` stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. -Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP runs statelessly whenever a client speaks `2026-07-28`. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves `2026-07-28` requests without a session and creates a legacy session only when an older client falls back to `initialize`. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index bb0218f97..de9b9b0ac 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -109,7 +109,7 @@ server.RegisterNotificationHandler( [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 825acfb0a..7b8eb8e59 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -126,7 +126,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 6750443df..d27cd2a8d 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -28,11 +28,11 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: -- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or honored. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to legacy handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that even with `Stateless = false`, a `2026-07-28` request is still served without a session because the protocol has no session header; the stateful path activates only when a client falls back to a legacy revision. +- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that with `Stateless = false`, a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. > [!TIP] @@ -42,18 +42,18 @@ The `Stateless` property is the single most important setting for forward-proofi The `2026-07-28` protocol revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). -**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP POST that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request (which carries no session) on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A `2026-07-28` request that carries an `Mcp-Session-Id` is always rejected, since the revision has no session concept. +**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP POST that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request. -**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. +**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients. **Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it: -- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. -- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. +- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the `initialize` flow on the same endpoint. +- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported initialize-capable version; anything else — including a timeout — falls back to `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. The era is cached per instance, so the probe cost is paid only on the first connect. -**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to a legacy revision. This is useful for strict-modern production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. +**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. ```csharp var clientOptions = new McpClientOptions diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 354299ce1..85ccec209 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -183,7 +183,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:stateless#forward-and-backward-compatibility). See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown. +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown. #### Host name validation diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index e0c8a8826..c36386360 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -64,10 +64,10 @@ public class HttpServerTransportOptions /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served /// when this property is . When it is , such a request is - /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-era client downgrades to + /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to /// the legacy initialize handshake and obtains the session the server was configured to provide. - /// A request that carries an Mcp-Session-Id is always rejected by the 2026-07-28 and later - /// revisions, regardless of this property's value. + /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored; + /// the server must not mint or echo session IDs for those revisions. /// /// public bool Stateless { get; set; } = true; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index ba9a9ddbc..7e57eaedd 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -38,8 +38,8 @@ internal sealed class StreamableHttpHandler( /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and - /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-era - /// client falls back to a legacy initialize handshake instead of retrying the 2026-07-28 version. + /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path + /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version. /// private static readonly string[] s_sessionSupportingProtocolVersions = McpHttpHeaders.InitializeHandshakeProtocolVersions; @@ -55,7 +55,8 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -130,7 +131,8 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -144,8 +146,8 @@ public async Task HandleGetRequestAsync(HttpContext context) if (RequiresPerRequestMetadataProtocol(context)) { await WriteJsonRpcErrorAsync(context, - "Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -247,7 +249,8 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -260,8 +263,8 @@ public async Task HandleDeleteRequestAsync(HttpContext context) if (RequiresPerRequestMetadataProtocol(context)) { await WriteJsonRpcErrorAsync(context, - "Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -367,25 +370,14 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message) { - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - // The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567) // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: if (RequiresPerRequestMetadataProtocol(context)) { - if (!string.IsNullOrEmpty(sessionId)) - { - // A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567). - await WriteJsonRpcErrorAsync(context, - "Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).", - StatusCodes.Status400BadRequest); - return null; - } - if (!HttpServerTransportOptions.Stateless) { // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 - // revision cannot provide. Refuse it so a dual-era client falls back to the legacy + // revision cannot provide. Refuse it so a dual-path client falls back to the // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). await WriteUnsupportedProtocolVersionErrorAsync(context); return null; @@ -395,6 +387,7 @@ await WriteJsonRpcErrorAsync(context, return await StartNewSessionAsync(context); } + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); if (string.IsNullOrEmpty(sessionId)) { // In stateful mode, only allow creating new sessions for initialize requests. @@ -428,7 +421,7 @@ await WriteJsonRpcErrorAsync(context, /// /// Returns when the request's MCP-Protocol-Version header declares a /// revision that operates without sessions, so the server must serve it statelessly. Such requests - /// never carry an Mcp-Session-Id and never perform the legacy initialize handshake + /// do not use an Mcp-Session-Id and never perform the initialize handshake /// (SEP-2575 + SEP-2567). /// private static bool RequiresPerRequestMetadataProtocol(HttpContext context) @@ -657,11 +650,14 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, /// rejection uses the error code with a data payload /// listing the server's supported versions so the client can select a fallback. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + private static bool ValidateProtocolVersionHeader( + HttpContext context, + IReadOnlyList supportedProtocolVersions, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !s_supportedProtocolVersions.Contains(protocolVersionHeader)) + !supportedProtocolVersions.Contains(protocolVersionHeader)) { errorDetail = new JsonRpcErrorDetail { @@ -670,7 +666,7 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { - Supported = [.. s_supportedProtocolVersions], + Supported = [.. supportedProtocolVersions], Requested = protocolVersionHeader, }, GetRequiredJsonTypeInfo()), @@ -682,6 +678,18 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW return true; } + private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) + { + if (protocolVersion is null) + { + return s_supportedProtocolVersions; + } + + return McpHttpHeaders.IsSupportedProtocolVersion(protocolVersion) ? + [protocolVersion] : + s_supportedProtocolVersions; + } + /// /// Validates that HTTP requests using per-request metadata declare the same protocol version in both /// the MCP-Protocol-Version header and body _meta envelope. @@ -766,7 +774,7 @@ metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue && /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to /// sessions; we return with a supported-versions list - /// that excludes 2026-07-28 and later. A dual-era client then falls back to the legacy initialize handshake + /// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake /// (SEP-2575). /// private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context) diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index ddf55e5a5..2d42d0f1a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -294,8 +294,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // handshake. Instead, the client calls server/discover to learn the server's // capabilities and then begins sending normal RPCs that carry protocolVersion / // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion - // prefers the 2026-07-28 revision and automatically falls back to the legacy initialize - // handshake when the server doesn't support it. The legacy branch below runs only when + // prefers the 2026-07-28 revision and automatically falls back to the initialize + // handshake when the server doesn't support it. The initialize branch below runs only when // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). if (_options.ProtocolVersion is null || McpHttpHeaders.RequiresPerRequestMetadata(_options.ProtocolVersion)) { @@ -307,11 +307,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _sessionHandler.NegotiatedProtocolVersion = preferredVersion; DiscoverResult? discoverResult = null; - bool fallbackToLegacy = false; + bool fallbackToInitialize = false; IList? serverSupportedVersions = null; - // Apply a probe timeout so dual-era clients don't block forever waiting for a - // legacy server that silently drops unknown methods (per stdio.mdx fallback rules). + // Apply a probe timeout so dual-path clients don't block forever waiting for an + // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules). // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is // always bounded by InitializationTimeout (only applied when it is the tighter bound). var probeTimeout = _options.DiscoverProbeTimeout; @@ -332,24 +332,24 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } catch (UnsupportedProtocolVersionException ex) { - // Spec-recognized modern-server signal: -32022 with data.supported[]. The server is - // refusing our preferred version; if it only supports legacy versions on this path, - // fall back to initialize with the highest mutually supported legacy version. - fallbackToLegacy = true; + // Spec-recognized SEP-2575 signal: -32022 with data.supported[]. The server is + // refusing our preferred version; if it only supports initialize-capable versions, + // fall back to initialize with the highest mutually supported version. + fallbackToInitialize = true; serverSupportedVersions = (IList)ex.Supported; } catch (MissingRequiredClientCapabilityException) { - // Spec-recognized modern-server signal: -32021. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32021. The server rejected // our capability set. Surface as-is (no fallback): the user must add capabilities. throw; } catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) { - // Spec-recognized modern-server signal: -32020. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32020. The server rejected // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no - // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. + // fallback): falling back to initialize wouldn't fix a malformed envelope. throw; } catch (McpProtocolException ex) when ( @@ -357,41 +357,41 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal)) { // Local transport validation: a 2026-07-28+ response must not carry HTTP session state. - // This is not evidence of a legacy server, so do not fall back to initialize. + // This is not evidence of an initialize-handshake server, so do not fall back. throw; } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. - // Any non-modern JSON-RPC error from the probe indicates a legacy server. + // Any non-SEP-2575 JSON-RPC error from the probe indicates an initialize-handshake server. // Common causes include MethodNotFound from a server that has no // server/discover handler, InvalidParams from a server confused by the // SEP-2575 _meta envelope, ParseError from a server that can't handle our - // payload shape, or any other transport-defined error. The three modern-server + // payload shape, or any other transport-defined error. The three SEP-2575 // signals (-32022 UnsupportedProtocolVersion, -32021 // MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and // never reach here. - fallbackToLegacy = true; + fallbackToInitialize = true; } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) { // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no - // response within a reasonable timeout means the server is legacy. Fall back. - fallbackToLegacy = true; + // response within a reasonable timeout means the server requires initialize. Fall back. + fallbackToInitialize = true; } if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) { // Server is reachable and supports server/discover, but doesn't support the - // preferred version. Fall back to legacy initialize with the highest - // mutually-supported legacy version from supportedVersions[]. - fallbackToLegacy = true; + // preferred version. Fall back to initialize with the highest mutually-supported + // initialize-capable version from supportedVersions[]. + fallbackToInitialize = true; serverSupportedVersions = discoverResult.SupportedVersions; } - if (fallbackToLegacy) + if (fallbackToInitialize) { - // Reset negotiated state and try legacy initialize. + // Reset negotiated state and try initialize. _negotiatedProtocolVersion = null; _sessionHandler.NegotiatedProtocolVersion = null; @@ -411,11 +411,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) $"The server does not support the requested protocol version '{pinnedVersion}'. " + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + (serverSupportedVersions is null - ? "The server appears to be a legacy server that requires the deprecated initialize handshake." + ? "The server appears to require the initialize handshake." : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); } - await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); + await PerformInitializeHandshakeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); } else { @@ -433,11 +433,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } else { - // Legacy initialize handshake. Reached only when the caller explicitly pinned a + // initialize handshake. Reached only when the caller explicitly pinned a // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so // _options.ProtocolVersion is non-null here. string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; - await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); + await PerformInitializeHandshakeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } } catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -457,10 +457,10 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } /// - /// Performs the legacy initialize handshake (initialize request + initialized notification), + /// Performs the initialize handshake (initialize request + initialized notification), /// records the negotiated protocol version, and stores the server capabilities/info/instructions. /// - private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken) + private async Task PerformInitializeHandshakeAsync(string requestProtocol, CancellationToken cancellationToken) { var initializeResponse = await SendRequestAsync( RequestMethods.Initialize, @@ -487,7 +487,7 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella _serverInstructions = initializeResponse.Instructions; // When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it. - // When no version was pinned, accept any supported legacy response. initialize cannot negotiate + // When no version was pinned, accept any supported initialize-handshake response. initialize cannot negotiate // the 2026-07-28 and later protocol revisions. bool isResponseProtocolValid; if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index b79264afd..74736d36f 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -52,16 +52,20 @@ public sealed class McpClientOptions /// /// /// + /// Supported values are 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, + /// and 2026-07-28. + /// + /// /// When (the default), the client prefers the latest revision (2026-07-28), /// which removed the initialize handshake and Streamable HTTP sessions. It probes with - /// server/discover and automatically falls back to the legacy initialize handshake, - /// downgrading to a legacy version the server advertises, when the server does not support that revision. + /// server/discover and automatically falls back to the initialize handshake, + /// downgrading to an initialize-capable version the server advertises, when the server does not support that revision. /// /// /// When non-, this value is both the requested version and the minimum the client /// will accept: the client requests exactly this version and refuses to downgrade below it, throwing an /// instead of falling back. Setting it to 2026-07-28 therefore disables - /// the automatic legacy-server fallback, and setting it to a version that still supports Streamable HTTP + /// the automatic initialize-handshake server fallback, and setting it to a version that still supports Streamable HTTP /// sessions, such as 2025-11-25, forces the initialize handshake and fails if the server /// negotiates a different version. To try more than one version, leave this unset for automatic fallback /// or retry the connection with a different value. @@ -90,7 +94,7 @@ public sealed class McpClientOptions /// /// Gets or sets the timeout applied to the server/discover probe that the client issues - /// before falling back to the legacy initialize handshake. + /// before falling back to the initialize handshake. /// /// /// The probe timeout. The default value is 5 seconds. Use @@ -102,17 +106,17 @@ public sealed class McpClientOptions /// This timeout only has an effect when the client prefers the 2026-07-28 protocol revision, that is, /// when is (the default) or 2026-07-28. /// In that mode the client first probes the server with a - /// server/discover request. A legacy server that predates the 2026-07-28 revision may + /// server/discover request. A server that predates the 2026-07-28 revision may /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the - /// client concludes the server is legacy and falls back to the initialize handshake on the - /// same connection. When the caller pins a legacy , no probe is issued + /// client concludes the server requires initialize and falls back to that handshake on the + /// same connection. When the caller pins an initialize-capable , no probe is issued /// and this value has no effect. /// /// - /// The default is intentionally short so that dual-era clients fall back quickly against legacy + /// The default is intentionally short so that dual-path clients fall back quickly against initialize-handshake /// servers. Increase it for high-latency environments (for example, cold-start serverless peers or - /// satellite links) where a short probe could trigger the legacy fallback before a server on the new revision - /// server has had a chance to respond. The probe is always also bounded by + /// satellite links) where a short probe could trigger the initialize fallback before a server on the + /// per-request metadata revision has had a chance to respond. The probe is always also bounded by /// , which governs the overall connect budget: if this value is /// greater than or equal to , the probe is effectively bounded by /// alone. diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 9e1bd5a2c..9d4bfe77c 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -67,19 +67,19 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation // Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a // JSON-RPC error envelope means the peer is signalling something application-level about // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the - // connect-time logic can react. For example, the three modern protocol error codes + // connect-time logic can react. For example, the three per-request metadata protocol error codes // (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability, // -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from - // legacy servers that don't understand the SEP-2575 _meta envelope) become generic + // initialize-handshake servers that don't understand the SEP-2575 _meta envelope) become generic // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. - // The three modern protocol error codes are also surfaced for non-400 status codes + // The three per-request metadata protocol error codes are also surfaced for non-400 status codes // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && (response.StatusCode == HttpStatusCode.BadRequest || - IsModernProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) + IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -87,7 +87,7 @@ await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsModernProtocolErrorCode(McpErrorCode code) => + private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) => code is McpErrorCode.UnsupportedProtocolVersion or McpErrorCode.MissingRequiredClientCapability or McpErrorCode.HeaderMismatch; @@ -214,14 +214,6 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes throw new McpException($"Streamable HTTP POST response completed without a reply to request with ID: {rpcRequest.Id}"); } - if (McpHttpHeaders.RequiresPerRequestMetadata(protocolVersionForRequest) && - response.Headers.Contains(McpHttpHeaders.SessionId)) - { - throw new McpProtocolException( - $"The server returned '{McpHttpHeaders.SessionId}' on a response using protocol version '{protocolVersionForRequest}', but that protocol revision does not support HTTP sessions.", - McpErrorCode.InvalidRequest); - } - if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) { // We've successfully initialized! Copy session-id and protocol version, then start GET request if any. @@ -536,7 +528,7 @@ internal static void CopyAdditionalHeaders( string? protocolVersion, string? lastEventId = null) { - if (sessionId is not null) + if (sessionId is not null && McpHttpHeaders.SupportsHttpSessions(protocolVersion)) { headers.Add(McpHttpHeaders.SessionId, sessionId); } diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 30e800ecb..98ec8c3d5 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -575,8 +575,8 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) // 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 modern-server signal and surface as-is rather - // than mistaking it for a legacy server and falling back to the initialize handshake. + // 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( diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs index 5ec348c06..4b992ebe4 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -14,10 +14,13 @@ namespace ModelContextProtocol.Protocol; public sealed class DiscoverResult : Result, ICacheableResult { /// - /// Gets or sets the list of MCP protocol version strings that the server supports. + /// Gets or sets the list of MCP protocol version strings the server supports for subsequent + /// per-request metadata requests. /// /// - /// The client should choose a version from this list for use in subsequent requests. + /// The client should choose a version from this list for subsequent requests that carry the + /// 2026-07-28-style per-request _meta envelope. Versions that require the + /// initialize handshake are negotiated through initialize instead. /// [JsonPropertyName("supportedVersions")] public required IList SupportedVersions { get; set; } diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 646dac75e..0daeb010a 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -217,7 +217,7 @@ public sealed class Converter : JsonConverter // Per JSON-RPC 2.0, when an error occurs before the request id can be determined // (e.g. parse error or invalid request), the server MUST respond with id=null. // Accept null-id error responses so callers can recognize the structured signal - // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-modern error code). + // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-SEP-2575 error code). return new JsonRpcError { Id = id, diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 80b412d01..29e15de31 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -27,6 +27,9 @@ internal sealed partial class McpServerImpl : McpServer private readonly NotificationHandlers _notificationHandlers; private readonly RequestHandlers _requestHandlers; private readonly McpSessionHandler _sessionHandler; + private readonly string[] _supportedProtocolVersions; + private readonly string[] _initializeHandshakeProtocolVersions; + private readonly string[] _perRequestMetadataProtocolVersions; private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly ConcurrentDictionary _taskCancellationSources = new(); private readonly ConcurrentDictionary _mrtrContinuations = new(); @@ -80,6 +83,9 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact _sessionTransport = transport; ServerOptions = options; Services = serviceProvider; + _supportedProtocolVersions = GetConfiguredSupportedProtocolVersions(options.ProtocolVersion); + _initializeHandshakeProtocolVersions = [.. _supportedProtocolVersions.Where(McpHttpHeaders.SupportsInitializeHandshake)]; + _perRequestMetadataProtocolVersions = [.. _supportedProtocolVersions.Where(McpHttpHeaders.RequiresPerRequestMetadata)]; _serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})"; _endpointName = _serverOnlyEndpointName; _servicesScopePerRequest = options.ScopeRequests; @@ -167,7 +173,7 @@ void Register(McpServerPrimitiveCollection? collection, /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so these values - /// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in + /// MUST be populated per-request. 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). /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) @@ -192,11 +198,11 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner // 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 (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion)) + if (!_supportedProtocolVersions.Contains(protocolVersion)) { throw new UnsupportedProtocolVersionException( requested: protocolVersion, - supported: McpSessionHandler.SupportedProtocolVersions); + supported: _supportedProtocolVersions); } if (McpHttpHeaders.RequiresPerRequestMetadata(protocolVersion)) @@ -213,7 +219,7 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner { throw new UnsupportedProtocolVersionException( requested: protocolVersion, - supported: McpHttpHeaders.PerRequestMetadataProtocolVersions, + supported: _perRequestMetadataProtocolVersions, message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata."); } @@ -236,7 +242,7 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner { throw new McpProtocolException( $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", - McpErrorCode.InvalidRequest); + McpErrorCode.InvalidParams); } if (hasReservedPerRequestMeta) @@ -255,7 +261,7 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner { // Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL // capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate - // makes any legacy per-request envelope a no-op (legacy capabilities stay as the + // makes any initialize-handshake per-request envelope a no-op (capabilities stay as the // initialize handshake established them); the HasStatefulTransport() gate keeps // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } // (where the same server instance handles every request, so persisting per-request @@ -314,7 +320,7 @@ private static void ValidateRequiredPerRequestMetadata( private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) => throw new McpProtocolException( $"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.", - McpErrorCode.InvalidRequest); + McpErrorCode.InvalidParams); private static void ThrowReservedPerRequestMetadata(string? requestedProtocolVersion, string key) => throw new McpProtocolException( @@ -343,14 +349,14 @@ request.Params is JsonObject paramsObj && paramsObj["_meta"] is JsonObject metaObj && metaObj.ContainsKey(key); - private static void ValidateInitializeRequestBoundary(JsonRpcRequest request) + private void ValidateInitializeRequestBoundary(JsonRpcRequest request) { if (request.Context?.ProtocolVersion is { } protocolVersion && !McpHttpHeaders.SupportsInitializeHandshake(protocolVersion)) { throw new UnsupportedProtocolVersionException( requested: protocolVersion, - supported: McpHttpHeaders.InitializeHandshakeProtocolVersions, + supported: _initializeHandshakeProtocolVersions, message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake."); } @@ -372,6 +378,23 @@ paramsObj[propertyName] is JsonValue value && return null; } + private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) + { + if (protocolVersion is null) + { + return McpHttpHeaders.SupportedProtocolVersions; + } + + if (!McpHttpHeaders.IsSupportedProtocolVersion(protocolVersion)) + { + throw new McpException( + $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + + string.Join(", ", McpHttpHeaders.SupportedProtocolVersions) + "."); + } + + return [protocolVersion]; + } + private void ValidateNotificationBoundary(JsonRpcNotification notification) { if (notification.Method == NotificationMethods.InitializedNotification && @@ -391,7 +414,8 @@ private void ValidateRequestMethodBoundary(JsonRpcRequest request) request.Method is RequestMethods.SubscriptionsListen or RequestMethods.TasksGet or RequestMethods.TasksUpdate - or RequestMethods.TasksCancel) + or RequestMethods.TasksCancel + or RequestMethods.ServerDiscover) { throw new McpProtocolException( $"The method '{request.Method}' requires a newer protocol revision that supports per-request metadata; " + @@ -549,7 +573,7 @@ private void ConfigureInitialize(McpServerOptions options) UpdateEndpointNameWithClientInfo(); _sessionHandler.EndpointName = _endpointName; - // Negotiate a legacy protocol version. initialize is not available in the 2026-07-28 + // Negotiate an initialize-handshake protocol version. initialize is not available in the 2026-07-28 // and later protocol revisions, so those versions must use server/discover with // per-request _meta instead. string? protocolVersion = options.ProtocolVersion; @@ -558,8 +582,8 @@ private void ConfigureInitialize(McpServerOptions options) { throw new UnsupportedProtocolVersionException( configuredProtocolVersion, - McpHttpHeaders.InitializeHandshakeProtocolVersions, - $"Protocol version '{configuredProtocolVersion}' is not available through the legacy initialize handshake."); + _initializeHandshakeProtocolVersions, + $"Protocol version '{configuredProtocolVersion}' is not available through the initialize handshake."); } if (protocolVersion is null) @@ -570,8 +594,8 @@ private void ConfigureInitialize(McpServerOptions options) { throw new UnsupportedProtocolVersionException( clientProtocolVersion, - McpHttpHeaders.InitializeHandshakeProtocolVersions, - $"Protocol version '{clientProtocolVersion}' is not available through the legacy initialize handshake."); + _initializeHandshakeProtocolVersions, + $"Protocol version '{clientProtocolVersion}' is not available through the initialize handshake."); } protocolVersion = McpHttpHeaders.SupportsInitializeHandshake(clientProtocolVersion) ? @@ -586,8 +610,8 @@ private void ConfigureInitialize(McpServerOptions options) string negotiatedProtocolVersion = protocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; - // The legacy initialize handshake is authoritative: it may supersede a protocol version - // a prior server/discover probe established on the same connection (the dual-era + // 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. @@ -610,9 +634,9 @@ private void ConfigureInitialize(McpServerOptions options) /// Registers the server/discover request handler introduced by the 2026-07-28 protocol revision (SEP-2575). /// /// - /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's - /// supported protocol versions (), server - /// capabilities, server info, and optional instructions. + /// The handler is registered unconditionally so requests can be routed to the protocol boundary filters. Successful + /// server/discover responses advertise only protocol versions available through per-request metadata; versions + /// that require the initialize handshake are negotiated through initialize instead. /// private void ConfigureDiscover(McpServerOptions options) { @@ -621,7 +645,7 @@ private void ConfigureDiscover(McpServerOptions options) { return new ValueTask(new DiscoverResult { - SupportedVersions = [.. McpSessionHandler.SupportedProtocolVersions], + SupportedVersions = [.. _perRequestMetadataProtocolVersions], Capabilities = ServerCapabilities ?? new(), ServerInfo = options.ServerInfo ?? DefaultImplementation, Instructions = options.ServerInstructions, @@ -671,7 +695,7 @@ private void ConfigureSubscriptions(McpServerOptions options) // changes back to the client (tracked by #1662). Rather than hold the POST open forever only // to deliver nothing - pinning the connection and its request scope - acknowledge the listen // request granting no notifications and complete immediately. This runs after protocol - // negotiation, so it is not a legacy-server signal and never triggers a client fallback to the + // negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the // initialize handshake. if (!HasStatefulTransport()) { @@ -751,7 +775,7 @@ private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifi /// private async Task SendListChangedNotificationAsync(string notificationMethod) { - // Legacy clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. + // Initialize-handshake clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. // subscriptions/listen is a SEP-2575 feature, so clients on the 2026-07-28 or later revision instead get // a fan-out limited to the notification types they explicitly subscribed to. if (!IsJuly2026OrLaterProtocol()) @@ -1034,8 +1058,8 @@ private void ConfigureTasks(McpServerOptions options) cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with - // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers - // stay registered so a dual-era server still serves them for 2026-07-28 requests. + // MethodNotFound when the request was negotiated under an initialize-handshake protocol version. The handlers + // stay registered so a dual-path server still serves them for 2026-07-28 requests. getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet); updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate); cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel); diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index a52fe7cab..82f925ff4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -14,7 +14,7 @@ public sealed class McpServerOptions /// Gets or sets information about this server implementation, including its name and version. /// /// - /// This information is sent to the client during initialization to identify the server. + /// This information is sent to the client during initialization or discovery to identify the server. /// It's displayed in client logs and can be used for debugging and compatibility checks. /// public Implementation? ServerInfo { get; set; } @@ -30,15 +30,26 @@ public sealed class McpServerOptions public ServerCapabilities? Capabilities { get; set; } /// - /// Gets or sets the legacy protocol version used by the initialize handshake, using a date-based versioning scheme. + /// Gets or sets the protocol version supported by this server, using a date-based versioning scheme. /// /// - /// The protocol version defines which legacy features and message formats this server uses for - /// sessions established through initialize. This uses a date-based versioning scheme in the - /// format "YYYY-MM-DD". If , the server negotiates the legacy version requested - /// by the client if that version is known to be supported, and otherwise uses the latest supported - /// legacy version. Protocol revisions starting with 2026-07-28 do not use initialize; - /// clients select them with server/discover and per-request metadata instead. + /// + /// The protocol version defines which features and message formats this server supports. Supported + /// values are 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and + /// 2026-07-28. + /// + /// + /// If , the server supports all of the versions listed above. For clients using + /// the initialize handshake, the server returns the requested initialize-capable version when it + /// is supported and otherwise returns 2025-11-25. For clients using server/discover and + /// per-request metadata, the server advertises the supported per-request metadata versions; currently + /// this is 2026-07-28. + /// + /// + /// Set this property to a specific supported value to pin the server to that version. Setting it to + /// 2026-07-28 makes the server reject initialize handshakes; setting it to an earlier + /// value makes the server reject 2026-07-28 per-request metadata. + /// /// public string? ProtocolVersion { get; set; } @@ -75,12 +86,13 @@ public sealed class McpServerOptions public bool ScopeRequests { get; set; } = true; /// - /// Gets or sets preexisting knowledge about the client including its name and version to help support - /// stateless Streamable HTTP servers that encode this knowledge in the mcp-session-id header. + /// Gets or sets preexisting knowledge about the client including its name and version. /// /// /// - /// When not specified, this information is sourced from the client's initialize request. + /// When not specified, this information is sourced from the client's initialize request or, + /// for protocol versions that use per-request metadata, from the current request's _meta field. + /// This is typically set during session migration in conjunction with . /// /// public Implementation? KnownClientInfo { get; set; } @@ -91,7 +103,8 @@ public sealed class McpServerOptions /// /// /// - /// When not specified, this information is sourced from the client's initialize request. + /// When not specified, this information is sourced from the client's initialize request or, + /// for protocol versions that use per-request metadata, from the current request's _meta field. /// This is typically set during session migration in conjunction with . /// /// diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 45ecdcde2..96e13c4d1 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -315,7 +315,7 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion await process.WaitForExitAsync(cts.Token); #else // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill - // extension and surface a timeout the same way the modern path does. + // extension and surface a timeout the same way the current target-framework path does. await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); if (!process.HasExited) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index d590b003b..43ba8946e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -427,17 +427,17 @@ public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() } [Fact] - public async Task Server_SkipsHeaderValidation_ForLegacyVersion() + public async Task Server_SkipsHeaderValidation_ForInitializeHandshakeVersion() { await StartAsync(); - await InitializeWithLegacyVersionAsync(); + await InitializeWithInitializeHandshakeVersionAsync(); - // With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched - var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includeModernMeta: false); + // With the initialize-handshake version, Mcp-Param-* headers are NOT validated even if mismatched. + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includePerRequestMetadata: false); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - // Send the WRONG header value. This should still succeed because the version is legacy. + // Send the WRONG header value. This should still succeed because the version uses initialize. request.Headers.Add("MCP-Protocol-Version", "2025-11-25"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); @@ -586,14 +586,14 @@ private async Task ProbeWithJuly2026ProtocolVersionAsync() // metadata instead of initialize. } - private async Task InitializeWithLegacyVersionAsync() + private async Task InitializeWithInitializeHandshakeVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return + // Server is stateless by default (SEP-2567), so initializing with an initialize-handshake protocol does not return // a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision. } @@ -601,10 +601,10 @@ private async Task InitializeWithLegacyVersionAsync() private long _lastRequestId = 1; - private string CallTool(string toolName, string arguments = "{}", bool includeModernMeta = true) + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); - var meta = includeModernMeta + var meta = includePerRequestMetadata ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""TestClient"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" : ""; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index ed8da12a9..34c16e519 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -14,7 +14,8 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These +/// Regression tests for the fallback from a 2026-07-28 per-request-metadata probe to the initialize +/// handshake over Streamable HTTP. These /// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's /// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body /// on a 2026-07-28 probe; vanilla Go does the same on POST /) so the client's HTTP-fallback @@ -29,15 +30,15 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// only surfaced the three error codes /// introduced by the 2026-07-28 revision (-32022, -32021, -32020) as ; -/// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server +/// any other JSON-RPC error code in a 400 body (e.g. -32600 from an initialize-handshake server /// that doesn't understand the 2026-07-28 _meta envelope) threw /// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger -/// on ANY non-modern JSON-RPC error in a 400 body. +/// on ANY non-SEP-2575 JSON-RPC error in a 400 body. /// /// /// treated any non-2xx HTTP response as a /// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked -/// application-level errors (including the three modern codes) because the SSE GET would +/// application-level errors (including the three SEP-2575/SEP-2567 codes) because the SSE GET would /// either fail with "session id required" or succeed against a different endpoint and lose /// the actual signal. /// @@ -86,11 +87,11 @@ private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatus /// /// Mimics Python's simple-streamablehttp-stateless on a 2026-07-28 probe: returns /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the - /// initial server/discover, then performs a normal legacy initialize handshake + /// initial server/discover, then performs a normal initialize handshake /// when the client falls back. /// [Fact] - public async Task Client_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + public async Task Client_AgainstInitializeHandshakeHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() { var ct = TestContext.Current.CancellationToken; @@ -107,7 +108,7 @@ await StartServerAsync(async context => return; } - // 2026-07-28 probe: simulate a legacy server that rejects the unknown protocol version with + // 2026-07-28 probe: simulate an initialize-handshake server that rejects the unknown protocol version with // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). if (request.Method == RequestMethods.ServerDiscover) { @@ -115,7 +116,7 @@ await StartServerAsync(async context => return; } - // Legacy initialize: respond with the highest version the legacy server speaks. + // Initialize handshake: respond with the highest version this server speaks. if (request.Method == RequestMethods.Initialize) { var response = new JsonRpcResponse @@ -125,7 +126,7 @@ await StartServerAsync(async context => { ProtocolVersion = "2025-06-18", Capabilities = new() { Tools = new() }, - ServerInfo = new Implementation { Name = "legacy", Version = "1.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; @@ -157,7 +158,7 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); @@ -170,7 +171,7 @@ await StartServerAsync(async context => /// /// Mimics vanilla Go: returns 400 + JSON-RPC -32022 with - /// data.supported[] on a 2026-07-28 probe so the client retries legacy + /// data.supported[] on a 2026-07-28 probe so the client retries /// initialize with one of the advertised versions. /// [Fact] @@ -244,7 +245,7 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); @@ -253,7 +254,7 @@ await StartServerAsync(async context => /// /// A 400 with a JSON-RPC -32020 HeaderMismatch envelope must be surfaced to the - /// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope. + /// caller (no initialize fallback). Falling back wouldn't fix a malformed envelope. /// [Fact] public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() @@ -302,9 +303,10 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, } [Fact] - public async Task Client_OnModernResponseWithMcpSessionId_RejectsSessionState() + public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() { var ct = TestContext.Current.CancellationToken; + string? toolsListSessionId = null; await StartServerAsync(async context => { @@ -322,7 +324,7 @@ await StartServerAsync(async context => { SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "bad-modern-server", Version = "1.0" }, + ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, }, McpJsonUtilities.DefaultOptions), @@ -334,6 +336,21 @@ await StartServerAsync(async context => return; } + if (message is JsonRpcRequest { Method: RequestMethods.ToolsList } toolsListRequest) + { + toolsListSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = toolsListRequest.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + context.Response.StatusCode = StatusCodes.Status202Accepted; }); @@ -342,15 +359,68 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - var exception = await Assert.ThrowsAsync(async () => + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + await client.ListToolsAsync(cancellationToken: ct); + + Assert.Null(client.SessionId); + Assert.Equal("", toolsListSessionId); + } + + [Fact] + public async Task Client_WithKnownSessionId_DoesNotEchoIt_OnPerRequestMetadataRequest() + { + var ct = TestContext.Current.CancellationToken; + string? discoverSessionId = null; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, - }, loggerFactory: LoggerFactory, cancellationToken: ct); + discoverSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; }); - Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); - Assert.Contains(McpHttpHeaders.SessionId, exception.Message, StringComparison.Ordinal); + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + KnownSessionId = "legacy-session", + OwnsSession = false, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("", discoverSessionId); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index fe7933ec0..6483f7e98 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server -/// suppresses the Mcp-Session-Id header for those requests and returns structured +/// does not issue Mcp-Session-Id for those requests and returns structured /// errors instead of plain 400s. /// public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable @@ -70,8 +70,8 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567), // so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that - // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back - // to the legacy initialize handshake. + // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back + // to the initialize handshake. await StartAsync(stateless: false); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); @@ -94,7 +94,7 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); Assert.NotNull(errorData); Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested); - // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version. + // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to an initialize-capable version. Assert.NotEmpty(errorData.Supported); Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported); } @@ -102,7 +102,7 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers [Fact] public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() { - await StartAsync(); + await StartAsync(stateless: true); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); @@ -128,23 +128,23 @@ public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProto } [Fact] - public async Task Request_WithMcpSessionIdHeader_IsRejected() + public async Task Request_WithMcpSessionIdHeader_IgnoresHeader_AndDoesNotEchoSessionId() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): - // a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the - // Stateless setting. - await StartAsync(); + // a request carrying an Mcp-Session-Id is ignored, and the server must not mint or echo session IDs. + await StartAsync(stateless: true); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); } [Fact] @@ -156,7 +156,7 @@ public async Task Get_WithoutSessionId_IsRejected() using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } [Fact] @@ -169,7 +169,7 @@ public async Task Get_WithSessionId_IsRejected() using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } [Fact] @@ -181,7 +181,7 @@ public async Task Delete_WithoutSessionId_IsRejected() using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } [Fact] @@ -194,7 +194,7 @@ public async Task Delete_WithSessionId_IsRejected() using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } private static string DiscoverRequestJuly2026Protocol => """ diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index ef2d3f6aa..0ee20b504 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -14,9 +14,9 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// server that deliberately opted into sessions ( /// is false). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports /// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with -32022 UnsupportedProtocolVersion. -/// The client must then auto-downgrade to the legacy initialize handshake, obtain the stateful session +/// The client must then auto-downgrade to the initialize handshake, obtain the stateful session /// the server author opted into, and continue to work, including a server→client elicitation round-trip -/// resolved over the stateful session via the legacy backcompat resolver. +/// resolved over the stateful session via the initialize-handshake backcompat resolver. /// public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { @@ -38,7 +38,7 @@ public async ValueTask DisposeAsync() private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) { // Server→client round-trip: only works when the session is stateful, which is exactly what - // the legacy fallback re-establishes for the 2026-07-28-first client. + // the initialize fallback re-establishes for the 2026-07-28-first client. var elicitResult = await server.ElicitAsync(new ElicitRequestParams { Message = "What is your name?", @@ -77,20 +77,20 @@ private async Task ConnectDefaultClientAsync(Action }, HttpClient, LoggerFactory); // Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes - // with server/discover before falling back to a legacy initialize handshake. + // with server/discover before falling back to an initialize handshake. var clientOptions = new McpClientOptions(); configureClient?.Invoke(clientOptions); return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); } [Fact] - public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() + public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_AndToolsWork() { await StartStatefulServerAsync(); await using var client = await ConnectDefaultClientAsync(); - // The 2026-07-28 probe was refused (-32022), so the client downgraded to legacy. + // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet", diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 0fa99b166..ee361f9a6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -261,7 +261,7 @@ public async Task CapabilityCheck_OnlyEmitsInputRequestsForDeclaredCapabilities( public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() { // Configure a server that does NOT pin 2026-07-28 so it can negotiate the current - // protocol with a legacy client. The backcompat resolver path only runs when the + // initialize-handshake protocol. The backcompat resolver path only runs when the // negotiated version is not 2026-07-28. Builder.Services.AddMcpServer(options => { @@ -302,7 +302,7 @@ static string (RequestContext context) => HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - // Initialize with the current legacy protocol so the server's backcompat resolver runs. + // Initialize with the current initialize-handshake protocol so the server's backcompat resolver runs. var initJson = """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}} """; @@ -337,7 +337,7 @@ static string (RequestContext context) => // the response headers arrive instead of waiting for the SSE stream to close. var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null) { - Content = JsonContent(CallTool("backcompat-roots-tool", includeModernMeta: false)), + Content = JsonContent(CallTool("backcompat-roots-tool", includePerRequestMetadata: false)), }; callRequest.Content.Headers.Add("Mcp-Method", "tools/call"); callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool"); @@ -456,11 +456,11 @@ private Task PostJsonRpcAsync(string json) private long _lastRequestId = 1; - private string Request(string method, string parameters = "{}", bool includeModernMeta = true) + private string Request(string method, string parameters = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject(); - if (includeModernMeta) + if (includePerRequestMetadata) { AddJuly2026ProtocolMeta(paramsObj); } @@ -497,8 +497,8 @@ private static void AddJuly2026ProtocolMeta(JsonObject paramsObj) } } - private string CallTool(string toolName, string arguments = "{}", bool includeModernMeta = true) => + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) => Request("tools/call", $$""" {"name":"{{toolName}}","arguments":{{arguments}}} - """, includeModernMeta); + """, includePerRequestMetadata); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index d9ed7832e..fbefc7344 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -23,12 +23,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn private WebApplication? _app; - private async Task StartAsync() + private async Task StartAsync(string? protocolVersion = null) { Builder.Services .AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; + options.ProtocolVersion = protocolVersion; }) .WithHttpTransport() .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); @@ -126,7 +127,7 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Equal([McpHttpHeaders.July2026ProtocolVersion], supported); // 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. @@ -135,6 +136,24 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); } + [Fact] + public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() + { + await StartAsync(McpHttpHeaders.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpHttpHeaders.July2026ProtocolVersion], supported); + } + [Fact] public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32022() { @@ -151,7 +170,7 @@ public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_W using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload - // listing the supported versions. The dual-era client uses this to switch versions without fallback. + // listing the supported versions. The dual-path client uses this to switch versions without fallback. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); @@ -171,8 +190,8 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi // The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a // different (still individually supported) version. Per SEP-2575 the server MUST reject the // disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body - // checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a - // legacy one and falling back to the initialize handshake. + // checks) so a conformant client on this revision surfaces the error instead of mistaking the + // per-request-metadata server for an initialize-handshake one and falling back to initialize. var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("2025-11-25") + "}}"; @@ -187,6 +206,30 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); } + [Fact] + public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion() + { + await StartAsync(McpHttpHeaders.November2025ProtocolVersion); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue()); + + var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpHttpHeaders.November2025ProtocolVersion], supported); + } + [Fact] public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020() { @@ -223,11 +266,11 @@ public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatc } [Fact] - public async Task Initialize_WithModernProtocolHeaderAndLegacyBody_ReturnsHeaderMismatch_Minus32020() + public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBody_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""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""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); @@ -240,11 +283,11 @@ public async Task Initialize_WithModernProtocolHeaderAndLegacyBody_ReturnsHeader } [Fact] - public async Task LegacyInitialize_StillSucceeds_OnDefaultServer() + public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""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""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 7cd79b410..2c205a805 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -926,7 +926,7 @@ public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includeModernMeta: true)); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header @@ -942,7 +942,7 @@ public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includeModernMeta: true)); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method @@ -958,7 +958,7 @@ public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includeModernMeta: true)); + request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -968,12 +968,12 @@ public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() } [Fact] - public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() + public async Task InitializeHandshakeVersion_DoesNotRequireMcpMethodHeader() { await StartAsync(); await CallInitializeAndValidateAsync(); - // With the legacy version, Mcp-Method header is not required + // With the initialize-handshake version, Mcp-Method header is not required. using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); request.Headers.Add("MCP-Protocol-Version", "2025-03-26"); @@ -1073,9 +1073,9 @@ private string Request(string method, string parameters = "{}") """; } - private string CallTool(string toolName, string arguments = "{}", bool includeModernMeta = false) + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = false) { - var meta = includeModernMeta + var meta = includePerRequestMetadata ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""IntegrationTestClient"",""version"":""1.0.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" : ""; diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index a06548a4f..aa75bb184 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -43,7 +43,7 @@ public async Task Client_RequestingJuly2026Protocol_NegotiatesIt() } [Fact] - public async Task Client_RequestingLegacyVersion_NegotiatesLegacy() + public async Task Client_RequestingInitializeHandshakeVersion_NegotiatesIt() { StartServer(); @@ -53,23 +53,21 @@ public async Task Client_RequestingLegacyVersion_NegotiatesLegacy() } [Fact] - public async Task LegacyClient_CanCallServerDiscover() + public async Task InitializeHandshakeClient_CannotCallServerDiscover() { - // server/discover is registered unconditionally, so a legacy client can probe it - // (e.g., to learn capabilities without doing a second initialize). + // server/discover is registered unconditionally so the protocol boundary filter can return a structured + // error, but initialize-handshake clients cannot use it after negotiating an older protocol version. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - var response = await client.SendRequestAsync( - new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, - TestContext.Current.CancellationToken); + var exception = await Assert.ThrowsAsync(async () => + await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken)); - var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); - Assert.NotNull(discoverResult); - Assert.NotEmpty(discoverResult.SupportedVersions); - Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); - Assert.Equal(nameof(July2026ProtocolConnectionTests), discoverResult.ServerInfo.Name); + Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); + Assert.Contains(RequestMethods.ServerDiscover, exception.Message, StringComparison.Ordinal); } [Fact] @@ -85,6 +83,6 @@ public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions); + Assert.Equal([McpHttpHeaders.July2026ProtocolVersion], discoverResult.SupportedVersions); } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index b51b33d76..6eab236a6 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -8,17 +8,17 @@ namespace ModelContextProtocol.Tests.Client; /// -/// Regression tests for the fallback from the 2026-07-28 protocol revision to a legacy protocol in +/// Regression tests for the fallback from the 2026-07-28 protocol revision to an initialize-handshake protocol in /// . With default options (ProtocolVersion = null) the client prefers -/// 2026-07-28 but probes with server/discover, falls back to the legacy initialize -/// handshake when the server is legacy, and accepts whatever supported protocol version the legacy +/// 2026-07-28 but probes with server/discover, falls back to the initialize +/// handshake when the server only supports that path, and accepts whatever supported protocol version the /// server negotiates. Pinning ProtocolVersion to 2026-07-28 instead makes it the /// minimum too, so the client refuses to fall back. /// /// -/// The originally shipped logic in PerformLegacyInitializeAsync compared the server's response -/// against the requested version and threw when a legacy server downgraded to (say) "2025-06-18", -/// even though the legacy negotiation succeeded. These tests guard against that regression. +/// The originally shipped initialize-handshake fallback logic compared the server's response +/// against the requested version and threw when an initialize-handshake server downgraded to (say) +/// "2025-06-18", even though negotiation succeeded. These tests guard against that regression. /// public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { @@ -26,15 +26,15 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: "2025-06-18"); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); } @@ -42,7 +42,7 @@ public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngrad public async Task Client_OnInvalidParams_FallsBackTo_Initialize() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( + await using var transport = new InitializeHandshakeServerTestTransport( serverNegotiatedVersion: "2025-11-25", probeErrorCode: (int)McpErrorCode.InvalidParams); @@ -50,16 +50,16 @@ public async Task Client_OnInvalidParams_FallsBackTo_Initialize() await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); } [Fact] - public async Task Client_OnLegacyFallback_RejectsModernInitializeResponse() + public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializeResponse() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( + await using var transport = new InitializeHandshakeServerTestTransport( serverNegotiatedVersion: McpHttpHeaders.July2026ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => @@ -69,16 +69,16 @@ public async Task Client_OnLegacyFallback_RejectsModernInitializeResponse() }); Assert.IsType(exception); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServer() + public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToInitializeHandshakeServer() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: "2025-06-18"); var exception = await Assert.ThrowsAnyAsync(async () => { @@ -94,11 +94,11 @@ public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServe } [Fact] - public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() + public async Task InitializeHandshakeClient_WithExplicitPin_StillRequires_ExactVersionMatch() { var ct = TestContext.Current.CancellationToken; // Server responds with a DIFFERENT version than the one the user pinned. - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: "2025-03-26"); var exception = await Assert.ThrowsAnyAsync(async () => { @@ -115,11 +115,11 @@ public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() [Fact] public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() { - // The peer is modern (returns the spec-defined -32020 HeaderMismatch on the probe). - // Falling back to legacy initialize would just produce another malformed envelope. + // The peer uses per-request metadata (returns the spec-defined -32020 HeaderMismatch on the probe). + // Falling back to initialize would just produce another malformed envelope. // Verify the connect-time logic surfaces the error to the caller instead of falling back. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( + await using var transport = new InitializeHandshakeServerTestTransport( serverNegotiatedVersion: "2025-11-25", probeErrorCode: (int)McpErrorCode.HeaderMismatch); @@ -132,18 +132,18 @@ public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() }); Assert.True(transport.ServerDiscoverProbed); - Assert.False(transport.LegacyInitializeReceived); + Assert.False(transport.InitializeReceived); Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); } [Fact] public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() { - // Simulate a legacy server that silently drops the unknown server/discover method (it never - // responds to the probe). The client must fall back to legacy initialize once the configured + // Simulate an initialize-handshake server that silently drops the unknown server/discover method (it never + // responds to the probe). The client must fall back to initialize once the configured // DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( + await using var transport = new InitializeHandshakeServerTestTransport( serverNegotiatedVersion: "2025-11-25", silentDiscoverProbe: true); @@ -157,8 +157,8 @@ public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredPro stopwatch.Stop(); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.LegacyInitializeProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. @@ -193,25 +193,25 @@ public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues() } /// - /// Minimal in-memory transport that simulates a legacy server: rejects + /// Minimal in-memory transport that simulates an initialize-handshake server: rejects /// server/discover (with a configurable JSON-RPC error code, or by /// silently dropping the request) and responds to initialize with a /// configurable protocol version. /// - private sealed class LegacyServerTestTransport( + private sealed class InitializeHandshakeServerTestTransport( string serverNegotiatedVersion, int probeErrorCode = (int)McpErrorCode.MethodNotFound, bool silentDiscoverProbe = false) : IClientTransport { private readonly Channel _incomingToClient = Channel.CreateUnbounded(); - public string Name => "legacy-server-test-transport"; + public string Name => "initialize-handshake-server-test-transport"; public bool ServerDiscoverProbed { get; private set; } - public bool LegacyInitializeReceived { get; private set; } + public bool InitializeReceived { get; private set; } - public string? LegacyInitializeProtocolVersion { get; private set; } + public string? InitializeProtocolVersion { get; private set; } public Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -229,7 +229,7 @@ private void HandleOutgoingMessage(JsonRpcMessage message) ServerDiscoverProbed = true; if (silentDiscoverProbe) { - // Model a legacy server that drops the unknown method without replying. + // Model an initialize-handshake server that drops the unknown method without replying. break; } @@ -247,9 +247,9 @@ private void HandleOutgoingMessage(JsonRpcMessage message) break; case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: - LegacyInitializeReceived = true; + InitializeReceived = true; var initializeRequest = JsonSerializer.Deserialize(initReq.Params, McpJsonUtilities.DefaultOptions); - LegacyInitializeProtocolVersion = initializeRequest?.ProtocolVersion; + InitializeProtocolVersion = initializeRequest?.ProtocolVersion; _ = WriteAsync(new JsonRpcResponse { Id = initReq.Id, @@ -257,7 +257,7 @@ private void HandleOutgoingMessage(JsonRpcMessage message) { ProtocolVersion = serverNegotiatedVersion, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake-test-server", Version = "1.0.0" }, }, McpJsonUtilities.DefaultOptions), }); break; @@ -269,7 +269,7 @@ private Task WriteAsync(JsonRpcMessage message) private sealed class TransportChannel( Channel incoming, - LegacyServerTestTransport parent) : ITransport + InitializeHandshakeServerTestTransport parent) : ITransport { public ChannelReader MessageReader => incoming.Reader; public bool IsConnected { get; private set; } = true; diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 0db595e8d..e7c338cb9 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -32,4 +32,5 @@ public void RequiresStandardHeaders_ReturnsExpected(string? version, bool expect { Assert.Equal(expected, McpHttpHeaders.RequiresStandardHeaders(version)); } + } diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs index 14f0d497e..4a5873df6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs @@ -43,7 +43,7 @@ public async Task DraftServerOmittingBothHints_LogsWarning(string method) { var (call, result) = GetScenario(method, ttl: null, scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, method, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, method, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains(method) && m.Message.Contains("SEP-2549")); @@ -56,7 +56,7 @@ public async Task DraftServerOmittingOnlyCacheScope_WarnsAboutCacheScope() { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -69,7 +69,7 @@ public async Task DraftServerProvidingBothHints_DoesNotWarn() { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: CacheScope.Public); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -81,7 +81,7 @@ public async Task OlderServerOmittingHints_DoesNotWarn() // A server on an older protocol version may legitimately omit the fields; no warning should fire. var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: null, scope: null); - await RunScenarioAsync(OlderProtocolVersion, useModernLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(OlderProtocolVersion, usePerRequestMetadataLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -98,7 +98,7 @@ public async Task AutoPaginatingOverload_DraftServerOmittingHints_LogsWarning() await RunScenarioAsync( July2026ProtocolVersion, - useModernLifecycle: true, + usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, (c, ct) => c.ListToolsAsync(cancellationToken: ct).AsTask(), @@ -130,7 +130,7 @@ public async Task AutoPaginatingOverload_MultiplePages_WarnsOnlyOncePerMethod() var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, useModernLifecycle: true, TestContext.Current.CancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, TestContext.Current.CancellationToken); await using var client = await clientTask; @@ -208,7 +208,7 @@ private static (Func Call, JsonNode Result) private async Task RunScenarioAsync( string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, string method, JsonNode resultNode, Func clientCall, @@ -217,8 +217,8 @@ private async Task RunScenarioAsync( var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Pin the protocol version so the client deterministically takes the modern (server/discover) - // lifecycle for 2026-07-28 and the legacy (initialize) lifecycle for older versions. + // Pin the protocol version so the client deterministically takes the per-request metadata + // (server/discover) lifecycle for 2026-07-28 and the initialize lifecycle for older versions. var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -231,7 +231,7 @@ private async Task RunScenarioAsync( var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, useModernLifecycle, cancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, usePerRequestMetadataLifecycle, cancellationToken); await using var client = await clientTask; Assert.Equal(serverProtocolVersion, client.NegotiatedProtocolVersion); @@ -271,7 +271,7 @@ private static async Task PerformHandshakeAsync( StreamReader serverReader, Stream serverWriter, string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, CancellationToken cancellationToken) { var requestLine = await serverReader.ReadLineAsync(cancellationToken); @@ -279,9 +279,9 @@ private static async Task PerformHandshakeAsync( var request = JsonSerializer.Deserialize(requestLine, McpJsonUtilities.DefaultOptions); Assert.NotNull(request); - if (useModernLifecycle) + if (usePerRequestMetadataLifecycle) { - // Modern 2026-07-28 lifecycle (SEP-2575): no initialize handshake. The client probes + // Per-request metadata lifecycle (SEP-2575): no initialize handshake. The client probes // server/discover to learn capabilities, then sends normal RPCs carrying per-request _meta. Assert.Equal(RequestMethods.ServerDiscover, request.Method); @@ -298,7 +298,7 @@ private static async Task PerformHandshakeAsync( } else { - // Legacy initialize handshake for older protocol versions. + // Initialize handshake for older protocol versions. Assert.Equal(RequestMethods.Initialize, request.Method); await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 87f363f03..2a74c3e35 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -26,7 +26,7 @@ private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = { return new McpServerOptions { - ProtocolVersion = "2024", + ProtocolVersion = "2024-11-05", InitializationTimeout = TimeSpan.FromSeconds(30), Capabilities = capabilities, }; @@ -285,8 +285,8 @@ await Can_Handle_Requests( Assert.NotNull(result); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); - Assert.Equal("2024", result.ProtocolVersion); - Assert.Equal("2024", server.NegotiatedProtocolVersion); + Assert.Equal("2024-11-05", result.ProtocolVersion); + Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); }); } diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index 2ed44f251..9ef615d0d 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -69,7 +69,7 @@ public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterCha } [Fact] - public async Task PerRequestProtocolVersion_RejectsLegacyVersionBeforeInitialize() + public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize() { var ct = TestContext.Current.CancellationToken; @@ -77,12 +77,12 @@ public async Task PerRequestProtocolVersion_RejectsLegacyVersionBeforeInitialize Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); - // The rejected legacy _meta request must not have established session state. + // The rejected initialize-handshake _meta request must not have established session state. Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); } [Fact] - public async Task PerRequestProtocolVersion_RejectsModernRequestMissingRequiredMetadata() + public async Task PerRequestMetadata_RejectsRequestMissingRequiredMetadata() { var ct = TestContext.Current.CancellationToken; @@ -93,7 +93,7 @@ await RoundTripAsync( ct, includeClientInfo: false)); - Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); } @@ -110,12 +110,12 @@ public async Task ServerDiscover_WithoutPerRequestMetadata_IsRejectedBeforeIniti }; var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); } [Fact] - public async Task Initialize_WithModernProtocolVersion_IsRejected() + public async Task Initialize_WithPerRequestMetadataProtocolVersion_IsRejected() { var ct = TestContext.Current.CancellationToken; @@ -144,7 +144,7 @@ public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() { [MetaKeys.ClientInfo] = new JsonObject { - ["name"] = "modern-meta-client", + ["name"] = "per-request-meta-client", ["version"] = "1.0.0", }, }, @@ -188,7 +188,7 @@ public async Task LoggingSetLevel_WithPerRequestMetadataProtocolVersion_IsReject Params = new JsonObject { ["level"] = "info", - ["_meta"] = ModernMeta(), + ["_meta"] = PerRequestMetadata(), }, }; @@ -204,7 +204,7 @@ private async Task RoundTripAsync( bool includeClientInfo = true, bool includeClientCapabilities = true) { - // tools/list is available under both the legacy and 2026-07-28 revisions (unlike ping/initialize, + // tools/list is available under both the initialize-handshake and 2026-07-28 revisions (unlike ping/initialize, // which the 2026-07-28 protocol removed), so it exercises the version guard rather than the // per-method availability gate. var meta = new JsonObject @@ -242,7 +242,7 @@ private async Task RoundTripAsync( return await SendAndReceiveAsync(request, cancellationToken); } - private static JsonObject ModernMeta() => new() + private static JsonObject PerRequestMetadata() => new() { [MetaKeys.ProtocolVersion] = McpHttpHeaders.July2026ProtocolVersion, [MetaKeys.ClientInfo] = new JsonObject diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 818bd2b4b..13edf37f1 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -150,11 +150,11 @@ await SendAsync( } [Fact] - public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() + public async Task InitializeHandshake_StillWorks_OnJuly2026ProtocolDefaultServer() { - // Dual-era: a server defaulting to the 2026-07-28 protocol (ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion in McpServerOptions) must still - // accept the legacy initialize handshake from clients that don't speak the new protocol. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + // Dual-path: a default server must still accept the initialize handshake from clients that + // don't speak the 2026-07-28 per-request metadata path. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var response = await ReadAsync(); Assert.Equal(1, response["id"]!.GetValue()); @@ -166,13 +166,14 @@ public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() [Fact] public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-era servers must accept 2026-07-28 and legacy traffic on the same connection. The exact mix below - // is what a permissive client running against an unknown server would emit while probing. + // 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() + "}}"); var discover = await ReadAsync(); Assert.NotNull(discover["result"]); - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + 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.NotNull(init["result"]); Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue()); From 02a294f5149e589e1db1d3fb02405aa05bb0eec3 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 6 Jul 2026 14:49:08 -0700 Subject: [PATCH 3/4] Refine protocol version helpers and conformance Move protocol-version semantics out of McpHttpHeaders into a shared helper, tighten per-request metadata retry behavior, refresh conformance gating, and clean up initialize-handshake terminology. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Common/McpHttpHeaders.cs | 96 ------------ src/Common/McpProtocolVersions.cs | 113 ++++++++++++++ .../HttpServerTransportOptions.cs | 2 +- .../ModelContextProtocol.AspNetCore.csproj | 1 + .../StreamableHttpHandler.cs | 16 +- .../Client/McpClientImpl.cs | 81 ++++++---- .../StreamableHttpClientSessionTransport.cs | 4 +- src/ModelContextProtocol.Core/McpSession.cs | 2 +- .../McpSessionHandler.cs | 10 +- .../ModelContextProtocol.Core.csproj | 1 + .../Protocol/DiscoverResult.cs | 2 +- .../Protocol/InitializeRequestParams.cs | 7 +- .../Protocol/InitializeResult.cs | 6 +- .../Protocol/RequestMethods.cs | 8 +- .../Server/McpServerImpl.cs | 36 ++--- tests/Common/Utils/NodeHelpers.cs | 110 ++++++------- .../CachingConformanceTests.cs | 4 +- .../ClientConformanceTests.cs | 15 +- .../HttpHeaderConformanceTests.cs | 2 +- .../July2026ProtocolHttpFallbackTests.cs | 22 +-- .../July2026ProtocolHttpHandlerTests.cs | 18 +-- .../July2026ProtocolStatefulFallbackTests.cs | 4 +- ...delContextProtocol.AspNetCore.Tests.csproj | 1 + .../MrtrProtocolTests.cs | 4 +- .../RawHttpConformanceTests.cs | 30 ++-- .../RequestAbortCancellationTests.cs | 4 +- .../ServerConformanceTests.cs | 6 +- .../Program.cs | 6 +- .../Client/July2026ProtocolConnectionTests.cs | 12 +- .../Client/July2026ProtocolFallbackTests.cs | 146 ++++++++++++++++-- .../July2026ProtocolListMetaEmissionTests.cs | 20 +-- .../Client/McpClientCreationTests.cs | 2 +- .../Client/McpClientTests.cs | 10 +- .../Client/McpRequestHeadersTests.cs | 2 +- .../ClientIntegrationTests.cs | 10 +- .../McpServerBuilderExtensionsPromptsTests.cs | 4 +- ...rverBuilderExtensionsRequestFilterTests.cs | 2 +- ...cpServerBuilderExtensionsResourcesTests.cs | 4 +- .../McpServerBuilderExtensionsToolsTests.cs | 4 +- .../ModelContextProtocol.Tests.csproj | 1 + .../Protocol/DiscoverResultCacheableTests.cs | 2 +- .../Server/NegotiatedProtocolVersionTests.cs | 24 +-- .../Server/PingProtocolGatingTests.cs | 10 +- .../Server/RawStreamConformanceTests.cs | 6 +- .../Server/SubscriptionsListenTests.cs | 8 +- 45 files changed, 525 insertions(+), 353 deletions(-) create mode 100644 src/Common/McpProtocolVersions.cs diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index a9b65658e..c08326e20 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -9,53 +9,6 @@ namespace ModelContextProtocol.Protocol; /// internal static class McpHttpHeaders { - /// - /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize - /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled - /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) - /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request - /// version against it (see ), so it underpins the more - /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it - /// by default. - /// - public const string July2026ProtocolVersion = "2026-07-28"; - - /// - /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP - /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. - /// It is the default version for the initialize and session-resume code paths, and the version - /// the server advertises when a peer requests an unsupported version on the initialize handshake. - /// - public const string November2025ProtocolVersion = "2025-11-25"; - - /// - /// Protocol versions that still use the initialize handshake. - /// - internal static readonly string[] InitializeHandshakeProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - November2025ProtocolVersion, - ]; - - /// - /// Protocol versions that use per-request metadata instead of the initialize handshake. - /// - internal static readonly string[] PerRequestMetadataProtocolVersions = - [ - July2026ProtocolVersion, - ]; - - /// - /// All protocol versions supported by this implementation. - /// - internal static readonly string[] SupportedProtocolVersions = - [ - .. InitializeHandshakeProtocolVersions, - .. PerRequestMetadataProtocolVersions, - ]; - /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; @@ -99,53 +52,4 @@ internal static class McpHttpHeaders /// internal const string ToolContextKey = "Mcp.Tool"; - /// - /// Returns if the given protocol version is - /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. - /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. - /// - internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) - => !string.IsNullOrEmpty(protocolVersion) - && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; - - /// - /// Returns if the given protocol version is supported by this implementation. - /// - internal static bool IsSupportedProtocolVersion(string? protocolVersion) - => protocolVersion is not null && SupportedProtocolVersions.Contains(protocolVersion); - - /// - /// Returns if the given protocol version is available through the - /// initialize handshake. - /// - internal static bool SupportsInitializeHandshake(string? protocolVersion) - => protocolVersion is not null && InitializeHandshakeProtocolVersions.Contains(protocolVersion); - - /// - /// Returns if the given protocol version requires the handshake-free - /// per-request metadata path. - /// - internal static bool RequiresPerRequestMetadata(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); - - /// - /// Returns if the given protocol version requires standard MCP request headers - /// (Mcp-Method, Mcp-Name). - /// - public static bool RequiresStandardHeaders(string? protocolVersion) - => RequiresPerRequestMetadata(protocolVersion); - - /// - /// Returns if the given protocol version supports Streamable HTTP sessions. - /// - internal static bool SupportsHttpSessions(string? protocolVersion) - => !RequiresPerRequestMetadata(protocolVersion); - - /// - /// Returns if the negotiated protocol version reports unresolvable - /// resource URIs with the standard JSON-RPC (-32602) - /// rather than the legacy (-32002). - /// - internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); } diff --git a/src/Common/McpProtocolVersions.cs b/src/Common/McpProtocolVersions.cs new file mode 100644 index 000000000..09b1f5b3d --- /dev/null +++ b/src/Common/McpProtocolVersions.cs @@ -0,0 +1,113 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Internal helpers for MCP protocol revision strings and protocol-era behavior gates. +/// +internal static class McpProtocolVersions +{ + /// + /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize + /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled + /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) + /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request + /// version against it (see ), so it underpins the more + /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it + /// by default. + /// + public const string July2026ProtocolVersion = "2026-07-28"; + + /// + /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP + /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. + /// It is the default version for the initialize and session-resume code paths, and the version + /// the server advertises when a peer requests an unsupported version on the initialize handshake. + /// + public const string November2025ProtocolVersion = "2025-11-25"; + + /// The 2025-06-18 MCP protocol revision. + public const string June2025ProtocolVersion = "2025-06-18"; + + /// The 2025-03-26 MCP protocol revision. + public const string March2025ProtocolVersion = "2025-03-26"; + + /// The 2024-11-05 MCP protocol revision. + public const string November2024ProtocolVersion = "2024-11-05"; + + /// + /// Protocol versions that still use the initialize handshake. + /// + internal static readonly string[] InitializeHandshakeProtocolVersions = + [ + November2024ProtocolVersion, + March2025ProtocolVersion, + June2025ProtocolVersion, + November2025ProtocolVersion, + ]; + + /// + /// Protocol versions that use per-request metadata instead of the initialize handshake. + /// + internal static readonly string[] PerRequestMetadataProtocolVersions = + [ + July2026ProtocolVersion, + ]; + + /// + /// All protocol versions supported by this implementation. + /// + internal static readonly string[] SupportedProtocolVersions = + [ + .. InitializeHandshakeProtocolVersions, + .. PerRequestMetadataProtocolVersions, + ]; + + /// + /// Returns if the given protocol version is + /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. + /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. + /// + internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) + => !string.IsNullOrEmpty(protocolVersion) + && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; + + /// + /// Returns if the given protocol version is supported by this implementation. + /// + internal static bool IsSupportedProtocolVersion(string? protocolVersion) + => protocolVersion is not null && SupportedProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version is available through the + /// initialize handshake. + /// + internal static bool SupportsInitializeHandshake(string? protocolVersion) + => protocolVersion is not null && InitializeHandshakeProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version requires the handshake-free + /// per-request metadata path. + /// + internal static bool RequiresPerRequestMetadata(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); + + /// + /// Returns if the given protocol version requires standard MCP request headers + /// (Mcp-Method, Mcp-Name). + /// + internal static bool RequiresStandardHeaders(string? protocolVersion) + => RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the given protocol version supports Streamable HTTP sessions. + /// + internal static bool SupportsHttpSessions(string? protocolVersion) + => !RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the negotiated protocol version reports unresolvable + /// resource URIs with the standard JSON-RPC (-32602) + /// rather than the legacy (-32002). + /// + internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); +} diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index c36386360..024772240 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -65,7 +65,7 @@ public class HttpServerTransportOptions /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served /// when this property is . When it is , such a request is /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to - /// the legacy initialize handshake and obtains the session the server was configured to provide. + /// the initialize handshake and obtains the session the server was configured to provide. /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored; /// the server must not mint or echo session IDs for those revisions. /// diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 44bac1bc9..4c46acdd1 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -25,6 +25,7 @@ + diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 7e57eaedd..b483f10d4 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -34,14 +34,14 @@ internal sealed class StreamableHttpHandler( /// /// All protocol versions supported by this implementation. /// - private static readonly string[] s_supportedProtocolVersions = McpHttpHeaders.SupportedProtocolVersions; + private static readonly string[] s_supportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version. /// - private static readonly string[] s_sessionSupportingProtocolVersions = McpHttpHeaders.InitializeHandshakeProtocolVersions; + private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -427,7 +427,7 @@ await WriteJsonRpcErrorAsync(context, private static bool RequiresPerRequestMetadataProtocol(HttpContext context) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - return McpHttpHeaders.RequiresPerRequestMetadata(protocolVersionHeader); + return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader); } private async ValueTask StartNewSessionAsync(HttpContext context) @@ -685,7 +685,7 @@ private static string[] GetConfiguredSupportedProtocolVersions(string? protocolV return s_supportedProtocolVersions; } - return McpHttpHeaders.IsSupportedProtocolVersion(protocolVersion) ? + return McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion) ? [protocolVersion] : s_supportedProtocolVersions; } @@ -708,8 +708,8 @@ private static bool ValidateProtocolVersionEnvelope( var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); - if (!McpHttpHeaders.RequiresPerRequestMetadata(protocolVersionHeader) && - !McpHttpHeaders.RequiresPerRequestMetadata(protocolVersionMeta)) + if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && + !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta)) { errorDetail = null; return true; @@ -783,7 +783,7 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpHttpHeaders.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + + Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + "Use the initialize handshake with a protocol version that still supports sessions instead.", Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData @@ -811,7 +811,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess { // Only validate for protocol versions that support standard headers. var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - if (!McpHttpHeaders.RequiresStandardHeaders(protocolVersion)) + if (!McpProtocolVersions.RequiresStandardHeaders(protocolVersion)) { errorMessage = null; return true; diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 2d42d0f1a..fb8c3dfa7 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -297,18 +297,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // prefers the 2026-07-28 revision and automatically falls back to the initialize // handshake when the server doesn't support it. The initialize branch below runs only when // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). - if (_options.ProtocolVersion is null || McpHttpHeaders.RequiresPerRequestMetadata(_options.ProtocolVersion)) + if (_options.ProtocolVersion is null || McpProtocolVersions.RequiresPerRequestMetadata(_options.ProtocolVersion)) { - string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; - - // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being - // on the 2026-07-28 revision when SendRequestAsync is invoked for server/discover. - _negotiatedProtocolVersion = preferredVersion; - _sessionHandler.NegotiatedProtocolVersion = preferredVersion; + string preferredVersion = _options.ProtocolVersion ?? McpProtocolVersions.July2026ProtocolVersion; DiscoverResult? discoverResult = null; bool fallbackToInitialize = false; IList? serverSupportedVersions = null; + string discoverVersion = preferredVersion; // Apply a probe timeout so dual-path clients don't block forever waiting for an // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules). @@ -323,20 +319,38 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - discoverResult = await SendRequestAsync( - RequestMethods.ServerDiscover, - new DiscoverRequestParams(), - McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, - McpJsonUtilities.JsonContext.Default.DiscoverResult, - cancellationToken: probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); } catch (UnsupportedProtocolVersionException ex) { // Spec-recognized SEP-2575 signal: -32022 with data.supported[]. The server is - // refusing our preferred version; if it only supports initialize-capable versions, - // fall back to initialize with the highest mutually supported version. - fallbackToInitialize = true; + // refusing our preferred version. Retry with a supported per-request metadata + // version if one exists; otherwise fall back to initialize with the highest + // mutually supported initialize-capable version. serverSupportedVersions = (IList)ex.Supported; + var retryVersion = serverSupportedVersions + .Where(McpProtocolVersions.PerRequestMetadataProtocolVersions.Contains) + .OrderByDescending(v => v, StringComparer.Ordinal) + .FirstOrDefault(); + + if (retryVersion is not null) + { + if (_options.ProtocolVersion is { } pinnedVersion && + StringComparer.Ordinal.Compare(retryVersion, pinnedVersion) < 0) + { + throw new McpException( + $"The server does not support the requested protocol version '{pinnedVersion}'. " + + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + + $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."); + } + + discoverVersion = retryVersion; + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + } + else + { + fallbackToInitialize = true; + } } catch (MissingRequiredClientCapabilityException) { @@ -380,11 +394,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) fallbackToInitialize = true; } - if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(discoverVersion)) { // Server is reachable and supports server/discover, but doesn't support the - // preferred version. Fall back to initialize with the highest mutually-supported - // initialize-capable version from supportedVersions[]. + // version we are using. Fall back to initialize with the highest + // mutually-supported initialize-capable version from supportedVersions[]. fallbackToInitialize = true; serverSupportedVersions = discoverResult.SupportedVersions; } @@ -396,10 +410,10 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _sessionHandler.NegotiatedProtocolVersion = null; string fallbackVersion = serverSupportedVersions? - .Where(McpHttpHeaders.InitializeHandshakeProtocolVersions.Contains) + .Where(McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault() - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // A non-null ProtocolVersion is also the minimum: refuse to fall back below the // explicitly requested version. String.Compare is the spec's prescribed ordering @@ -430,13 +444,28 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _serverInfo = discoverResult.ServerInfo; _serverInstructions = discoverResult.Instructions; } + + async Task SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken) + { + // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being + // on a per-request metadata revision when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = protocolVersion; + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + + return await SendRequestAsync( + RequestMethods.ServerDiscover, + new DiscoverRequestParams(), + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + } } else { // initialize handshake. Reached only when the caller explicitly pinned a // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so // _options.ProtocolVersion is non-null here. - string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; + string requestProtocol = _options.ProtocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; await PerformInitializeHandshakeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } } @@ -490,13 +519,13 @@ private async Task PerformInitializeHandshakeAsync(string requestProtocol, Cance // When no version was pinned, accept any supported initialize-handshake response. initialize cannot negotiate // the 2026-07-28 and later protocol revisions. bool isResponseProtocolValid; - if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) + if (_options.ProtocolVersion is { } optionsProtocol && !McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) { isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; } else { - isResponseProtocolValid = McpHttpHeaders.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion); + isResponseProtocolValid = McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion); } if (!isResponseProtocolValid) { @@ -531,7 +560,7 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) _serverInstructions = resumeOptions.ServerInstructions; _negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion ?? _options.ProtocolVersion - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; @@ -697,7 +726,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && /// /// Injects the 2026-07-28 protocol's per-request _meta fields (protocol version, client info, /// client capabilities) into the request when this client negotiated the 2026-07-28 or later revision - /// (SEP-2575). No-op on a legacy session. + /// (SEP-2575). No-op on an initialize-handshake session. /// private void InjectRequestMetaIfNeeded(JsonRpcRequest request) { diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 9d4bfe77c..1bfb68f43 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -71,7 +71,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation // (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability, // -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from // initialize-handshake servers that don't understand the SEP-2575 _meta envelope) become generic - // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. + // McpProtocolException instances and trigger the initialize-handshake fallback path. // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. // The three per-request metadata protocol error codes are also surfaced for non-400 status codes @@ -528,7 +528,7 @@ internal static void CopyAdditionalHeaders( string? protocolVersion, string? lastEventId = null) { - if (sessionId is not null && McpHttpHeaders.SupportsHttpSessions(protocolVersion)) + if (sessionId is not null && McpProtocolVersions.SupportsHttpSessions(protocolVersion)) { headers.Add(McpHttpHeaders.SessionId, sessionId); } diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index e1bd0844b..1747a6303 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -55,7 +55,7 @@ public abstract partial class McpSession : IAsyncDisposable /// definition of "is this peer speaking the 2026-07-28 or later revision" used by both the client and server. /// internal bool IsJuly2026OrLaterProtocol() => - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); /// /// Sends a JSON-RPC request to the connected session and waits for a response. diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 98ec8c3d5..61a1872f2 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -32,7 +32,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// All protocol versions supported by this implementation. The era-specific lists live on /// so the shared source file is the single source of truth. /// - internal static readonly string[] SupportedProtocolVersions = McpHttpHeaders.SupportedProtocolVersions; + internal static readonly string[] SupportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// Checks if the given protocol version supports priming events. @@ -45,7 +45,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// internal static bool SupportsPrimingEvent(string? protocolVersion) { - const string MinResumabilityProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + const string MinResumabilityProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; if (protocolVersion is null) { @@ -73,7 +73,7 @@ internal static bool SupportsNaturalOutputSchemas(string? protocolVersion) return false; } - return string.Compare(protocolVersion, McpHttpHeaders.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; + return string.Compare(protocolVersion, McpProtocolVersions.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; } private readonly bool _isServer; @@ -154,7 +154,7 @@ public McpSessionHandler( (request, jsonRpcRequest, cancellationToken) => { string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; - if (McpHttpHeaders.RequiresPerRequestMetadata(perRequestVersion)) + if (McpProtocolVersions.RequiresPerRequestMetadata(perRequestVersion)) { throw new McpProtocolException( $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", @@ -610,7 +610,7 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) /// /// /// Used by on a 2026-07-28 or later session to carry protocol version, client - /// info, and client capabilities on every outgoing request (replacing what the legacy + /// info, and client capabilities on every outgoing request (replacing what the /// initialize handshake previously negotiated once). /// internal static void InjectRequestMeta( diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 455c36798..ee9f24f3c 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -32,6 +32,7 @@ + diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs index 4b992ebe4..6ab6cedf6 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client -/// to learn what a server supports without performing the legacy initialize handshake. +/// to learn what a server supports without performing the initialize handshake. /// /// public sealed class DiscoverResult : Result, ICacheableResult diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs index c48b9eb9a..63b17cdfe 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs @@ -22,16 +22,15 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeRequestParams : RequestParams { /// - /// Gets or sets the legacy Model Context Protocol version that the client wants to use with - /// the initialize handshake. + /// Gets or sets the initialize-handshake Model Context Protocol version that the client wants to use. /// /// /// /// Protocol version is specified using a date-based versioning scheme in the format "YYYY-MM-DD". - /// The client and server must agree on a legacy protocol version to communicate successfully. + /// The client and server must agree on an initialize-capable protocol version to communicate successfully. /// /// - /// During initialization, the server will check if it supports this requested legacy version. Protocol + /// During initialization, the server will check if it supports this requested version. Protocol /// revisions starting with 2026-07-28 do not use initialize; clients select them with /// server/discover and per-request metadata instead. /// diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs index 8643fb2a3..4c0f014f0 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs @@ -22,12 +22,12 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeResult : Result { /// - /// Gets or sets the legacy Model Context Protocol version that the server will use for this session. + /// Gets or sets the initialize-handshake Model Context Protocol version that the server will use for this session. /// /// /// - /// This is the legacy protocol version the server has agreed to use, which should match the client's - /// requested legacy version. If there's a mismatch, the client should throw an exception to prevent + /// This is the initialize-capable protocol version the server has agreed to use, which should match the client's + /// requested version. If there's a mismatch, the client should throw an exception to prevent /// communication issues due to incompatible protocol versions. /// /// diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a2884efba..29ba57626 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -159,15 +159,15 @@ public static class RequestMethods /// /// /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client - /// to learn what a server supports without performing the legacy initialize handshake. + /// to learn what a server supports without performing the initialize handshake. /// /// /// The server's response includes its supported protocol versions, capabilities, implementation /// information, and optional usage instructions. /// /// - /// Servers SHOULD implement this method. Legacy clients MAY ignore it. Clients on the 2026-07-28 revision - /// typically call this once during connection establishment. + /// Servers SHOULD implement this method. Initialize-handshake clients MAY ignore it. Clients on the + /// 2026-07-28 revision typically call this once during connection establishment. /// /// public const string ServerDiscover = "server/discover"; @@ -179,7 +179,7 @@ public static class RequestMethods /// /// /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) and replaces the unsolicited - /// HTTP GET endpoint and the legacy / + /// HTTP GET endpoint and the initialize-handshake / /// request methods. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 29e15de31..2989f1d97 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -84,8 +84,8 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ServerOptions = options; Services = serviceProvider; _supportedProtocolVersions = GetConfiguredSupportedProtocolVersions(options.ProtocolVersion); - _initializeHandshakeProtocolVersions = [.. _supportedProtocolVersions.Where(McpHttpHeaders.SupportsInitializeHandshake)]; - _perRequestMetadataProtocolVersions = [.. _supportedProtocolVersions.Where(McpHttpHeaders.RequiresPerRequestMetadata)]; + _initializeHandshakeProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.SupportsInitializeHandshake)]; + _perRequestMetadataProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata)]; _serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})"; _endpointName = _serverOnlyEndpointName; _servicesScopePerRequest = options.ScopeRequests; @@ -205,7 +205,7 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner supported: _supportedProtocolVersions); } - if (McpHttpHeaders.RequiresPerRequestMetadata(protocolVersion)) + if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) { ValidateRequiredPerRequestMetadata( protocolVersion, @@ -213,7 +213,7 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner context.ClientInfo is not null, context.ClientCapabilities is not null); } - else if (McpHttpHeaders.SupportsInitializeHandshake(protocolVersion)) + else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) { if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta) { @@ -250,7 +250,7 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey); } } - else if (McpHttpHeaders.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) + else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) { ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey); } @@ -352,7 +352,7 @@ request.Params is JsonObject paramsObj && private void ValidateInitializeRequestBoundary(JsonRpcRequest request) { if (request.Context?.ProtocolVersion is { } protocolVersion && - !McpHttpHeaders.SupportsInitializeHandshake(protocolVersion)) + !McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) { throw new UnsupportedProtocolVersionException( requested: protocolVersion, @@ -382,14 +382,14 @@ private static string[] GetConfiguredSupportedProtocolVersions(string? protocolV { if (protocolVersion is null) { - return McpHttpHeaders.SupportedProtocolVersions; + return McpProtocolVersions.SupportedProtocolVersions; } - if (!McpHttpHeaders.IsSupportedProtocolVersion(protocolVersion)) + if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion)) { throw new McpException( $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + - string.Join(", ", McpHttpHeaders.SupportedProtocolVersions) + "."); + string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + "."); } return [protocolVersion]; @@ -398,7 +398,7 @@ private static string[] GetConfiguredSupportedProtocolVersions(string? protocolV private void ValidateNotificationBoundary(JsonRpcNotification notification) { if (notification.Method == NotificationMethods.InitializedNotification && - McpHttpHeaders.RequiresPerRequestMetadata(notification.Context?.ProtocolVersion ?? _negotiatedProtocolVersion)) + McpProtocolVersions.RequiresPerRequestMetadata(notification.Context?.ProtocolVersion ?? _negotiatedProtocolVersion)) { throw new McpProtocolException( $"The notification '{NotificationMethods.InitializedNotification}' is only valid after the initialize handshake.", @@ -578,7 +578,7 @@ private void ConfigureInitialize(McpServerOptions options) // per-request _meta instead. string? protocolVersion = options.ProtocolVersion; if (protocolVersion is { } configuredProtocolVersion && - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(configuredProtocolVersion)) + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(configuredProtocolVersion)) { throw new UnsupportedProtocolVersionException( configuredProtocolVersion, @@ -590,7 +590,7 @@ private void ConfigureInitialize(McpServerOptions options) { if (request?.ProtocolVersion is string clientProtocolVersion) { - if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(clientProtocolVersion)) + if (McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(clientProtocolVersion)) { throw new UnsupportedProtocolVersionException( clientProtocolVersion, @@ -598,17 +598,17 @@ private void ConfigureInitialize(McpServerOptions options) $"Protocol version '{clientProtocolVersion}' is not available through the initialize handshake."); } - protocolVersion = McpHttpHeaders.SupportsInitializeHandshake(clientProtocolVersion) ? + protocolVersion = McpProtocolVersions.SupportsInitializeHandshake(clientProtocolVersion) ? clientProtocolVersion : - McpHttpHeaders.November2025ProtocolVersion; + McpProtocolVersions.November2025ProtocolVersion; } else { - protocolVersion = McpHttpHeaders.November2025ProtocolVersion; + protocolVersion = McpProtocolVersions.November2025ProtocolVersion; } } - string negotiatedProtocolVersion = protocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; + 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 @@ -1136,7 +1136,7 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null && listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult()); readResourceHandler ??= (static async (request, _) => { - var errorCode = McpHttpHeaders.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) + var errorCode = McpProtocolVersions.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) ? McpErrorCode.InvalidParams : McpErrorCode.ResourceNotFound; throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", errorCode); @@ -2050,7 +2050,7 @@ internal bool HasStatefulTransport() => /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion( request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion); /// diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 96e13c4d1..5cbfff200 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -179,75 +179,67 @@ public static bool IsNodeInstalled() } /// - /// Checks whether the SEP-2243 conformance scenarios are available, by reading the - /// installed conformance package version from node_modules. - /// The http-standard-headers, http-custom-headers, http-invalid-tool-headers, - /// http-header-validation, and http-custom-header-server-validation scenarios were - /// introduced in conformance package 0.2.0. Reading the installed version (rather than - /// the pinned version in package.json) means this also returns - /// when a newer private build has been installed locally via - /// npm install --no-save <path-to-conformance>. + /// Checks whether the SEP-2243 conformance scenarios are available in the installed + /// conformance package. /// public static bool HasSep2243Scenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "http-standard-headers", + "http-invalid-tool-headers", + "http-header-validation", + "http-custom-header-server-validation"); /// - /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance - /// PR #275) is available, by reading the installed conformance package version - /// from node_modules. The caching scenario was introduced in conformance package 0.2.0. - /// Reading the installed version (rather than the pinned version in package.json) means - /// this also returns when a newer private build has been installed - /// locally via npm install --no-save <path-to-conformance>. + /// Checks whether the SEP-2575 request-metadata client conformance scenario is available + /// in the installed conformance package. /// - public static bool HasCachingScenario() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + public static bool HasRequestMetadataScenario() + => HasInstalledConformanceScenario("request-metadata"); /// - /// Returns when the conformance package installed in node_modules - /// has a version greater than or equal to . + /// Checks whether the SEP-2549 "caching" conformance scenario is available in the installed + /// conformance package. /// - private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion) - { - var version = GetInstalledConformanceVersion(); - return version is not null && version >= minimumVersion; - } + public static bool HasCachingScenario() + => HasInstalledConformanceScenario("caching"); /// - /// Reads the version of the conformance package actually installed in node_modules, - /// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be - /// parsed as a . Returns if it cannot be - /// determined. + /// Checks whether all named conformance scenarios are present in the installed + /// @modelcontextprotocol/conformance bundle. This is intentionally based on the + /// installed scenario list rather than the package version so prerelease/private builds are + /// gated by the scenarios they actually contain. /// - private static Version? GetInstalledConformanceVersion() + private static bool HasInstalledConformanceScenarios(params string[] scenarioNames) + => ReadInstalledConformanceBundle() is { } bundle + && scenarioNames.All(scenarioName => HasInstalledConformanceScenario(bundle, scenarioName)); + + private static bool HasInstalledConformanceScenario(string scenarioName) + => ReadInstalledConformanceBundle() is { } bundle + && HasInstalledConformanceScenario(bundle, scenarioName); + + private static bool HasInstalledConformanceScenario(string bundle, string scenarioName) + => bundle.Contains($"`{scenarioName}`", StringComparison.Ordinal) || + bundle.Contains($"\"{scenarioName}\"", StringComparison.Ordinal) || + bundle.Contains($"'{scenarioName}'", StringComparison.Ordinal); + + private static string? ReadInstalledConformanceBundle() { try { var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine( - repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); + var bundlePath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); - // This is a skip gate for version-conditional conformance scenarios, so it must stay - // side-effect-free. If the conformance package isn't installed, report no version (the + // This is a skip gate for scenario-conditional conformance tests, so it must stay + // side-effect-free. If the conformance package isn't installed, report no bundle (the // scenario is simply gated off); the actual scenario run path restores npm dependencies // separately via ConformanceTestStartInfo. - if (!File.Exists(packageJsonPath)) + if (!File.Exists(bundlePath)) { return null; } - using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("version", out var versionElement) && - versionElement.GetString() is { } versionStr) - { - // Strip any prerelease/build suffix so System.Version can parse it. - var core = versionStr.Split('-', '+')[0]; - if (Version.TryParse(core, out var version)) - { - return version; - } - } - - return null; + return File.ReadAllText(bundlePath); } catch { @@ -376,16 +368,24 @@ private static bool ConformanceOutputIndicatesSuccess(string output) /// /// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) - /// conformance scenarios are available, by reading the installed conformance - /// package version from node_modules. The incomplete-result-* scenarios were - /// introduced in conformance package 0.2.0 (see - /// https://github.com/modelcontextprotocol/conformance/pull/188). - /// Reading the installed version (rather than the pinned version in package.json) means - /// this also returns when a newer private build has been installed - /// locally via npm install --no-save <path-to-conformance>. + /// conformance scenarios are available in the installed conformance package. /// public static bool HasMrtrScenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "input-required-result-basic-elicitation", + "input-required-result-basic-sampling", + "input-required-result-basic-list-roots", + "input-required-result-request-state", + "input-required-result-multiple-input-requests", + "input-required-result-multi-round", + "input-required-result-missing-input-response", + "input-required-result-non-tool-request", + "input-required-result-result-type", + "input-required-result-unsupported-methods", + "input-required-result-tampered-state", + "input-required-result-capability-check", + "input-required-result-ignore-extra-params", + "input-required-result-validate-input"); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 38e503258..e23a097e5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -108,7 +108,7 @@ public async ValueTask DisposeAsync() /// /// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle. /// It is gated on the installed conformance -/// package version (>= 0.2.0). The stateless server is +/// package's scenario list. The stateless server is /// started only after the gates pass, so a skipped run binds no port. /// public class CachingConformanceTests(ITestOutputHelper output) @@ -119,7 +119,7 @@ public async Task RunCachingConformanceTest() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasCachingScenario(), - "SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0)."); + "SEP-2549 caching conformance scenario is not available in the installed conformance package."); await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 2990b3d83..9fbf3fd78 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -17,6 +17,7 @@ public class ClientConformanceTests // Public static property required for SkipUnless attribute public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios(); + public static bool HasRequestMetadataScenario => NodeHelpers.HasRequestMetadataScenario(); public ClientConformanceTests(ITestOutputHelper output) { @@ -44,7 +45,7 @@ public ClientConformanceTests(ITestOutputHelper output) [InlineData("auth/resource-mismatch")] [InlineData("auth/pre-registration")] - // Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata). + // Backcompat: 2025-03-26 OAuth flows (no per-request metadata, root-location metadata). [InlineData("auth/2025-03-26-oauth-metadata-backcompat")] [InlineData("auth/2025-03-26-oauth-endpoint-fallback")] @@ -62,8 +63,18 @@ public async Task RunConformanceTest(string scenario) $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // Per-request metadata (SEP-2575) + [Fact(Skip = "SEP-2575 request-metadata conformance scenario is not available in the installed conformance package.", SkipUnless = nameof(HasRequestMetadataScenario))] + public async Task RunConformanceTest_RequestMetadata() + { + var result = await RunClientConformanceScenario("request-metadata"); + + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + // HTTP Standardization (SEP-2243) - [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] + [Theory(Skip = "SEP-2243 conformance scenarios are not available in the installed conformance package.", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-invalid-tool-headers")] // Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header, diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 43ba8946e..32e3d00b0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -563,7 +563,7 @@ public void Client_EncodeValue_Boolean_EncodesCorrectly() [InlineData("", false)] public void RequiresStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.RequiresStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } #endregion diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 34c16e519..12bae02bb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -124,7 +124,7 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-06-18", + ProtocolVersion = McpProtocolVersions.June2025ProtocolVersion, Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), @@ -162,7 +162,7 @@ await StartServerAsync(async context => await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); // Sanity: subsequent traffic still works post-fallback. var tools = await client.ListToolsAsync(cancellationToken: ct); @@ -198,8 +198,8 @@ await StartServerAsync(async context => // Use the typed payload type so the source-generated serializer can handle it. var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData { - Supported = new List { "2025-11-25" }, - Requested = "2026-07-28", + Supported = new List { McpProtocolVersions.November2025ProtocolVersion }, + Requested = McpProtocolVersions.July2026ProtocolVersion, }, GetJsonTypeInfo()); var rpcError = new JsonRpcError @@ -226,7 +226,7 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), @@ -249,7 +249,7 @@ await StartServerAsync(async context => await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } /// @@ -294,7 +294,7 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -322,7 +322,7 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new DiscoverResult { - SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, TimeToLive = TimeSpan.Zero, @@ -361,7 +361,7 @@ await StartServerAsync(async context => await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); await client.ListToolsAsync(cancellationToken: ct); @@ -392,7 +392,7 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new DiscoverResult { - SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, TimeToLive = TimeSpan.Zero, @@ -418,7 +418,7 @@ await StartServerAsync(async context => await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); Assert.Equal("", discoverSessionId); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index 6483f7e98..14237b528 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -52,7 +52,7 @@ public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() { await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); // On a stateless server, server/discover succeeds without creating a session. @@ -74,7 +74,7 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers // to the initialize handshake. await StartAsync(stateless: false); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( @@ -93,10 +93,10 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers var dataElement = (JsonElement)rpcError.Error.Data!; var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); Assert.NotNull(errorData); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, errorData.Requested); // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to an initialize-capable version. Assert.NotEmpty(errorData.Supported); - Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported); + Assert.DoesNotContain(McpProtocolVersions.July2026ProtocolVersion, errorData.Supported); } [Fact] @@ -134,7 +134,7 @@ public async Task Request_WithMcpSessionIdHeader_IgnoresHeader_AndDoesNotEchoSes // a request carrying an Mcp-Session-Id is ignored, and the server must not mint or echo session IDs. await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); @@ -152,7 +152,7 @@ public async Task Get_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); @@ -164,7 +164,7 @@ public async Task Get_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); @@ -177,7 +177,7 @@ public async Task Delete_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); @@ -189,7 +189,7 @@ public async Task Delete_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index 0ee20b504..d593ff2f2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -91,7 +91,7 @@ public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_And await using var client = await ConnectDefaultClientAsync(); // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet", new Dictionary { ["name"] = "Alice" }, @@ -118,7 +118,7 @@ public async Task DefaultClient_AgainstStatefulServer_ServerToClientElicitation_ }); }); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet_via_elicit", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index eb036ad29..d75877bab 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index ee361f9a6..aa724bb1b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -147,7 +147,7 @@ static CallToolResult (RequestContext context) => // headers. No initialize handshake and no Mcp-Session-Id. HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); } public async ValueTask DisposeAsync() @@ -484,7 +484,7 @@ private static void AddJuly2026ProtocolMeta(JsonObject paramsObj) paramsObj["_meta"] = meta; } - meta[MetaKeys.ProtocolVersion] = McpHttpHeaders.July2026ProtocolVersion; + meta[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion; meta[MetaKeys.ClientInfo] = new JsonObject { ["name"] = "MrtrTestClient", diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index fbefc7344..f31e2328d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -83,7 +83,7 @@ private static async Task ReadJsonResponseAsync(HttpResponseMessage re return JsonNode.Parse(body)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -98,7 +98,7 @@ public async Task July2026ToolsCall_WithFullMeta_Succeeds_200() July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -120,14 +120,14 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Equal([McpHttpHeaders.July2026ProtocolVersion], supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); // 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. @@ -139,19 +139,19 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() [Fact] public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() { - await StartAsync(McpHttpHeaders.July2026ProtocolVersion); + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Equal([McpHttpHeaders.July2026ProtocolVersion], supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] @@ -179,7 +179,7 @@ public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_W Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] @@ -197,7 +197,7 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi July2026ProtocolMetaFragment("2025-11-25") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -209,14 +209,14 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi [Fact] public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion() { - await StartAsync(McpHttpHeaders.November2025ProtocolVersion); + await StartAsync(McpProtocolVersions.November2025ProtocolVersion); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -224,10 +224,10 @@ public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_Retu Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue()); var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Equal([McpHttpHeaders.November2025ProtocolVersion], supported); + Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); } [Fact] @@ -238,7 +238,7 @@ public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_ var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""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, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -273,7 +273,7 @@ public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBo var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", RequestMethods.Initialize); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 807daaefe..73d000797 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -112,7 +112,7 @@ private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protoco var body = july2026Protocol ? """ {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} - """.Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion) + """.Replace("PROTOCOL_VERSION", McpProtocolVersions.July2026ProtocolVersion) : """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}"""; var request = new HttpRequestMessage(HttpMethod.Post, "") @@ -125,7 +125,7 @@ private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protoco if (july2026Protocol) { - request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "blockingTool"); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 82fb9c020..8aaacc7d6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -174,7 +174,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless // lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call @@ -196,7 +196,7 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); await using var server = await StatelessConformanceServer.StartAsync( TestContext.Current.CancellationToken, basePort: 3024); @@ -245,7 +245,7 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() public async Task RunMrtrConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios are not available in the installed conformance package."); var result = await RunStatelessConformanceTestAsync( $"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28"); diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 13af5d170..60d07d46e 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -37,11 +37,11 @@ }; // The default client now prefers the 2026-07-28 protocol (probing with server/discover and -// falling back to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios -// specifically exercise the legacy initialize handshake and SSE resumability (removed in the +// falling back to an initialize handshake). The "initialize" and "sse-retry" scenarios +// specifically exercise the initialize handshake and SSE resumability (removed in the // 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the // latest stable version. Other scenarios run on the 2026-07-28 default and exercise the -// server/discover probe plus the transparent legacy fallback. +// server/discover probe plus the transparent initialize-handshake fallback. if (scenario is "initialize" or "sse-retry") { options.ProtocolVersion = "2025-11-25"; diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index aa75bb184..45d72f15b 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -9,12 +9,12 @@ namespace ModelContextProtocol.Tests.Client; /// /// Connection-flow tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567) /// on . A client that requests -/// calls server/discover rather than +/// calls server/discover rather than /// initialize. /// public class July2026ProtocolConnectionTests : ClientServerTestBase { - private const string LatestStableVersion = "2025-11-25"; + private const string LatestStableVersion = McpProtocolVersions.November2025ProtocolVersion; public July2026ProtocolConnectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) @@ -34,10 +34,10 @@ public async Task Client_RequestingJuly2026Protocol_NegotiatesIt() { StartServer(); - var options = new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }; + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; await using var client = await CreateMcpClientForServer(options); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); Assert.NotNull(client.ServerCapabilities); Assert.Equal(nameof(July2026ProtocolConnectionTests), client.ServerInfo.Name); } @@ -49,7 +49,7 @@ public async Task Client_RequestingInitializeHandshakeVersion_NegotiatesIt() await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - Assert.NotEqual(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] @@ -83,6 +83,6 @@ public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); - Assert.Equal([McpHttpHeaders.July2026ProtocolVersion], discoverResult.SupportedVersions); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions); } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index 6eab236a6..03f8f5b33 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -26,7 +26,7 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() { var ct = TestContext.Current.CancellationToken; - await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), @@ -34,8 +34,8 @@ public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngrad Assert.True(transport.ServerDiscoverProbed); Assert.True(transport.InitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] @@ -43,7 +43,7 @@ public async Task Client_OnInvalidParams_FallsBackTo_Initialize() { var ct = TestContext.Current.CancellationToken; await using var transport = new InitializeHandshakeServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.InvalidParams); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. @@ -51,8 +51,8 @@ public async Task Client_OnInvalidParams_FallsBackTo_Initialize() loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.InitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] @@ -60,7 +60,7 @@ public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializ { var ct = TestContext.Current.CancellationToken; await using var transport = new InitializeHandshakeServerTestTransport( - serverNegotiatedVersion: McpHttpHeaders.July2026ProtocolVersion); + serverNegotiatedVersion: McpProtocolVersions.July2026ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { @@ -70,7 +70,7 @@ public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializ Assert.IsType(exception); Assert.True(transport.InitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -78,14 +78,14 @@ public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializ public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToInitializeHandshakeServer() { var ct = TestContext.Current.CancellationToken; - await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { // Pinning the version makes it the minimum too, so the client refuses to fall back. - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -98,13 +98,13 @@ public async Task InitializeHandshakeClient_WithExplicitPin_StillRequires_ExactV { var ct = TestContext.Current.CancellationToken; // Server responds with a DIFFERENT version than the one the user pinned. - await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: "2025-03-26"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.March2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -120,14 +120,14 @@ public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() // Verify the connect-time logic surfaces the error to the caller instead of falling back. var ct = TestContext.Current.CancellationToken; await using var transport = new InitializeHandshakeServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.HeaderMismatch); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -136,6 +136,20 @@ public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); } + [Fact] + public async Task Client_OnUnsupportedProtocolVersion_WithPerRequestMetadataVersion_RetriesDiscover() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new PerRequestMetadataRetryTestTransport(); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal(2, transport.ServerDiscoverRequests); + Assert.False(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + } + [Fact] public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() { @@ -144,7 +158,7 @@ public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredPro // DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout. var ct = TestContext.Current.CancellationToken; await using var transport = new InitializeHandshakeServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, silentDiscoverProbe: true); var stopwatch = Stopwatch.StartNew(); @@ -158,8 +172,8 @@ public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredPro Assert.True(transport.ServerDiscoverProbed); Assert.True(transport.InitializeReceived); - Assert.Equal(McpHttpHeaders.November2025ProtocolVersion, transport.InitializeProtocolVersion); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. Assert.True( @@ -289,4 +303,102 @@ public ValueTask DisposeAsync() } } } + + private sealed class PerRequestMetadataRetryTestTransport : IClientTransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + + public string Name => "per-request-metadata-retry-test-transport"; + + public int ServerDiscoverRequests { get; private set; } + + public bool InitializeReceived { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + ITransport transport = new TransportChannel(_incomingToClient, this); + return Task.FromResult(transport); + } + + public ValueTask DisposeAsync() => default; + + private void HandleOutgoingMessage(JsonRpcMessage message) + { + switch (message) + { + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq: + ServerDiscoverRequests++; + + if (ServerDiscoverRequests == 1) + { + _ = WriteAsync(new JsonRpcError + { + Id = discoverReq.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = "Unsupported protocol version", + Data = CreateUnsupportedProtocolVersionData(), + }, + }); + } + else + { + _ = WriteAsync(new JsonRpcResponse + { + Id = discoverReq.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }); + } + + break; + + case JsonRpcRequest { Method: RequestMethods.Initialize }: + InitializeReceived = true; + break; + } + } + + private Task WriteAsync(JsonRpcMessage message) + => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); + + private static JsonElement CreateUnsupportedProtocolVersionData() + { + var json = JsonSerializer.Serialize(new UnsupportedProtocolVersionErrorData + { + Requested = McpProtocolVersions.July2026ProtocolVersion, + Supported = [McpProtocolVersions.July2026ProtocolVersion], + }, McpJsonUtilities.DefaultOptions); + + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private sealed class TransportChannel( + Channel incoming, + PerRequestMetadataRetryTestTransport parent) : ITransport + { + public ChannelReader MessageReader => incoming.Reader; + public bool IsConnected { get; private set; } = true; + public string? SessionId => null; + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + parent.HandleOutgoingMessage(message); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + incoming.Writer.TryComplete(); + IsConnected = false; + return default; + } + } + } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs index 19ff40287..dd7ba7b29 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs @@ -76,7 +76,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -87,7 +87,7 @@ public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -98,7 +98,7 @@ public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -109,7 +109,7 @@ public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() public async Task Client_ListResourceTemplates_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -122,7 +122,7 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, // which still flows through the client's per-request _meta injector. StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); // Hook the server-side handler invocation via a notification handler is awkward here; assert // instead by sending the request and parsing the wire-shape echo from the response context. @@ -136,7 +136,7 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() Assert.NotNull(response.Result); var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discover.SupportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, discover.SupportedVersions); // The server enforces the per-request envelope shape; if the client had omitted _meta, the // request would have failed with -32602 / -32021 rather than returning a DiscoverResult. The @@ -144,11 +144,11 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() } [Fact] - public async Task LegacyClient_ListTools_DoesNotEmitMeta() + public async Task InitializeHandshakeClient_ListTools_DoesNotEmitMeta() { - // Sanity guard: a client on the session-supporting (legacy) protocol must NOT emit the SEP-2575 + // Sanity guard: a client on the session-supporting initialize-handshake protocol must NOT emit the SEP-2575 // envelope. The injector is gated on the negotiated protocol version; if it ever started writing - // those keys on a legacy request, every legacy server would reject it. + // those keys on an initialize-handshake request, every initialize-handshake server would reject it. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); @@ -175,6 +175,6 @@ private void AssertRequiredMetaPresent(string method) $"Missing clientCapabilities key on {method} _meta envelope"); // The protocolVersion value must match the negotiated 2026-07-28 protocol version. - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 42af028b2..48f4e66e1 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -178,7 +178,7 @@ public virtual Task SendMessageAsync(JsonRpcMessage message, CancellationToken c Result = JsonSerializer.SerializeToNode(new DiscoverResult { Capabilities = new ServerCapabilities(), - SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], ServerInfo = new Implementation { Name = "NopTransport", diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index ce17e9bd2..aefe6a962 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -483,7 +483,7 @@ private sealed class SynchronousProgress(Action callb [Fact] public async Task AsClientLoggerProvider_MessagesSentToClient() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion }); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); ILoggerProvider loggerProvider = Server.AsClientLoggerProvider(); Assert.Throws("categoryName", () => loggerProvider.CreateLogger(null!)); @@ -765,7 +765,7 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task SetLoggingLevelAsync_WithRequestParams_SetsLevel() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion }); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); // Should not throw await client.SetLoggingLevelAsync( @@ -795,9 +795,9 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task ServerCanPingClient() { - // ping is a legacy-only RPC (removed in the 2026-07-28 protocol per SEP-2575), so pin the client - // to a legacy protocol version to exercise the server-initiated ping round-trip. - await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" }); + // ping is available only on initialize-handshake revisions (removed in the 2026-07-28 protocol + // per SEP-2575), so pin the client to exercise the server-initiated ping round-trip. + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping }; var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index e7c338cb9..be629ecd2 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -30,7 +30,7 @@ public void McpErrorCode_HeaderMismatch_HasCorrectValue() [InlineData("", false)] public void RequiresStandardHeaders_ReturnsExpected(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.RequiresStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } } diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index e1ef50efa..9c63f12ce 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -33,10 +33,10 @@ public async Task ConnectAndPing_Stdio(string clientId) // Arrange // Act - // ping was removed in the 2026-07-28 protocol (SEP-2575), so pin to the latest stable - // protocol version to keep exercising the legacy ping RPC. The 2026-07-28 protocol relies on - // the transport/request lifecycle instead of an explicit ping. - await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = "2025-11-25" }); + // ping was removed in the 2026-07-28 protocol (SEP-2575), so pin to the latest + // initialize-handshake revision to keep exercising the ping RPC. The 2026-07-28 protocol + // relies on the transport/request lifecycle instead of an explicit ping. + await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert @@ -558,7 +558,7 @@ public async Task SetLoggingLevel_ReceivesLoggingMessages(string clientId) TaskCompletionSource receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 4182957cf..2043d13d1 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -130,10 +130,10 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs index 00cede9a5..d3de3a23e 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs @@ -280,7 +280,7 @@ public async Task AddUnsubscribeFromResourcesFilter_Logs_When_UnsubscribeFromRes [Fact] public async Task AddSetLoggingLevelFilter_Logs_When_SetLoggingLevel_Called() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion }); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); await client.SetLoggingLevelAsync(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index d8fd0a231..ad75e1281 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -164,10 +164,10 @@ public async Task Can_Be_Notified_Of_Resource_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 5359ec73c..90aa61a91 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -190,10 +190,10 @@ public async Task Can_Be_Notified_Of_Tool_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 4b782cd64..973ca4cb6 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -36,6 +36,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs index 248bf7768..4a2d7e6df 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -17,7 +17,7 @@ public static class DiscoverResultCacheableTests { private static DiscoverResult NewDiscoverResult() => new() { - SupportedVersions = [McpHttpHeaders.November2025ProtocolVersion, McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, }; diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index 9ef615d0d..d3e6f5e69 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -54,18 +54,18 @@ public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterCha var ct = TestContext.Current.CancellationToken; // The first request establishes the 2026-07-28 version for the stateful session (null -> 2026-07-28). - Assert.IsType(await RoundTripAsync(id: 1, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); // Re-sending the same version is an idempotent no-op, not an error. - Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); // Switching to a different (still-supported) version mid-session is rejected. - var error = Assert.IsType(await RoundTripAsync(id: 3, McpHttpHeaders.November2025ProtocolVersion, ct)); + var error = Assert.IsType(await RoundTripAsync(id: 3, McpProtocolVersions.November2025ProtocolVersion, ct)); Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); // The rejected request must not have mutated the negotiated version: the original 2026-07-28 version still works. - Assert.IsType(await RoundTripAsync(id: 4, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 4, McpProtocolVersions.July2026ProtocolVersion, ct)); } [Fact] @@ -73,12 +73,12 @@ public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInit { var ct = TestContext.Current.CancellationToken; - var error = Assert.IsType(await RoundTripAsync(id: 1, McpHttpHeaders.November2025ProtocolVersion, ct)); + 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); // The rejected initialize-handshake _meta request must not have established session state. - Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); } [Fact] @@ -89,7 +89,7 @@ public async Task PerRequestMetadata_RejectsRequestMissingRequiredMetadata() var error = Assert.IsType( await RoundTripAsync( id: 1, - McpHttpHeaders.July2026ProtocolVersion, + McpProtocolVersions.July2026ProtocolVersion, ct, includeClientInfo: false)); @@ -120,7 +120,7 @@ public async Task Initialize_WithPerRequestMetadataProtocolVersion_IsRejected() var ct = TestContext.Current.CancellationToken; var error = Assert.IsType( - await RoundTripInitializeAsync(id: 1, McpHttpHeaders.July2026ProtocolVersion, ct)); + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); @@ -137,7 +137,7 @@ public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() Method = RequestMethods.Initialize, Params = JsonSerializer.SerializeToNode(new InitializeRequestParams { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Capabilities = new ClientCapabilities(), ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, Meta = new JsonObject @@ -162,7 +162,7 @@ public async Task SubscriptionsListen_WithInitializeProtocolVersion_IsRejected() var ct = TestContext.Current.CancellationToken; Assert.IsType( - await RoundTripInitializeAsync(id: 1, McpHttpHeaders.November2025ProtocolVersion, ct)); + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); var request = new JsonRpcRequest { @@ -212,7 +212,7 @@ private async Task RoundTripAsync( [MetaKeys.ProtocolVersion] = protocolVersion, }; - if (McpHttpHeaders.RequiresPerRequestMetadata(protocolVersion)) + if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) { if (includeClientInfo) { @@ -244,7 +244,7 @@ private async Task RoundTripAsync( private static JsonObject PerRequestMetadata() => new() { - [MetaKeys.ProtocolVersion] = McpHttpHeaders.July2026ProtocolVersion, + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, [MetaKeys.ClientInfo] = new JsonObject { ["name"] = "test-client", diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs index 9e5a9e11f..d5735f105 100644 --- a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Tests.Server; /// /// Verifies that the built-in ping handler is gated by protocol version. /// SEP-2575 (the 2026-07-28 revision) removes ping; servers must -/// respond with -32601 MethodNotFound. Legacy protocol versions still -/// support ping per the spec. +/// respond with -32601 MethodNotFound. Initialize-handshake protocol +/// versions still support ping per the spec. /// public sealed class PingProtocolGatingTests : ClientServerTestBase { @@ -28,7 +28,7 @@ public async Task Ping_OnJuly2026ProtocolSession_ReturnsMethodNotFound() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }); var ex = await Assert.ThrowsAsync(async () => @@ -38,13 +38,13 @@ public async Task Ping_OnJuly2026ProtocolSession_ReturnsMethodNotFound() } [Fact] - public async Task Ping_OnLegacySession_StillSucceeds() + public async Task Ping_OnInitializeHandshakeSession_StillSucceeds() { // Default server config; client pinned to 2025-11-25. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 13edf37f1..5ece0f6b1 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -76,7 +76,7 @@ private async Task ReadAsync() return JsonNode.Parse(line!)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -96,7 +96,7 @@ public async Task ServerDiscover_ReturnsSupportedVersionsIncludingJuly2026Protoc var supportedVersions = result!["supportedVersions"]!.AsArray() .Select(n => n!.GetValue()) .ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supportedVersions); // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. Assert.NotNull(result["capabilities"]); @@ -146,7 +146,7 @@ await SendAsync( Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs index d0dd2a796..d727e00f7 100644 --- a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs @@ -15,7 +15,7 @@ namespace ModelContextProtocol.Tests.Server; /// End-to-end tests for the SEP-2575 subscriptions/listen list-changed delivery over an /// in-memory stream transport (the stdio-shaped path exercised by ). /// Validates that a client on the 2026-07-28 protocol receives only the change notifications it subscribed to, each tagged -/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast. +/// with the subscription id, and that initialize-handshake sessions keep receiving the session-wide broadcast. /// public class SubscriptionsListenTests : ClientServerTestBase { @@ -104,11 +104,11 @@ public async Task July2026Protocol_WithoutSubscription_DoesNotBroadcastListChang } [Fact] - public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() + public async Task InitializeHandshake_ListChanged_IsBroadcast_WithoutSubscription() { await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var toolsChannel = Channel.CreateUnbounded(); @@ -118,7 +118,7 @@ public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() var serverOptions = ServiceProvider.GetRequiredService>().Value; serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); - // Legacy sessions keep the session-wide broadcast and the notification carries no subscription id. + // Initialize-handshake sessions keep the session-wide broadcast and the notification carries no subscription id. var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); Assert.Null(GetSubscriptionId(notification)); } From 6134a5b4a8b9abd3f5f2d24e8d6d8ee605a609f6 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 10 Jul 2026 18:23:21 -0700 Subject: [PATCH 4/4] Address protocol boundary review feedback Keep rejected reserved metadata from establishing protocol state, reject invalid HTTP protocol configuration consistently, and advertise POST on draft-protocol 405 responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 13 ++- .../Server/McpServerImpl.cs | 10 ++- .../July2026ProtocolHttpHandlerTests.cs | 4 + .../Server/McpServerTests.cs | 81 +++++++++++++++++++ 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index b483f10d4..82f2a3ef6 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -145,6 +145,7 @@ public async Task HandleGetRequestAsync(HttpContext context) // no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id. if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, "Method Not Allowed: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", StatusCodes.Status405MethodNotAllowed); @@ -262,6 +263,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context) // so the DELETE is invalid whether or not it carries an Mcp-Session-Id. if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, "Method Not Allowed: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", StatusCodes.Status405MethodNotAllowed); @@ -685,9 +687,14 @@ private static string[] GetConfiguredSupportedProtocolVersions(string? protocolV return s_supportedProtocolVersions; } - return McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion) ? - [protocolVersion] : - s_supportedProtocolVersions; + if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion)) + { + throw new McpException( + $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + + string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + "."); + } + + return [protocolVersion]; } /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2989f1d97..5a0e5946c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -193,7 +193,11 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner if (context?.ProtocolVersion is { } protocolVersion) { - bool protocolVersionRecorded = false; + 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 @@ -225,13 +229,11 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner if (hasReservedPerRequestMeta) { - SetNegotiatedProtocolVersion(protocolVersion); - protocolVersionRecorded = true; ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey); } } - if (!protocolVersionRecorded) + if (!protocolVersionAlreadyEstablished) { SetNegotiatedProtocolVersion(protocolVersion); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index 14237b528..ab9d04b70 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -157,6 +157,7 @@ public async Task Get_WithoutSessionId_IsRejected() using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -170,6 +171,7 @@ public async Task Get_WithSessionId_IsRejected() using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -182,6 +184,7 @@ public async Task Delete_WithoutSessionId_IsRejected() using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -195,6 +198,7 @@ public async Task Delete_WithSessionId_IsRejected() using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } private static string DiscoverRequestJuly2026Protocol => """ diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 2a74c3e35..c872f9644 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -290,6 +290,87 @@ await Can_Handle_Requests( }); } + [Fact] + public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new TestServerTransport(); + var options = CreateOptions(); + options.ProtocolVersion = null; + + 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") + { + acceptedResponse.TrySetResult(message); + } + }; + + await transport.SendClientMessageAsync(new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, + }, + }, + Context = new JsonRpcMessageContext + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, + }, 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); + + await transport.DisposeAsync(); + await runTask; + } + [Fact] public async Task Initialize_IncludesExtensionsInResponse() {