From f15eb86dc212ab640db0cce7b9d870e199db1f56 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 13:47:49 -0700 Subject: [PATCH 01/23] Add SEP-2575 + SEP-2567 draft protocol support Implements the protocol-level changes for the draft revision (SEP-2575 stateless MCP and SEP-2567 sessionless MCP): - New _meta keys for per-request protocolVersion / clientInfo / clientCapabilities / logLevel - New RPCs: server/discover and subscriptions/listen, plus the acknowledgement notification - New JSON-RPC error codes -32004 (UnsupportedProtocolVersion) and -32003 (MissingRequiredClientCapability) with typed exception classes - Client skips initialize under draft mode, calls server/discover instead, and falls back to legacy initialize when the server doesn't support the experimental version - Server keeps the legacy initialize handler for back-compat, and a new built-in incoming message filter projects per-request _meta values onto the per-session client info/capabilities/version state under draft - HTTP server suppresses Mcp-Session-Id and routes draft requests through the stateless path regardless of HttpServerTransportOptions.Stateless - HTTP server returns -32004 with a structured supportedVersions data payload when a client requests an unsupported protocol version - HTTP client transport carries the protocol version header on every request (sourced from per-request _meta when present), and surfaces -32004/-32003 from HTTP error responses as typed McpProtocolException for the connection logic to react Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 122 ++++++++-- .../Client/McpClientImpl.cs | 209 ++++++++++++++---- .../StreamableHttpClientSessionTransport.cs | 84 ++++++- src/ModelContextProtocol.Core/McpErrorCode.cs | 25 +++ .../McpJsonUtilities.cs | 10 + .../McpSessionHandler.cs | 139 ++++++++++++ ...issingRequiredClientCapabilityException.cs | 67 ++++++ .../Protocol/DiscoverRequestParams.cs | 16 ++ .../Protocol/DiscoverResult.cs | 46 ++++ .../Protocol/JsonRpcMessageContext.cs | 31 +++ ...issingRequiredClientCapabilityErrorData.cs | 21 ++ .../Protocol/NotificationMethods.cs | 59 +++++ .../Protocol/RequestMethods.cs | 38 ++++ ...criptionsAcknowledgedNotificationParams.cs | 27 +++ .../SubscriptionsListenRequestParams.cs | 71 ++++++ .../UnsupportedProtocolVersionErrorData.cs | 26 +++ .../Server/McpServerImpl.cs | 167 +++++++++++++- .../UnsupportedProtocolVersionException.cs | 74 +++++++ .../MapMcpTests.Mrtr.cs | 5 + .../Client/MrtrIntegrationTests.cs | 37 ++-- 20 files changed, 1186 insertions(+), 88 deletions(-) create mode 100644 src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs create mode 100644 src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index a489cd9e6..6b290a38e 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -12,6 +12,7 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Security.Cryptography; +using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -54,9 +55,9 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; } @@ -108,9 +109,24 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); + return; + } + + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + + // Under the draft protocol revision (SEP-2575), the standalone HTTP GET endpoint for unsolicited + // server-to-client messages is removed. Clients should use subscriptions/listen (POST) instead. + // We only reject GET when the request looks like a draft-mode probe (experimental version with + // no Mcp-Session-Id); legacy stateful sessions that opted into MRTR via the experimental version + // are still allowed to use GET for back-compat. + if (IsDraftProtocolRequest(context) && string.IsNullOrEmpty(sessionId)) + { + await WriteJsonRpcErrorAsync(context, + "Bad Request: The GET endpoint is not supported by the draft protocol revision. Use subscriptions/listen via POST instead.", + StatusCodes.Status400BadRequest); return; } @@ -122,7 +138,6 @@ await WriteJsonRpcErrorAsync(context, return; } - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); var session = await GetSessionAsync(context, sessionId); if (session is null) { @@ -211,13 +226,25 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + + // Under the draft revision there are no sessions to terminate. Reject DELETE requests that + // declare the draft version without an Mcp-Session-Id. Legacy stateful sessions opted into + // the draft version may still call DELETE for back-compat. + if (IsDraftProtocolRequest(context) && string.IsNullOrEmpty(sessionId)) + { + await WriteJsonRpcErrorAsync(context, + "Bad Request: The DELETE endpoint is not supported by the draft protocol revision (no Mcp-Session-Id sessions exist).", + StatusCodes.Status400BadRequest); + return; + } + if (string.IsNullOrEmpty(sessionId) || !sessionManager.TryGetValue(sessionId, out var session)) { return; @@ -319,6 +346,22 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + bool isDraftRequest = IsDraftProtocolRequest(context); + + // Under the draft protocol revision (SEP-2575 + SEP-2567), sessions are removed entirely. + // A request that declares the experimental draft version via MCP-Protocol-Version and that + // does NOT include an Mcp-Session-Id is treated as sessionless regardless of the + // HttpServerTransportOptions.Stateless setting (which governs only legacy clients). + // + // For back-compat with clients that previously used the experimental version on top of the + // legacy stateful session model (e.g., MRTR-as-extension-on-initialize), we still route + // experimental-version requests that DO include an Mcp-Session-Id through the legacy session + // lookup path. SEP-2567 will eventually phase that out, but we preserve it now to avoid + // breaking existing consumers without forcing them to change their setup code. + if (isDraftRequest && string.IsNullOrEmpty(sessionId)) + { + return await StartNewSessionAsync(context, forceStateless: true); + } if (string.IsNullOrEmpty(sessionId)) { @@ -350,12 +393,33 @@ await WriteJsonRpcErrorAsync(context, } } - private async ValueTask StartNewSessionAsync(HttpContext context) + /// + /// Returns when the request declares the experimental draft protocol revision via + /// the MCP-Protocol-Version header. Draft requests are always sessionless and do not perform + /// the legacy initialize handshake (SEP-2575 + SEP-2567). + /// + private bool IsDraftProtocolRequest(HttpContext context) + { +#pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is for evaluation purposes only + var experimental = mcpServerOptionsSnapshot.Value.ExperimentalProtocolVersion; +#pragma warning restore MCPEXP001 + if (experimental is null) + { + return false; + } + + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + return string.Equals(protocolVersionHeader, experimental, StringComparison.Ordinal); + } + + private async ValueTask StartNewSessionAsync(HttpContext context, bool forceStateless = false) { string sessionId; StreamableHttpServerTransport transport; - if (!HttpServerTransportOptions.Stateless) + bool isStateless = HttpServerTransportOptions.Stateless || forceStateless; + + if (!isStateless) { sessionId = MakeNewSessionId(); transport = new(loggerFactory) @@ -372,7 +436,7 @@ private async ValueTask StartNewSessionAsync(HttpContext } else { - // In stateless mode, each request is independent. Don't set any session ID on the transport. + // In stateless mode (legacy or draft), each request is independent. Don't set any session ID on the transport. // If in the future we support resuming stateless requests, we should populate // the event stream store and retry interval here as well. sessionId = ""; @@ -382,22 +446,25 @@ private async ValueTask StartNewSessionAsync(HttpContext }; } - return await CreateSessionAsync(context, transport, sessionId); + return await CreateSessionAsync(context, transport, sessionId, forceStateless: forceStateless); } private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, string sessionId, - Action? configureOptions = null) + Action? configureOptions = null, + bool forceStateless = false) { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + bool effectivelyStateless = HttpServerTransportOptions.Stateless || forceStateless; + + if (effectivelyStateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (HttpServerTransportOptions.Stateless) + if (effectivelyStateless) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -559,19 +626,32 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, /// /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility, - /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. + /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the + /// 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, out string? errorMessage) + private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && !s_supportedProtocolVersions.Contains(protocolVersionHeader)) { - errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; + errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.", + Data = JsonSerializer.SerializeToNode( + new UnsupportedProtocolVersionErrorData + { + Supported = [.. s_supportedProtocolVersions], + Requested = protocolVersionHeader, + }, + GetRequiredJsonTypeInfo()), + }; return false; } - errorMessage = null; + errorDetail = null; return true; } @@ -858,6 +938,12 @@ private static bool ValuesMatch(string? actual, string? expected, System.Text.Js return false; } + private static Task WriteJsonRpcErrorDetailAsync(HttpContext context, JsonRpcErrorDetail detail, int statusCode) + { + var jsonRpcError = new JsonRpcError { Error = detail }; + return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context); + } + private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) => acceptHeaderValue.MatchesMediaType("application/json"); diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index e6ab3aae4..61b20dcd0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -670,54 +670,86 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - // Send initialize request - string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; - var initializeResponse = await SendRequestAsync( - RequestMethods.Initialize, - new InitializeRequestParams - { - ProtocolVersion = requestProtocol, - Capabilities = _options.Capabilities ?? new ClientCapabilities(), - ClientInfo = _options.ClientInfo ?? DefaultImplementation, - }, - McpJsonUtilities.JsonContext.Default.InitializeRequestParams, - McpJsonUtilities.JsonContext.Default.InitializeResult, - cancellationToken: initializationCts.Token).ConfigureAwait(false); - - // Store server information - if (_logger.IsEnabled(LogLevel.Information)) + // Under the draft protocol revision (SEP-2575), there is no initialize 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. + if (_options.ExperimentalProtocolVersion is { } draftVersion) { - LogServerCapabilitiesReceived(_endpointName, - capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), - serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); - } - - _serverCapabilities = initializeResponse.Capabilities; - _serverInfo = initializeResponse.ServerInfo; - _serverInstructions = initializeResponse.Instructions; + // Eagerly set the negotiated version so InjectDraftMetaIfNeeded recognizes us as + // a draft client when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = draftVersion; + _sessionHandler.NegotiatedProtocolVersion = draftVersion; + + DiscoverResult? discoverResult = null; + bool fallbackToLegacy = false; + IList? serverSupportedVersions = null; + try + { + discoverResult = await SendRequestAsync( + RequestMethods.ServerDiscover, + new DiscoverRequestParams(), + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult, + cancellationToken: initializationCts.Token).ConfigureAwait(false); + } + catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound) + { + // Server doesn't implement server/discover (likely a legacy server). Fall back + // to the legacy initialize handshake per SEP-2575 §"Supporting Multiple Versions". + fallbackToLegacy = true; + } + catch (UnsupportedProtocolVersionException ex) + { + // Server rejected the experimental protocol version at the transport layer. + // Per SEP-2575, fall back to a mutually-supported version reported in ex.Supported. + fallbackToLegacy = true; + serverSupportedVersions = (IList)ex.Supported; + } - // Validate protocol version - bool isResponseProtocolValid = - _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : - McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); - if (!isResponseProtocolValid) - { - LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); - throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); - } + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion)) + { + // Server is reachable and supports server/discover, but doesn't support the + // experimental version. Fall back to legacy initialize with the highest + // mutually-supported version from supportedVersions[]. + fallbackToLegacy = true; + serverSupportedVersions = discoverResult.SupportedVersions; + } - _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; + if (fallbackToLegacy) + { + // Reset negotiated state and try legacy initialize. + _negotiatedProtocolVersion = null; + _sessionHandler.NegotiatedProtocolVersion = null; - // Update session handler with the negotiated protocol version for telemetry - _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; + string fallbackVersion = serverSupportedVersions? + .Where(McpSessionHandler.SupportedProtocolVersions.Contains) + .OrderByDescending(v => v, StringComparer.Ordinal) + .FirstOrDefault() + ?? McpSessionHandler.LatestProtocolVersion; - // Send initialized notification - await this.SendNotificationAsync( - NotificationMethods.InitializedNotification, - new InitializedNotificationParams(), - McpJsonUtilities.JsonContext.Default.InitializedNotificationParams, - cancellationToken: initializationCts.Token).ConfigureAwait(false); + await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); + } + else + { + if (_logger.IsEnabled(LogLevel.Information)) + { + LogServerCapabilitiesReceived(_endpointName, + capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), + serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + } + _serverCapabilities = discoverResult!.Capabilities; + _serverInfo = discoverResult.ServerInfo; + _serverInstructions = discoverResult.Instructions; + } + } + else + { + // Legacy initialize handshake. + string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; + await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); + } } catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -735,6 +767,54 @@ await this.SendNotificationAsync( LogClientConnected(_endpointName); } + /// + /// Performs the legacy 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) + { + var initializeResponse = await SendRequestAsync( + RequestMethods.Initialize, + new InitializeRequestParams + { + ProtocolVersion = requestProtocol, + Capabilities = _options.Capabilities ?? new ClientCapabilities(), + ClientInfo = _options.ClientInfo ?? DefaultImplementation, + }, + McpJsonUtilities.JsonContext.Default.InitializeRequestParams, + McpJsonUtilities.JsonContext.Default.InitializeResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Information)) + { + LogServerCapabilitiesReceived(_endpointName, + capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), + serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + } + + _serverCapabilities = initializeResponse.Capabilities; + _serverInfo = initializeResponse.ServerInfo; + _serverInstructions = initializeResponse.Instructions; + + bool isResponseProtocolValid = + _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : + McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + if (!isResponseProtocolValid) + { + LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); + throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); + } + + _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; + _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; + + await this.SendNotificationAsync( + NotificationMethods.InitializedNotification, + new InitializedNotificationParams(), + McpJsonUtilities.JsonContext.Default.InitializedNotificationParams, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + /// /// Configures the client to use an already initialized session without performing the handshake. /// @@ -847,6 +927,8 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && const int maxRetries = 10; + InjectDraftMetaIfNeeded(request); + for (int attempt = 0; attempt <= maxRetries; attempt++) { JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); @@ -884,8 +966,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && } request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; - } - else if (inputRequiredResult.RequestState is not null) + InjectDraftMetaIfNeeded(request); { // No input requests but has requestState (e.g., load shedding) - just retry with state. var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); @@ -893,10 +974,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && paramsObj.Remove("inputResponses"); request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; - } - else - { - throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState."); + InjectDraftMetaIfNeeded(request); } continue; // retry with the updated request @@ -908,6 +986,41 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && throw new McpException($"Server returned InputRequiredResult more than {maxRetries} times."); } + /// + /// Injects the draft-protocol per-request _meta fields (protocol version, client info, + /// client capabilities) into the request when this client is using the draft protocol revision + /// (SEP-2575). No-op for legacy clients. + /// + private void InjectDraftMetaIfNeeded(JsonRpcRequest request) + { + if (!IsDraftProtocol()) + { + return; + } + + // Initialize is never sent under the draft revision, but guard defensively in case a caller + // routes it through here (e.g., during back-compat fallback negotiation). + if (request.Method == RequestMethods.Initialize) + { + return; + } + + McpSessionHandler.InjectDraftMeta( + request, + _negotiatedProtocolVersion!, + _options.ClientInfo ?? DefaultImplementation, + _options.Capabilities ?? new ClientCapabilities()); + } + + /// + /// Returns when the negotiated protocol version is the experimental draft + /// revision (SEP-2575 + SEP-2567 + MRTR). + /// + internal bool IsDraftProtocol() => + _negotiatedProtocolVersion is not null && + _options.ExperimentalProtocolVersion is not null && + _negotiatedProtocolVersion == _options.ExperimentalProtocolVersion; + /// public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => _sessionHandler.SendMessageAsync(message, cancellationToken); diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 2cebccb3b..b3eb00718 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -63,9 +63,60 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation { // Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it. using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false); + + // For unsuccessful responses, surface structured JSON-RPC errors with codes introduced by the + // draft protocol revision (SEP-2575) — UnsupportedProtocolVersion (-32004) and + // MissingRequiredClientCapability (-32003) — as typed McpProtocolException so the client's + // connection logic can react (e.g., fall back to legacy initialize on version mismatch). + // Other JSON-RPC errors carried in 4xx/5xx bodies (e.g., 403 forbidden, 404 session-not-found) + // continue to surface as HttpRequestException to preserve back-compat with existing behavior. + if (!response.IsSuccessStatusCode && + response.Content.Headers.ContentType?.MediaType == "application/json") + { + string body; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + body = string.Empty; + } + + if (!string.IsNullOrEmpty(body) && + TryParseJsonRpcError(body, out var parsedError) && + ShouldSurfaceAsStructuredException((McpErrorCode)parsedError.Error.Code)) + { + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } + } + await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } + private static bool ShouldSurfaceAsStructuredException(McpErrorCode code) => + code is McpErrorCode.UnsupportedProtocolVersion or McpErrorCode.MissingRequiredClientCapability; + + private static bool TryParseJsonRpcError(string body, out JsonRpcError parsedError) + { + try + { + var message = JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); + if (message is JsonRpcError rpcError) + { + parsedError = rpcError; + return true; + } + } + catch + { + // Not a valid JSON-RPC error response — fall through to the standard HTTP exception path. + } + + parsedError = null!; + return false; + } + // This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception. internal async Task SendHttpRequestAsync(JsonRpcMessage message, CancellationToken cancellationToken) { @@ -79,6 +130,12 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes LogTransportSendingMessageSensitive(message); + // Under the draft protocol revision (SEP-2575), every request carries its protocol version in + // _meta/io.modelcontextprotocol/protocolVersion (and the matching MCP-Protocol-Version HTTP + // header). Pick the value off the message so the first draft request (server/discover) can + // include the header even before we've recorded a negotiated version from an initialize reply. + var protocolVersionForRequest = ExtractProtocolVersionFromMeta(message) ?? _negotiatedProtocolVersion; + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token); cancellationToken = sendCts.Token; @@ -90,7 +147,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes }, }; - CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion); + CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, protocolVersionForRequest); AddMcpRequestHeaders(httpRequestMessage.Headers, message); @@ -156,10 +213,35 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); } + else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse) + { + // Under the draft protocol revision (SEP-2575), server/discover replaces the initialize + // handshake. The transport caches the protocol version from the outgoing request's _meta + // so subsequent requests carry the matching MCP-Protocol-Version header without re-parsing. + _negotiatedProtocolVersion ??= ExtractProtocolVersionFromMeta(message); + } return response; } + /// + /// Reads the protocol version from a request's _meta/io.modelcontextprotocol/protocolVersion field, + /// introduced by the draft protocol revision (SEP-2575). Returns for messages that + /// don't have that field. + /// + private static string? ExtractProtocolVersionFromMeta(JsonRpcMessage message) + { + if (message is JsonRpcRequest { Params: System.Text.Json.Nodes.JsonObject paramsObj } && + paramsObj["_meta"] is System.Text.Json.Nodes.JsonObject metaObj && + metaObj[NotificationMethods.ProtocolVersionMetaKey] is System.Text.Json.Nodes.JsonValue versionValue && + versionValue.TryGetValue(out string? version)) + { + return version; + } + + return null; + } + public override async ValueTask DisposeAsync() { using var _ = await _disposeLock.LockAsync().ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 54b9eeebf..74f9110bb 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -43,6 +43,31 @@ public enum McpErrorCode /// ResourceNotFound = -32002, + /// + /// Indicates that a request requires a client capability that was not declared in the request's + /// _meta/io.modelcontextprotocol/clientCapabilities field. + /// + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// requiredCapabilities object describing the capabilities the server requires from the client + /// to process the request. For HTTP, the response status code is 400 Bad Request. + /// + /// + MissingRequiredClientCapability = -32003, + + /// + /// Indicates that the request's declared protocol version is not supported by the server. + /// + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// supported array of protocol version strings the server supports and the original + /// requested protocol version. For HTTP, the response status code is 400 Bad Request. + /// + /// + UnsupportedProtocolVersion = -32004, + /// /// Indicates that URL-mode elicitation is required to complete the requested operation. /// diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index f1d18b4f9..bd7673374 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -118,8 +118,14 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(CompleteResult))] [JsonSerializable(typeof(CreateMessageRequestParams))] [JsonSerializable(typeof(CreateMessageResult))] + [JsonSerializable(typeof(DiscoverRequestParams))] + [JsonSerializable(typeof(DiscoverResult))] [JsonSerializable(typeof(ElicitRequestParams))] [JsonSerializable(typeof(ElicitResult))] + [JsonSerializable(typeof(MissingRequiredClientCapabilityErrorData))] + [JsonSerializable(typeof(SubscriptionsListenRequestParams))] + [JsonSerializable(typeof(SubscriptionsAcknowledgedNotificationParams))] + [JsonSerializable(typeof(UnsupportedProtocolVersionErrorData))] [JsonSerializable(typeof(UrlElicitationRequiredErrorData))] [JsonSerializable(typeof(EmptyResult))] [JsonSerializable(typeof(GetPromptRequestParams))] @@ -187,6 +193,10 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(ProgressToken))] [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(Implementation))] + [JsonSerializable(typeof(ClientCapabilities))] + [JsonSerializable(typeof(ServerCapabilities))] + [JsonSerializable(typeof(LoggingLevel))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [JsonSerializable(typeof(AuthorizationServerMetadata))] diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index e874e6724..fc62aae3c 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -263,6 +263,18 @@ ex is OperationCanceledException && Message = urlException.Message, Data = urlException.CreateErrorDataNode(), }, + UnsupportedProtocolVersionException upvException => new() + { + Code = (int)upvException.ErrorCode, + Message = upvException.Message, + Data = upvException.CreateErrorDataNode(), + }, + MissingRequiredClientCapabilityException mrccException => new() + { + Code = (int)mrccException.ErrorCode, + Message = mrccException.Message, + Data = mrccException.CreateErrorDataNode(), + }, McpProtocolException mcpProtocolException => new() { Code = (int)mcpProtocolException.ErrorCode, @@ -371,6 +383,14 @@ private static async Task GetCompletionDetailsAsync(Tas private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { + // Project the draft-protocol per-request _meta fields onto the message context before any + // filters run so they (and downstream handlers) can read client info / capabilities / + // protocol version / log level without re-parsing. + if (_isServer && message is JsonRpcRequest incomingRequest) + { + PopulateContextFromMeta(incomingRequest); + } + Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); @@ -506,6 +526,104 @@ await SendMessageAsync(new JsonRpcResponse return result; } + /// + /// Reads the draft-protocol per-request _meta fields off the request and projects them onto + /// so they're available without re-parsing throughout the pipeline. + /// + /// + /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, + /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field + /// that's already set on the context (e.g., + /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone + /// unless explicitly overwritten by a non-null value parsed here. + /// + internal static void PopulateContextFromMeta(JsonRpcRequest request) + { + if (request.Params is not JsonObject paramsObj) + { + return; + } + + if (paramsObj["_meta"] is not JsonObject metaObj) + { + return; + } + + var context = request.Context ??= new JsonRpcMessageContext(); + + if (metaObj[NotificationMethods.ProtocolVersionMetaKey] is JsonValue protocolVersion && + protocolVersion.TryGetValue(out string? protocolVersionValue)) + { + // If a transport-level header already populated this, validate it matches per SEP-2575. + if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"Protocol version mismatch: the per-request _meta value '{protocolVersionValue}' does not match the transport-level header value '{existing}'.", + McpErrorCode.InvalidParams); + } + + context.ProtocolVersion = protocolVersionValue; + } + + if (metaObj[NotificationMethods.ClientInfoMetaKey] is JsonNode clientInfoNode) + { + context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); + } + + if (metaObj[NotificationMethods.ClientCapabilitiesMetaKey] is JsonNode clientCapabilitiesNode) + { + context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); + } + + if (metaObj[NotificationMethods.LogLevelMetaKey] is JsonNode logLevelNode) + { + context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + } + + /// + /// Injects the draft-protocol per-request _meta fields into an outgoing request, + /// idempotently overwriting any existing values. + /// + /// + /// Used by in draft mode to carry protocol version, client info, and + /// client capabilities on every outgoing request (replacing what the legacy initialize handshake + /// previously negotiated once). + /// + internal static void InjectDraftMeta( + JsonRpcRequest request, + string protocolVersion, + Implementation clientInfo, + ClientCapabilities clientCapabilities, + LoggingLevel? logLevel = null) + { + var paramsObj = request.Params as JsonObject; + if (paramsObj is null) + { + paramsObj = new JsonObject(); + request.Params = paramsObj; + } + + if (paramsObj["_meta"] is not JsonObject metaObj) + { + metaObj = new JsonObject(); + paramsObj["_meta"] = metaObj; + } + + metaObj[NotificationMethods.ProtocolVersionMetaKey] = protocolVersion; + metaObj[NotificationMethods.ClientInfoMetaKey] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.JsonContext.Default.Implementation); + metaObj[NotificationMethods.ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities); + + if (logLevel is { } level) + { + metaObj[NotificationMethods.LogLevelMetaKey] = JsonSerializer.SerializeToNode(level, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + else + { + metaObj.Remove(NotificationMethods.LogLevelMetaKey); + } + } + private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request) { if (!cancellationToken.CanBeCanceled) @@ -994,6 +1112,17 @@ private static TimeSpan GetElapsed(long startingTimestamp) => } private static McpProtocolException CreateRemoteProtocolException(JsonRpcError error) + => CreateRemoteProtocolExceptionFromError(error); + + /// + /// Creates a typed from a JSON-RPC error response. + /// + /// + /// Exposed internally so transports that surface an HTTP-level error containing a JSON-RPC error + /// body (e.g., a 400 with ) can convert + /// the error to the same typed exception that JSON-RPC-level error responses produce. + /// + internal static McpProtocolException CreateRemoteProtocolExceptionFromError(JsonRpcError error) { string formattedMessage = $"Request failed (remote): {error.Error.Message}"; var errorCode = (McpErrorCode)error.Error.Code; @@ -1004,6 +1133,16 @@ private static McpProtocolException CreateRemoteProtocolException(JsonRpcError e { exception = urlException; } + else if (errorCode == McpErrorCode.UnsupportedProtocolVersion && + UnsupportedProtocolVersionException.TryCreateFromError(formattedMessage, error.Error, out var upvException)) + { + exception = upvException; + } + else if (errorCode == McpErrorCode.MissingRequiredClientCapability && + MissingRequiredClientCapabilityException.TryCreateFromError(formattedMessage, error.Error, out var mrccException)) + { + exception = mrccException; + } else { exception = new McpProtocolException(formattedMessage, errorCode); diff --git a/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs new file mode 100644 index 000000000..aca6d4902 --- /dev/null +++ b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol; + +/// +/// Represents an exception used to signal that a request requires a client capability that was not declared +/// in the request's per-request _meta/io.modelcontextprotocol/clientCapabilities field. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when a handler cannot +/// proceed because the client did not declare a required capability for the request. The exception is converted +/// to a JSON-RPC error response with code (-32003) +/// and a payload. +/// +public sealed class MissingRequiredClientCapabilityException : McpProtocolException +{ + /// + /// Initializes a new instance of the class. + /// + /// The capabilities the server requires for the request. + /// A human-readable description of the error. If , a default message is used. + public MissingRequiredClientCapabilityException(ClientCapabilities requiredCapabilities, string? message = null) + : base(message ?? "The request requires client capabilities that were not declared in _meta/clientCapabilities.", + McpErrorCode.MissingRequiredClientCapability) + { + Throw.IfNull(requiredCapabilities); + RequiredCapabilities = requiredCapabilities; + } + + /// Gets the client capabilities required for the request. + public ClientCapabilities RequiredCapabilities { get; } + + internal JsonNode CreateErrorDataNode() + { + var payload = new MissingRequiredClientCapabilityErrorData + { + RequiredCapabilities = RequiredCapabilities, + }; + + return JsonSerializer.SerializeToNode(payload, McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData)!; + } + + internal static bool TryCreateFromError( + string formattedMessage, + JsonRpcErrorDetail detail, + [NotNullWhen(true)] out MissingRequiredClientCapabilityException? exception) + { + exception = null; + + if (detail.Data is not JsonElement dataElement || dataElement.ValueKind is not JsonValueKind.Object) + { + return false; + } + + var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData); + if (payload?.RequiredCapabilities is null) + { + return false; + } + + exception = new MissingRequiredClientCapabilityException(payload.RequiredCapabilities, formattedMessage); + return true; + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs new file mode 100644 index 000000000..e9a343f46 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs @@ -0,0 +1,16 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a request. +/// +/// +/// +/// The discover RPC takes no payload of its own. Per-request metadata +/// (protocol version, client info, client capabilities) flows through the +/// inherited property under the +/// io.modelcontextprotocol/* keys defined by the draft protocol revision (SEP-2575). +/// +/// +public sealed class DiscoverRequestParams : RequestParams +{ +} diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs new file mode 100644 index 000000000..c2c5833bf --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result returned from a request. +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575) as the canonical way for a client +/// to learn what a server supports without performing the legacy initialize handshake. +/// +/// +public sealed class DiscoverResult : Result +{ + /// + /// Gets or sets the list of MCP protocol version strings that the server supports. + /// + /// + /// The client should choose a version from this list for use in subsequent requests. + /// + [JsonPropertyName("supportedVersions")] + public required IList SupportedVersions { get; set; } + + /// + /// Gets or sets the capabilities of the server. + /// + [JsonPropertyName("capabilities")] + public required ServerCapabilities Capabilities { get; set; } + + /// + /// Gets or sets information about the server implementation. + /// + [JsonPropertyName("serverInfo")] + public required Implementation ServerInfo { get; set; } + + /// + /// Gets or sets optional instructions describing how to use the server and its features. + /// + /// + /// This can be used by clients to improve an LLM's understanding of the server, + /// for example by including it in a system prompt. + /// + [JsonPropertyName("instructions")] + public string? Instructions { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index e5c0f3931..b804c288a 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -85,4 +85,35 @@ public sealed class JsonRpcMessageContext /// to flow the protocol version header so the server can determine client capabilities. /// public string? ProtocolVersion { get; set; } + + /// + /// Gets or sets the client info derived from the per-request + /// _meta/io.modelcontextprotocol/clientInfo field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). When the request was made under the draft revision, + /// the server uses this in lieu of the value previously captured during the initialize handshake. + /// + public Implementation? ClientInfo { get; set; } + + /// + /// Gets or sets the client capabilities derived from the per-request + /// _meta/io.modelcontextprotocol/clientCapabilities field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client + /// capabilities from previous requests; the authoritative value is the one declared on each request. + /// + public ClientCapabilities? ClientCapabilities { get; set; } + + /// + /// Gets or sets the per-request log level derived from the + /// _meta/io.modelcontextprotocol/logLevel field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Replaces the legacy + /// RPC. When absent, the server MUST NOT emit log notifications + /// for the request. + /// + public LoggingLevel? LogLevel { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs new file mode 100644 index 000000000..8370aaf9a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the payload for the JSON-RPC error. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). When a server cannot fulfill a request because +/// the client did not declare a required capability in its per-request +/// _meta/io.modelcontextprotocol/clientCapabilities field, it MUST return this error so clients +/// know which capabilities to advertise on a retry. +/// +public sealed class MissingRequiredClientCapabilityErrorData +{ + /// + /// Gets or sets the client capabilities the server requires to process the request. + /// + [JsonPropertyName("requiredCapabilities")] + public required ClientCapabilities RequiredCapabilities { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 949361650..fcc75aff4 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -178,4 +178,63 @@ public static class NotificationMethods /// /// public const string RelatedTaskMetaKey = "io.modelcontextprotocol/related-task"; + + /// + /// The name of the notification sent first on a + /// response stream to indicate which notification types the server agreed to deliver. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The notification's params mirror the shape + /// of the requested notifications and include only the entries the server actually supports. + /// + public const string SubscriptionsAcknowledgedNotification = "notifications/subscriptions/acknowledged"; + + /// + /// The metadata key used to carry the MCP protocol version in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). For HTTP transports, the value MUST + /// match the MCP-Protocol-Version header. Servers reject mismatched versions with + /// . + /// + public const string ProtocolVersionMetaKey = "io.modelcontextprotocol/protocolVersion"; + + /// + /// The metadata key used to identify the client software in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries an + /// describing the client; replaces the clientInfo previously sent only with initialize. + /// + public const string ClientInfoMetaKey = "io.modelcontextprotocol/clientInfo"; + + /// + /// The metadata key used to declare client capabilities in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries a + /// describing what optional features the client supports for this specific request. Servers MUST NOT + /// infer capabilities from previous requests. + /// + public const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + + /// + /// The metadata key used to specify the desired log level for a request's resulting log notifications. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries a . + /// Replaces the legacy RPC. When absent, the server + /// MUST NOT send log notifications for the request. + /// + public const string LogLevelMetaKey = "io.modelcontextprotocol/logLevel"; + + /// + /// The metadata key used to associate a notification with the request ID of an active + /// subscription. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Allows clients to demultiplex notifications + /// belonging to different subscriptions on a shared channel (especially STDIO). + /// + public const string SubscriptionIdMetaKey = "io.modelcontextprotocol/subscriptionId"; } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index e0118fa57..93fff011f 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -149,4 +149,42 @@ public static class RequestMethods /// The name of the request method to explicitly cancel a task. /// public const string TasksCancel = "tasks/cancel"; + + /// + /// The name of the request method sent from the client to discover the server's protocol versions, + /// capabilities, and metadata. + /// + /// + /// + /// This RPC is introduced in the draft protocol revision (SEP-2575) as the canonical way for a client + /// to learn what a server supports without performing the legacy 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. Draft-revision clients + /// typically call this once during connection establishment. + /// + /// + public const string ServerDiscover = "server/discover"; + + /// + /// The name of the request method sent from the client to open a long-lived subscription for + /// receiving server-to-client notifications outside of a specific request's response stream. + /// + /// + /// + /// This RPC is introduced in the draft protocol revision (SEP-2575) and replaces the unsolicited + /// HTTP GET endpoint and the legacy / + /// request methods. + /// + /// + /// The request opens a response stream on which the server first sends a + /// describing the granted + /// notifications, and then streams matching notifications until the subscription is cancelled. + /// + /// + public const string SubscriptionsListen = "subscriptions/listen"; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs new file mode 100644 index 000000000..f4212b2b7 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters sent with a . +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). This notification is the first message on a +/// response stream and informs the client which +/// subset of requested notification types the server has agreed to deliver. +/// +/// +public sealed class SubscriptionsAcknowledgedNotificationParams +{ + /// + /// Gets or sets the notification subscriptions the server has agreed to honor. + /// + /// + /// Only includes notification types the server actually supports. If the client requested an + /// unsupported notification type (e.g., promptsListChanged when the server has no prompts), + /// it is omitted from this set. + /// + [JsonPropertyName("notifications")] + public required SubscriptionsListenNotifications Notifications { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs new file mode 100644 index 000000000..a81d45669 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a request. +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). The client uses this request to open a +/// long-lived channel for receiving notifications outside the context of a specific request. +/// +/// +/// Per-request metadata (protocol version, client info, client capabilities, optional log level) +/// flows through the inherited property under the +/// io.modelcontextprotocol/* keys. +/// +/// +public sealed class SubscriptionsListenRequestParams : RequestParams +{ + /// + /// Gets or sets the notifications the client wants to receive on this subscription stream. + /// + /// + /// Each notification type is opt-in; the server MUST NOT send notification types the client + /// has not explicitly requested here. The server's + /// reports the subset + /// of requested notifications the server actually supports. + /// + [JsonPropertyName("notifications")] + public required SubscriptionsListenNotifications Notifications { get; set; } +} + +/// +/// Describes the set of notification types a client wants to receive (or that a server has agreed +/// to deliver) for a subscription. +/// +public sealed class SubscriptionsListenNotifications +{ + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("toolsListChanged")] + public bool? ToolsListChanged { get; set; } + + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("promptsListChanged")] + public bool? PromptsListChanged { get; set; } + + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("resourcesListChanged")] + public bool? ResourcesListChanged { get; set; } + + /// + /// Gets or sets the list of resource URIs to subscribe to for + /// notifications. + /// + /// + /// Replaces the legacy / + /// RPCs from prior protocol revisions. + /// + [JsonPropertyName("resourceSubscriptions")] + public IList? ResourceSubscriptions { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs new file mode 100644 index 000000000..ac394db90 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the payload for the JSON-RPC error. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). When a server receives a request whose +/// declared protocol version it does not implement, it MUST return this error so clients can +/// fall back to a mutually supported version. +/// +public sealed class UnsupportedProtocolVersionErrorData +{ + /// + /// Gets or sets the protocol version strings that the server supports. + /// + [JsonPropertyName("supported")] + public required IList Supported { get; set; } + + /// + /// Gets or sets the protocol version requested by the client. + /// + [JsonPropertyName("requested")] + public required string Requested { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index c48ee2da0..0f612e33c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -93,12 +93,14 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact // Configure all request handlers based on the supplied options. ServerCapabilities = new(); ConfigureInitialize(options); + ConfigureDiscover(options); ConfigureTools(options); ConfigurePrompts(options); ConfigureResources(options); ConfigureTasks(options); ConfigureLogging(options); ConfigureCompletion(options); + ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); ConfigureMrtr(); @@ -142,17 +144,80 @@ void Register(McpServerPrimitiveCollection? collection, // And initialize the session. var incomingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters); var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters); + + // Prepend a built-in filter that picks up per-request _meta values populated by + // McpSessionHandler.PopulateContextFromMeta and projects them onto the server's + // per-session state. Under the draft protocol revision (SEP-2575) the client no longer + // performs an initialize handshake, so this is the only place client info / capabilities + // / negotiated protocol version are recorded server-side. This filter is a no-op for + // legacy clients that already populated these via initialize. + var draftStateSyncFilter = CreateDraftStateSyncFilter(); + var combinedIncomingFilter = ComposeFilters(draftStateSyncFilter, incomingMessageFilter); + _sessionHandler = new McpSessionHandler( isServer: true, _sessionTransport, _endpointName!, _requestHandlers, _notificationHandlers, - incomingMessageFilter, + combinedIncomingFilter, outgoingMessageFilter, _logger); } + /// Composes two s so runs first. + private static JsonRpcMessageFilter ComposeFilters(JsonRpcMessageFilter outer, JsonRpcMessageFilter inner) => + next => outer(inner(next)); + + /// + /// Builds an incoming message filter that, for every JSON-RPC request, synchronizes server-side state + /// (, , ) + /// from the per-request _meta values projected onto . + /// + /// + /// Under the draft 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 this filter + /// is a no-op (the values were captured during the initialize handler). + /// + private JsonRpcMessageFilter CreateDraftStateSyncFilter() + { + return next => async (message, cancellationToken) => + { + if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context) + { + bool endpointNameNeedsRefresh = false; + + if (context.ProtocolVersion is { } protocolVersion && + !string.Equals(_negotiatedProtocolVersion, protocolVersion, StringComparison.Ordinal)) + { + _negotiatedProtocolVersion = protocolVersion; + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + } + + if (context.ClientCapabilities is { } clientCapabilities) + { + _clientCapabilities = clientCapabilities; + } + + if (context.ClientInfo is { } clientInfo && + (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || + !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) + { + _clientInfo = clientInfo; + endpointNameNeedsRefresh = true; + } + + if (endpointNameNeedsRefresh) + { + UpdateEndpointNameWithClientInfo(); + _sessionHandler.EndpointName = _endpointName; + } + } + + await next(message, cancellationToken).ConfigureAwait(false); + }; + } + /// public override string? SessionId => _sessionTransport.SessionId; @@ -289,6 +354,106 @@ private void ConfigureInitialize(McpServerOptions options) McpJsonUtilities.JsonContext.Default.InitializeResult); } + /// + /// Registers the server/discover request handler introduced by the draft protocol revision (SEP-2575). + /// + /// + /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's + /// supported protocol versions (including any configured ), + /// server capabilities, server info, and optional instructions. + /// + private void ConfigureDiscover(McpServerOptions options) + { + _requestHandlers.Set(RequestMethods.ServerDiscover, + (request, _, _) => + { + var supportedVersions = new List(McpSessionHandler.SupportedProtocolVersions); + if (options.ExperimentalProtocolVersion is { } experimental && + !supportedVersions.Contains(experimental)) + { + supportedVersions.Add(experimental); + } + + return new ValueTask(new DiscoverResult + { + SupportedVersions = supportedVersions, + Capabilities = ServerCapabilities ?? new(), + ServerInfo = options.ServerInfo ?? DefaultImplementation, + Instructions = options.ServerInstructions, + }); + }, + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult); + } + + /// + /// Registers the subscriptions/listen request handler introduced by the draft protocol revision (SEP-2575). + /// + /// + /// + /// The handler opens a long-lived response stream (over the per-request + /// for HTTP, or the shared STDIO channel) that first sends + /// reporting which subscriptions the + /// server agreed to honor, and then streams matching notifications until the request is cancelled. + /// + /// + /// Subscription-bound notifications carry the listen request's id in their + /// _meta/io.modelcontextprotocol/subscriptionId field per SEP-2575 so clients can demultiplex. + /// + /// + private void ConfigureSubscriptions(McpServerOptions options) + { + _requestHandlers.Set(RequestMethods.SubscriptionsListen, + async (request, jsonRpcRequest, cancellationToken) => + { + // Filter the requested notifications against what the server actually supports. + var requested = request?.Notifications ?? new SubscriptionsListenNotifications(); + var granted = new SubscriptionsListenNotifications + { + ToolsListChanged = requested.ToolsListChanged == true && ServerCapabilities?.Tools?.ListChanged == true ? true : null, + PromptsListChanged = requested.PromptsListChanged == true && ServerCapabilities?.Prompts?.ListChanged == true ? true : null, + ResourcesListChanged = requested.ResourcesListChanged == true && ServerCapabilities?.Resources?.ListChanged == true ? true : null, + ResourceSubscriptions = requested.ResourceSubscriptions is { Count: > 0 } subs && ServerCapabilities?.Resources?.Subscribe == true + ? new List(subs) + : null, + }; + + // Track this subscription so notifications can tag themselves with the right subscriptionId + // and so we can stream resource-updated notifications for the requested URIs. + var subscription = new ActiveSubscription(jsonRpcRequest.Id, granted, jsonRpcRequest.Context?.LogLevel); + _activeSubscriptions[jsonRpcRequest.Id] = subscription; + + try + { + // Send the acknowledgement notification first, as required by SEP-2575. + await this.SendNotificationAsync( + NotificationMethods.SubscriptionsAcknowledgedNotification, + new SubscriptionsAcknowledgedNotificationParams { Notifications = granted }, + McpJsonUtilities.JsonContext.Default.SubscriptionsAcknowledgedNotificationParams, + cancellationToken).ConfigureAwait(false); + + // Keep the subscription open until the request is cancelled (client disconnect on HTTP, + // or notifications/cancelled on STDIO). + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), tcs); + await tcs.Task.ConfigureAwait(false); + } + finally + { + _activeSubscriptions.TryRemove(jsonRpcRequest.Id, out _); + } + + return new EmptyResult(); + }, + McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams, + McpJsonUtilities.JsonContext.Default.EmptyResult); + } + + /// Tracks an active subscriptions/listen subscription for notification fan-out. + private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifications Granted, LoggingLevel? LogLevel); + + private readonly ConcurrentDictionary _activeSubscriptions = new(); + private void ConfigureCompletion(McpServerOptions options) { var completeHandler = options.Handlers.CompleteHandler; diff --git a/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs new file mode 100644 index 000000000..fc37e05cd --- /dev/null +++ b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs @@ -0,0 +1,74 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol; + +/// +/// Represents an exception used to signal that a request's declared protocol version is not supported by the server. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when they cannot process +/// a request because the per-request _meta/io.modelcontextprotocol/protocolVersion (or the equivalent +/// transport-level header) names a version the server does not implement. The exception is converted to a +/// JSON-RPC error response with code (-32004) and +/// a payload. +/// +public sealed class UnsupportedProtocolVersionException : McpProtocolException +{ + /// + /// Initializes a new instance of the class. + /// + /// The protocol version the client requested. + /// The protocol versions the server supports. + /// A human-readable description of the error. If , a default message is used. + public UnsupportedProtocolVersionException(string requested, IEnumerable supported, string? message = null) + : base(message ?? $"Unsupported protocol version '{requested}'.", McpErrorCode.UnsupportedProtocolVersion) + { + Throw.IfNull(requested); + Throw.IfNull(supported); + + Requested = requested; + Supported = new List(supported); + } + + /// Gets the protocol version the client requested. + public string Requested { get; } + + /// Gets the protocol versions the server supports. + public IReadOnlyList Supported { get; } + + internal JsonNode CreateErrorDataNode() + { + var payload = new UnsupportedProtocolVersionErrorData + { + Requested = Requested, + Supported = (IList)Supported, + }; + + return JsonSerializer.SerializeToNode(payload, McpJsonUtilities.JsonContext.Default.UnsupportedProtocolVersionErrorData)!; + } + + internal static bool TryCreateFromError( + string formattedMessage, + JsonRpcErrorDetail detail, + [NotNullWhen(true)] out UnsupportedProtocolVersionException? exception) + { + exception = null; + + if (detail.Data is not JsonElement dataElement || dataElement.ValueKind is not JsonValueKind.Object) + { + return false; + } + + var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.UnsupportedProtocolVersionErrorData); + if (payload is null) + { + return false; + } + + exception = new UnsupportedProtocolVersionException(payload.Requested, payload.Supported, formattedMessage); + return true; + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index ddae6c66b..54a37ecc6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -180,6 +180,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) // and no persistent server instance for the backcompat retry loop). The server returns // a JSON-RPC error. await using var client = await ConnectAsync(configureClient: configureClient); + var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-mixed", cancellationToken: TestContext.Current.CancellationToken).AsTask()); @@ -267,6 +268,10 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) // Parallel awaits work with regular JSON-RPC but fail with MRTR because // MrtrContext only supports one exchange at a time (TrySetResult gate). Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); + // Under the draft protocol revision (SEP-2567), the server is implicitly stateless for draft + // clients, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client + // case for the same reason as Mrtr_MixedExceptionAndAwaitStyle. + Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; draft protocol revision (SEP-2567) is sessionless."); ConfigureServer(MrtrParallelAwait); await using var app = Builder.Build(); diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 90864d393..7417f19cc 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -165,7 +165,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() Model = "test-model" }); - // Start the client task - it will send initialize and block waiting for response + // Start the client task — it will send server/discover (draft) and block waiting for response var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -175,35 +175,32 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - // Simulate server: read initialize request, respond with experimental version + // Simulate server: read server/discover request, respond with a DiscoverResult + // that advertises support for the experimental version. var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - // Read the initialize request from client - var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initLine); - var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(initRequest); - Assert.Equal("initialize", initRequest.Method); + // Read the server/discover request from client (draft revision skips initialize per SEP-2575). + var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(discoverLine); + var discoverRequest = JsonSerializer.Deserialize(discoverLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverRequest); + Assert.Equal(RequestMethods.ServerDiscover, discoverRequest.Method); - // Respond with experimental protocol version (MRTR negotiated) - var initResponse = new JsonRpcResponse + // Respond with a DiscoverResult that includes the experimental version in supportedVersions. + var discoverResponse = new JsonRpcResponse { - Id = initRequest.Id, - Result = JsonSerializer.SerializeToNode(new InitializeResult + Id = discoverRequest.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult { - ProtocolVersion = "DRAFT-2026-v1", + SupportedVersions = new List { "DRAFT-2026-v1" }, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" } + ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; - await WriteJsonRpcAsync(serverWriter, initResponse); - - // Read the initialized notification from client - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); + await WriteJsonRpcAsync(serverWriter, discoverResponse); - // Client is now connected with MRTR negotiated + // Client is now connected with MRTR negotiated (no initialized notification under draft). await using var client = await clientTask; Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); From 64aaf70786e387e8f35c53aac7712b20d0b8c954 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 13:56:50 -0700 Subject: [PATCH 02/23] Add draft-mode tests and update XML docs Adds round-trip tests for the new draft protocol revision and updates the XML documentation on ExperimentalProtocolVersion (client + server) to describe the full SEP-2575 + SEP-2567 behavior (sessionless, handshake-less, server/discover, MRTR-only server-to-client interactions, fallback to legacy initialize on unsupported-version responses). Tests added: - DiscoverProtocolTests / SubscriptionsListenProtocolTests / DraftErrorDataTests: JSON-serialization round-trip coverage for the new protocol types and error data payloads. - DraftConnectionTests: end-to-end client/server connection flow for draft client vs. draft server, draft client vs. legacy server (fallback), legacy client vs. draft server, and explicit server/discover invocation. - DraftHttpHandlerTests (AspNetCore): HTTP-level checks that draft requests don't emit Mcp-Session-Id, unsupported protocol versions return -32004 with the structured supportedVersions payload, draft requests carrying an Mcp-Session-Id route through the legacy lookup, and draft GET/DELETE are rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DraftHttpHandlerTests.cs | 139 ++++++++++++++++++ .../Client/DraftConnectionTests.cs | 116 +++++++++++++++ .../Protocol/DiscoverProtocolTests.cs | 80 ++++++++++ .../Protocol/DraftErrorDataTests.cs | 67 +++++++++ .../SubscriptionsListenProtocolTests.cs | 63 ++++++++ 5 files changed, 465 insertions(+) create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs new file mode 100644 index 000000000..a43be7552 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// HTTP-level tests for the draft protocol revision (SEP-2575 + SEP-2567): verify that the server +/// suppresses the Mcp-Session-Id header for draft requests and returns structured +/// errors instead of plain 400s. +/// +public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = "2026-06-XX"; + + private WebApplication? _app; + + private async Task StartAsync(bool experimentalServer) + { +#pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is experimental + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" }; + if (experimentalServer) + { + options.ExperimentalProtocolVersion = DraftVersion; + } + }).WithHttpTransport(); +#pragma warning restore MCPEXP001 + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task DraftRequest_DoesNotEmitMcpSessionIdHeader() + { + await StartAsync(experimentalServer: true); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + + // server/discover should succeed without creating a session. + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Draft responses must not include Mcp-Session-Id"); + } + + [Fact] + public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() + { + await StartAsync(experimentalServer: false); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); + + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var rpcMessage = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions); + var rpcError = Assert.IsType(rpcMessage); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code); + + // Validate the structured data payload (SEP-2575 §"Unsupported Protocol Versions"). + var dataElement = (JsonElement)rpcError.Error.Data!; + var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); + Assert.NotNull(errorData); + Assert.Equal("2099-12-31", errorData.Requested); + Assert.NotEmpty(errorData.Supported); + } + + [Fact] + public async Task DraftRequest_WithMcpSessionIdHeader_RoutesThroughLegacyPath() + { + // For back-compat with clients that opted into the experimental version on top of the legacy + // stateful session model (MRTR-as-extension-on-initialize), draft-version requests that DO + // include an Mcp-Session-Id are still accepted via the legacy session lookup path. + await StartAsync(experimentalServer: true); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); + + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + // Legacy path returns 404 for unknown sessions. + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DraftGet_WithoutSessionId_IsRejected() + { + await StartAsync(experimentalServer: true); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + + using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftDelete_WithoutSessionId_IsRejected() + { + await StartAsync(experimentalServer: true); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + + using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs new file mode 100644 index 000000000..7c6b3840f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs @@ -0,0 +1,116 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for the draft protocol revision (SEP-2575 + SEP-2567) connection flow on +/// — the client should call server/discover instead of +/// initialize when is set and +/// the server supports the requested version, and it should fall back to the legacy +/// initialize handshake otherwise. +/// +#pragma warning disable MCPEXP002 // ExperimentalProtocolVersion +public class DraftConnectionTests : ClientServerTestBase +{ + private const string DraftVersion = "2026-06-XX"; + private const string LatestStableVersion = "2025-11-25"; + + private bool _serverHasExperimental; + + public DraftConnectionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftConnectionTests), Version = "1.0" }; + if (_serverHasExperimental) + { + options.ExperimentalProtocolVersion = DraftVersion; + } + }); + } + + [Fact] + public async Task DraftClient_ConnectingToDraftServer_NegotiatesExperimentalVersion() + { + _serverHasExperimental = true; + StartServer(); + + var options = new McpClientOptions { ExperimentalProtocolVersion = DraftVersion }; + await using var client = await CreateMcpClientForServer(options); + + Assert.Equal(DraftVersion, client.NegotiatedProtocolVersion); + Assert.NotNull(client.ServerCapabilities); + Assert.Equal(nameof(DraftConnectionTests), client.ServerInfo.Name); + } + + [Fact] + public async Task DraftClient_ConnectingToLegacyServer_FallsBackToLegacyInitialize() + { + _serverHasExperimental = false; + StartServer(); + + var options = new McpClientOptions { ExperimentalProtocolVersion = DraftVersion }; + await using var client = await CreateMcpClientForServer(options); + + Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion); + Assert.Equal(LatestStableVersion, client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion() + { + _serverHasExperimental = true; + StartServer(); + + await using var client = await CreateMcpClientForServer(); + + Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacyClient_CanCallServerDiscover_EvenWithoutDraftConfigured() + { + // server/discover is registered unconditionally, so a legacy client can probe it + // (e.g., to learn capabilities without doing a second initialize). + _serverHasExperimental = false; + StartServer(); + + await using var client = await CreateMcpClientForServer(); + + var response = 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(DraftConnectionTests), discoverResult.ServerInfo.Name); + } + + [Fact] + public async Task DraftServer_DiscoverIncludesExperimentalVersion() + { + _serverHasExperimental = true; + StartServer(); + + await using var client = await CreateMcpClientForServer(); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverResult); + Assert.Contains(DraftVersion, discoverResult.SupportedVersions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs new file mode 100644 index 000000000..23921b670 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -0,0 +1,80 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the request/result types introduced by the draft protocol revision (SEP-2575). +/// +public static class DiscoverProtocolTests +{ + [Fact] + public static void DiscoverRequestParams_SerializationRoundTrip_WithMeta() + { + var original = new DiscoverRequestParams + { + Meta = new JsonObject + { + [NotificationMethods.ProtocolVersionMetaKey] = "2026-06-XX", + [NotificationMethods.ClientInfoMetaKey] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0", + }, + [NotificationMethods.ClientCapabilitiesMetaKey] = new JsonObject(), + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Meta); + Assert.Equal("2026-06-XX", (string)deserialized.Meta[NotificationMethods.ProtocolVersionMetaKey]!); + } + + [Fact] + public static void DiscoverResult_SerializationRoundTrip_PreservesAllProperties() + { + var original = new DiscoverResult + { + SupportedVersions = new List { "2025-11-25", "2026-06-XX" }, + Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability { ListChanged = true }, + }, + ServerInfo = new Implementation { Name = "test-server", Version = "2.0" }, + Instructions = "Use this server for testing.", + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(["2025-11-25", "2026-06-XX"], deserialized.SupportedVersions); + Assert.NotNull(deserialized.Capabilities.Tools); + Assert.True(deserialized.Capabilities.Tools.ListChanged); + Assert.Equal("test-server", deserialized.ServerInfo.Name); + Assert.Equal("Use this server for testing.", deserialized.Instructions); + } + + [Fact] + public static void DiscoverResult_SerializationRoundTrip_WithMinimalProperties() + { + var original = new DiscoverResult + { + SupportedVersions = new List { "2026-06-XX" }, + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "minimal-server", Version = "1.0" }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Single(deserialized.SupportedVersions); + Assert.Equal("2026-06-XX", deserialized.SupportedVersions[0]); + Assert.Null(deserialized.Instructions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs new file mode 100644 index 000000000..092ebc624 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the error data payloads introduced by the draft protocol revision (SEP-2575). +/// +public static class DraftErrorDataTests +{ + [Fact] + public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_PreservesAllProperties() + { + var original = new UnsupportedProtocolVersionErrorData + { + Supported = new List { "2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25" }, + Requested = "2026-06-XX", + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(4, deserialized.Supported.Count); + Assert.Contains("2025-11-25", deserialized.Supported); + Assert.Equal("2026-06-XX", deserialized.Requested); + } + + [Fact] + public static void MissingRequiredClientCapabilityErrorData_SerializationRoundTrip_PreservesAllProperties() + { + var original = new MissingRequiredClientCapabilityErrorData + { + RequiredCapabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.RequiredCapabilities.Sampling); + } + + [Fact] + public static void UnsupportedProtocolVersionException_ExposesRequestedAndSupported() + { + var ex = new UnsupportedProtocolVersionException("2099-12-31", ["2025-11-25", "2025-06-18"]); + + Assert.Equal(McpErrorCode.UnsupportedProtocolVersion, ex.ErrorCode); + Assert.Equal("2099-12-31", ex.Requested); + Assert.Equal(2, ex.Supported.Count); + Assert.Contains("2025-11-25", ex.Supported); + } + + [Fact] + public static void MissingRequiredClientCapabilityException_ExposesRequiredCapabilities() + { + var caps = new ClientCapabilities { Roots = new RootsCapability() }; + var ex = new MissingRequiredClientCapabilityException(caps); + + Assert.Equal(McpErrorCode.MissingRequiredClientCapability, ex.ErrorCode); + Assert.Same(caps, ex.RequiredCapabilities); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs new file mode 100644 index 000000000..e61b9b055 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs @@ -0,0 +1,63 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the subscriptions/listen types introduced by the draft protocol revision (SEP-2575). +/// +public static class SubscriptionsListenProtocolTests +{ + [Fact] + public static void SubscriptionsListenRequestParams_SerializationRoundTrip_PreservesAllProperties() + { + var original = new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + PromptsListChanged = true, + ResourcesListChanged = true, + ResourceSubscriptions = new List { "file:///foo.txt", "file:///bar.txt" }, + }, + Meta = new JsonObject + { + [NotificationMethods.ProtocolVersionMetaKey] = "2026-06-XX", + [NotificationMethods.LogLevelMetaKey] = "info", + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Notifications.ToolsListChanged); + Assert.True(deserialized.Notifications.PromptsListChanged); + Assert.True(deserialized.Notifications.ResourcesListChanged); + Assert.NotNull(deserialized.Notifications.ResourceSubscriptions); + Assert.Equal(["file:///foo.txt", "file:///bar.txt"], deserialized.Notifications.ResourceSubscriptions); + Assert.Equal("2026-06-XX", (string)deserialized.Meta![NotificationMethods.ProtocolVersionMetaKey]!); + } + + [Fact] + public static void SubscriptionsAcknowledgedNotificationParams_SerializationRoundTrip_PreservesNotifications() + { + var original = new SubscriptionsAcknowledgedNotificationParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Notifications.ToolsListChanged); + Assert.Null(deserialized.Notifications.PromptsListChanged); + Assert.Null(deserialized.Notifications.ResourcesListChanged); + Assert.Null(deserialized.Notifications.ResourceSubscriptions); + } +} From bd5c624477d4c8c59225620c97a767f8d5d4fd24 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 5 Jun 2026 11:25:33 -0700 Subject: [PATCH 03/23] Rename ExperimentalProtocolVersion to ProtocolVersion + sync draft version to 2026-07-28 - Drop ExperimentalProtocolVersion from McpClientOptions/McpServerOptions and use ProtocolVersion == McpSessionHandler.DraftProtocolVersion as the draft predicate. - McpHttpHeaders.DraftProtocolVersion and McpSessionHandler.DraftProtocolVersion are now `2026-07-28` (matches the published spec) instead of `DRAFT-2026-v1`. - Server always advertises draft via SupportedProtocolVersions; ConfigureDiscover no longer takes an opt-in flag. - Drop _serverHasExperimental machinery from DraftConnectionTests; the test class now relies on the unconditional draft support. - Skip Mrtr_MixedExceptionAndAwaitStyle(experimentalClient: True) over Streamable HTTP; the await-style path needs session affinity and draft HTTP is sessionless. Stdio coverage stays in DraftProtocolBackcompatTests. - Sweep the remaining DRAFT-2026-v1 / 2026-06-XX literals across docs and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/elicitation/elicitation.md | 6 +-- docs/concepts/mrtr/mrtr.md | 24 +++++------ docs/concepts/roots/roots.md | 6 +-- docs/concepts/sampling/sampling.md | 6 +-- docs/concepts/tools/tools.md | 2 +- src/Common/McpHttpHeaders.cs | 2 +- .../StreamableHttpHandler.cs | 18 +++----- .../Client/McpClientImpl.cs | 14 ++++--- .../McpSessionHandler.cs | 2 +- .../Server/McpServerImpl.cs | 19 +++------ .../AddKnownToolsHeaderTests.cs | 2 +- .../DraftHttpHandlerTests.cs | 23 +++++------ .../HttpHeaderConformanceTests.cs | 24 +++++------ .../MapMcpTests.Mrtr.cs | 41 +++++++++++-------- .../MrtrProtocolTests.cs | 14 +++---- .../ServerConformanceTests.cs | 2 +- .../StreamableHttpClientConformanceTests.cs | 4 +- .../StreamableHttpServerConformanceTests.cs | 12 +++--- .../Client/DraftConnectionTests.cs | 39 ++++-------------- .../Client/McpClientTests.cs | 6 +-- .../Client/McpRequestHeadersTests.cs | 2 +- .../Client/MrtrIntegrationTests.cs | 22 +++++----- .../McpServerResourceRoutingTests.cs | 2 +- .../Protocol/DiscoverProtocolTests.cs | 12 +++--- .../Protocol/DraftErrorDataTests.cs | 4 +- .../SubscriptionsListenProtocolTests.cs | 4 +- .../Server/DraftProtocolBackcompatTests.cs | 14 +++---- .../Server/MrtrHandlerLifecycleTests.cs | 16 ++++---- .../Server/MrtrInputRequiredExceptionTests.cs | 2 +- .../Server/MrtrMessageFilterTests.cs | 12 +++--- .../Server/MrtrServerBackcompatTests.cs | 2 +- .../Server/MrtrSessionLimitTests.cs | 6 +-- 32 files changed, 165 insertions(+), 199 deletions(-) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 78782bfbb..003723e39 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,10 +172,10 @@ Here's an example implementation of how a console application might handle elici ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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 `DRAFT-2026-v1` 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 `DRAFT-2026-v1` 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 — 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. For example: @@ -196,7 +196,7 @@ public static string ElicitWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request user input diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 1d1ebce32..a8fa3df0f 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -9,7 +9,7 @@ uid: mrtr > [!WARNING] -> MRTR is part of the **`DRAFT-2026-v1`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. +> MRTR is part of the **`2026-07-28`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. Multi Round-Trip Requests (MRTR) let a server tool request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate server-to-client JSON-RPC request for each interaction. Instead of returning a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. @@ -33,13 +33,13 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`DRAFT-2026-v1`** during `initialize`. The C# SDK opts in by listing `DRAFT-2026-v1` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`** during `initialize`. The C# SDK opts in by listing `2026-07-28` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. ```csharp // Client var clientOptions = new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Handlers = new McpClientHandlers { ElicitationHandler = HandleElicitationAsync, @@ -48,7 +48,7 @@ var clientOptions = new McpClientOptions }; ``` -Under `DRAFT-2026-v1`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `DRAFT-2026-v1` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. +Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. @@ -60,7 +60,7 @@ A tool participates in MRTR by throwing before throwing `InputRequiredException`. It returns `true` when either: -- The negotiated protocol revision is `DRAFT-2026-v1` (MRTR is native), or +- The negotiated protocol revision is `2026-07-28` (MRTR is native), or - The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). ```csharp @@ -71,7 +71,7 @@ public static string MyTool( { if (!server.IsMrtrSupported) { - return "This tool requires a client that negotiates DRAFT-2026-v1, " + return "This tool requires a client that negotiates 2026-07-28, " + "or a stateful current-protocol session."; } @@ -258,7 +258,7 @@ When MRTR is not supported, you can provide domain-specific guidance: if (!server.IsMrtrSupported) { return "This tool requires interactive input. To use it:\n" - + "1. Connect with a client that negotiates MCP protocol revision DRAFT-2026-v1, or\n" + + "1. Connect with a client that negotiates MCP protocol revision 2026-07-28, or\n" + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; } @@ -270,22 +270,22 @@ The SDK supports `InputRequiredException` across two protocol revisions and two | Negotiated protocol | Session mode | Behavior | |---|---|---| -| `DRAFT-2026-v1` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | -| `DRAFT-2026-v1` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | +| `2026-07-28` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | +| `2026-07-28` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | | Current (`2025-06-18` and earlier) | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | | Current (`2025-06-18` and earlier) | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | > [!NOTE] -> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). +> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `2026-07-28` (check `IsMrtrSupported`). ### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). -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 `DRAFT-2026-v1`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft 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. +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 draft 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. ### Future direction -The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `DRAFT-2026-v1` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. +The `2026-07-28` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `2026-07-28` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. This work is a follow-up to the present PR. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 213d317c0..220887fae 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -106,10 +106,10 @@ server.RegisterNotificationHandler( ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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 `DRAFT-2026-v1` 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 `DRAFT-2026-v1` 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 — 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. For example: @@ -128,7 +128,7 @@ public static string ListRootsWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request the client's root list diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index bac6ed5ab..1dd0b90ec 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -123,10 +123,10 @@ Sampling requires the client to advertise the `sampling` capability. This is han ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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 `DRAFT-2026-v1` 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 `DRAFT-2026-v1` 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 — 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. For example: @@ -146,7 +146,7 @@ public static string SampleWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request LLM completion from the client diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 4936f4e5d..ae552e32c 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -339,7 +339,7 @@ Rules and constraints: - The header name must contain only visible ASCII characters (0x21–0x7E) excluding colon (`:`). - Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper. - Header names must be case-insensitively unique within the tool's input schema. -- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later). +- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `2026-07-28` and later). ### Pre-loading tool definitions on the client diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 0768cb442..ae5c84d6f 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -30,7 +30,7 @@ internal static class McpHttpHeaders /// The associated helpers perform exact ordinal matches against this single value rather /// than any ordered comparison. /// - public const string DraftProtocolVersion = "DRAFT-2026-v1"; + public const string DraftProtocolVersion = "2026-07-28"; /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 6b290a38e..87732bdf8 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -40,7 +40,7 @@ internal sealed class StreamableHttpHandler( "2025-03-26", "2025-06-18", "2025-11-25", - "DRAFT-2026-v1", + McpHttpHeaders.DraftProtocolVersion, ]; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); @@ -83,7 +83,7 @@ await WriteJsonRpcErrorAsync(context, return; } - if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out errorMessage)) + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch); return; @@ -394,22 +394,14 @@ await WriteJsonRpcErrorAsync(context, } /// - /// Returns when the request declares the experimental draft protocol revision via + /// Returns when the request declares the draft protocol revision via /// the MCP-Protocol-Version header. Draft requests are always sessionless and do not perform /// the legacy initialize handshake (SEP-2575 + SEP-2567). /// - private bool IsDraftProtocolRequest(HttpContext context) + private static bool IsDraftProtocolRequest(HttpContext context) { -#pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is for evaluation purposes only - var experimental = mcpServerOptionsSnapshot.Value.ExperimentalProtocolVersion; -#pragma warning restore MCPEXP001 - if (experimental is null) - { - return false; - } - var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - return string.Equals(protocolVersionHeader, experimental, StringComparison.Ordinal); + return string.Equals(protocolVersionHeader, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); } private async ValueTask StartNewSessionAsync(HttpContext context, bool forceStateless = false) diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 61b20dcd0..f32d208be 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -674,8 +674,10 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // 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. - if (_options.ExperimentalProtocolVersion is { } draftVersion) + if (_options.ProtocolVersion == McpSessionHandler.DraftProtocolVersion) { + string draftVersion = McpSessionHandler.DraftProtocolVersion; + // Eagerly set the negotiated version so InjectDraftMetaIfNeeded recognizes us as // a draft client when SendRequestAsync is invoked for server/discover. _negotiatedProtocolVersion = draftVersion; @@ -967,6 +969,8 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; InjectDraftMetaIfNeeded(request); + } + else if (inputRequiredResult.RequestState is not null) { // No input requests but has requestState (e.g., load shedding) - just retry with state. var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); @@ -1013,13 +1017,11 @@ private void InjectDraftMetaIfNeeded(JsonRpcRequest request) } /// - /// Returns when the negotiated protocol version is the experimental draft - /// revision (SEP-2575 + SEP-2567 + MRTR). + /// Returns when the negotiated protocol version is the draft revision + /// (SEP-2575 + SEP-2567 + MRTR). /// internal bool IsDraftProtocol() => - _negotiatedProtocolVersion is not null && - _options.ExperimentalProtocolVersion is not null && - _negotiatedProtocolVersion == _options.ExperimentalProtocolVersion; + _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; /// public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index fc62aae3c..a12b7453f 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -36,7 +36,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// Clients and servers opt in by setting /// or to this value. /// - internal const string DraftProtocolVersion = "DRAFT-2026-v1"; + internal const string DraftProtocolVersion = "2026-07-28"; /// /// All protocol versions supported by this implementation. diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 0f612e33c..f5264ced8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -359,24 +359,17 @@ private void ConfigureInitialize(McpServerOptions options) /// /// /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's - /// supported protocol versions (including any configured ), - /// server capabilities, server info, and optional instructions. + /// supported protocol versions (), server + /// capabilities, server info, and optional instructions. /// private void ConfigureDiscover(McpServerOptions options) { _requestHandlers.Set(RequestMethods.ServerDiscover, (request, _, _) => { - var supportedVersions = new List(McpSessionHandler.SupportedProtocolVersions); - if (options.ExperimentalProtocolVersion is { } experimental && - !supportedVersions.Contains(experimental)) - { - supportedVersions.Add(experimental); - } - return new ValueTask(new DiscoverResult { - SupportedVersions = supportedVersions, + SupportedVersions = [.. McpSessionHandler.SupportedProtocolVersions], Capabilities = ServerCapabilities ?? new(), ServerInfo = options.ServerInfo ?? DefaultImplementation, Instructions = options.ServerInstructions, @@ -1330,7 +1323,7 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => }; /// - /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1). + /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (2026-07-28). /// internal bool ClientSupportsMrtr() => _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; @@ -1388,7 +1381,7 @@ internal bool IsStatefulSession() => // In stateless mode without MRTR, the server can't resolve input requests via // JSON-RPC (no persistent session for server-to-client requests), and the client // won't recognize the InputRequiredResult. This is the one unsupported configuration. - // TODO(stateless-draft): When DRAFT-2026-v1 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol. + // TODO(stateless-draft): When 2026-07-28 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol. if (!IsStatefulSession()) { throw new McpException( @@ -1615,7 +1608,7 @@ private void WrapHandlerWithMrtr(string method) } // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits - // InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand, + // InputRequiredResult on the wire, which only 2026-07-28 clients understand, // and requires the same server instance to handle the retry (stateful session). // For all other cases - legacy clients, stateless sessions - fall through to the // exception-based path, which transparently resolves InputRequiredException via diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs index 7ab98f38e..8f0c13188 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs @@ -46,7 +46,7 @@ private async Task StartAsync() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-capture-test", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs index a43be7552..51ffb9b88 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs @@ -15,22 +15,16 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { - private const string DraftVersion = "2026-06-XX"; + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private WebApplication? _app; - private async Task StartAsync(bool experimentalServer) + private async Task StartAsync() { -#pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is experimental Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" }; - if (experimentalServer) - { - options.ExperimentalProtocolVersion = DraftVersion; - } }).WithHttpTransport(); -#pragma warning restore MCPEXP001 _app = Builder.Build(); _app.MapMcp(); @@ -52,9 +46,10 @@ public async ValueTask DisposeAsync() [Fact] public async Task DraftRequest_DoesNotEmitMcpSessionIdHeader() { - await StartAsync(experimentalServer: true); + await StartAsync(); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); // server/discover should succeed without creating a session. var content = new StringContent( @@ -69,9 +64,10 @@ public async Task DraftRequest_DoesNotEmitMcpSessionIdHeader() [Fact] public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() { - await StartAsync(experimentalServer: false); + await StartAsync(); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", @@ -99,9 +95,10 @@ public async Task DraftRequest_WithMcpSessionIdHeader_RoutesThroughLegacyPath() // For back-compat with clients that opted into the experimental version on top of the legacy // stateful session model (MRTR-as-extension-on-initialize), draft-version requests that DO // include an Mcp-Session-Id are still accepted via the legacy session lookup path. - await StartAsync(experimentalServer: true); + await StartAsync(); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); var content = new StringContent( @@ -116,7 +113,7 @@ public async Task DraftRequest_WithMcpSessionIdHeader_RoutesThroughLegacyPath() [Fact] public async Task DraftGet_WithoutSessionId_IsRejected() { - await StartAsync(experimentalServer: true); + await StartAsync(); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); @@ -128,7 +125,7 @@ public async Task DraftGet_WithoutSessionId_IsRejected() [Fact] public async Task DraftDelete_WithoutSessionId_IsRejected() { - await StartAsync(experimentalServer: true); + await StartAsync(); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index c3232b56a..50e4ab79c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -100,7 +100,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); request.Headers.TryAddWithoutValidation("Mcp-Name", " header_test "); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -123,7 +123,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", " tools/call "); request.Headers.TryAddWithoutValidation("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -147,7 +147,7 @@ public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -170,7 +170,7 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -196,7 +196,7 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", encodedValue!); @@ -221,7 +221,7 @@ public async Task Server_AcceptsLargeIntegerWithFullPrecision() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -246,7 +246,7 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -269,7 +269,7 @@ public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -331,7 +331,7 @@ public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); // Raw UTF-8 non-ASCII value in Mcp-Name — server must reject this request.Headers.TryAddWithoutValidation("Mcp-Name", "café☕"); @@ -410,7 +410,7 @@ public void Client_EncodeValue_Boolean_EncodesCorrectly() #region Version gating tests [Theory] - [InlineData("DRAFT-2026-v1", true)] + [InlineData("2026-07-28", true)] [InlineData("2025-11-25", false)] [InlineData("2025-06-18", false)] [InlineData("2024-11-05", false)] @@ -431,7 +431,7 @@ private async Task InitializeWithDraftVersionAsync() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(InitializeRequestDraft); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -471,7 +471,7 @@ private string CallTool(string toolName, string arguments = "{}") """; private static string InitializeRequestDraft => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; #endregion diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 54a37ecc6..4ebd82664 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -17,7 +17,7 @@ private ServerMessageTracker ConfigureServer(params Delegate[] tools) { options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" }; // Do not pin a protocol version - let it be negotiated based on what the client requests. - // DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it; others get + // 2026-07-28 is in SupportedProtocolVersions, so an opt-in client gets it; others get // the latest non-draft. messageTracker.AddFilters(options.Filters.Message); }) @@ -30,7 +30,7 @@ private Task ConnectExperimentalAsync() => ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }); private Task ConnectDefaultAsync() => @@ -79,7 +79,7 @@ private static void ConfigureMrtrHandlers(McpClientOptions options) // ===================================================================== // MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases. - // Each test creates its own server with DRAFT-2026-v1 enabled. + // Each test creates its own server with 2026-07-28 enabled. // ===================================================================== [McpServerTool(Name = "mrtr-mixed")] @@ -156,8 +156,15 @@ private static async Task MrtrMixed(McpServer server, RequestContext configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. @@ -203,7 +210,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) // Stateful path - both client modes complete all 3 rounds. await using var statefulClient = await ConnectAsync(configureClient: configureClient); - Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", + Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", statefulClient.NegotiatedProtocolVersion); var result = await statefulClient.CallToolAsync("mrtr-mixed", @@ -279,7 +286,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) await app.StartAsync(TestContext.Current.CancellationToken); Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); @@ -288,7 +295,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) { // MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second // call throws InvalidOperationException, which the tool catches and returns as text. - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", cancellationToken: TestContext.Current.CancellationToken); @@ -356,7 +363,7 @@ public async Task Mrtr_Roots_CompletesViaMrtr() app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots", cancellationToken: TestContext.Current.CancellationToken); @@ -418,7 +425,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); @@ -442,7 +449,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) if (experimentalClient) { - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); messageTracker.AssertMrtrUsed(); } else @@ -464,10 +471,10 @@ public async Task Mrtr_IsMrtrSupported(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); - Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-check", cancellationToken: TestContext.Current.CancellationToken); @@ -531,7 +538,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() await using var client = await ConnectAsync(configureClient: options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; options.Handlers.ElicitationHandler = async (request, ct) => { elicitCalled.TrySetResult(); @@ -558,7 +565,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() }; }; }); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-concurrent-three", cancellationToken: TestContext.Current.CancellationToken); @@ -587,7 +594,7 @@ public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-loadshed", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 6be82aec0..65a149e0d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -31,7 +31,7 @@ private async Task StartAsync() Name = nameof(MrtrProtocolTests), Version = "1", }; - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }).WithTools([ McpServerTool.Create( async (string message, McpServer server, CancellationToken ct) => @@ -229,9 +229,9 @@ public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() [Fact] public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() { - // Configure a server that does NOT pin DRAFT-2026-v1 so it can negotiate the current + // 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 - // negotiated version is not DRAFT-2026-v1. + // negotiated version is not 2026-07-28. Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation @@ -395,7 +395,7 @@ private Task PostJsonRpcAsync(string json) { var content = JsonContent(json); - // DRAFT-2026-v1 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. + // 2026-07-28 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. // Parse the body to derive them and attach to this request only. var bodyNode = JsonNode.Parse(json); if (bodyNode is JsonObject obj) @@ -444,7 +444,7 @@ private string CallTool(string toolName, string arguments = "{}") => private async Task InitializeWithMrtrAsync() { var initJson = """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} """; using var response = await PostJsonRpcAsync(initJson); @@ -453,7 +453,7 @@ private async Task InitializeWithMrtrAsync() // Verify the server negotiated to the experimental version var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue(); - Assert.Equal("DRAFT-2026-v1", protocolVersion); + Assert.Equal("2026-07-28", protocolVersion); var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); @@ -461,7 +461,7 @@ private async Task InitializeWithMrtrAsync() // Set the MCP-Protocol-Version header for subsequent requests HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2026-07-28"); // Reset request ID counter since initialize used ID 1 _lastRequestId = 1; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index ea4187a95..fd7795567 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -162,7 +162,7 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() // SEP-2322 (Multi Round-Trip Requests / IncompleteResult) conformance scenarios. // The csharp-sdk ConformanceServer surfaces the matching tools/prompts via // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts. - // Each scenario uses the conformance harness's RawMcpSession, which negotiates DRAFT-2026-v1 + // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28 // so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the // upstream conformance package ships with SEP-2322 scenarios // (https://github.com/modelcontextprotocol/conformance/pull/188). diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 517d41e02..963ac765f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -628,7 +628,7 @@ private async Task StartHeaderToolServer() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-test-server", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) @@ -705,7 +705,7 @@ private async Task StartHeaderCapturingServer(Dictionary capture Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 38b1ca696..685f9de20 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; @@ -743,7 +743,7 @@ public async Task DraftVersion_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"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -759,7 +759,7 @@ public async Task DraftVersion_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"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -775,7 +775,7 @@ public async Task DraftVersion_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"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -805,7 +805,7 @@ private async Task CallInitializeWithDraftVersionAndValidateAsync() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(InitializeRequestDraft); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -817,7 +817,7 @@ private async Task CallInitializeWithDraftVersionAndValidateAsync() } private static string InitializeRequestDraft => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} """; #endregion diff --git a/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs index 7c6b3840f..58ea532e7 100644 --- a/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs @@ -9,18 +9,14 @@ namespace ModelContextProtocol.Tests.Client; /// /// Tests for the draft protocol revision (SEP-2575 + SEP-2567) connection flow on /// — the client should call server/discover instead of -/// initialize when is set and -/// the server supports the requested version, and it should fall back to the legacy -/// initialize handshake otherwise. +/// initialize when is set to +/// . /// -#pragma warning disable MCPEXP002 // ExperimentalProtocolVersion public class DraftConnectionTests : ClientServerTestBase { - private const string DraftVersion = "2026-06-XX"; + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private const string LatestStableVersion = "2025-11-25"; - private bool _serverHasExperimental; - public DraftConnectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { @@ -31,20 +27,15 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.Configure(options => { options.ServerInfo = new Implementation { Name = nameof(DraftConnectionTests), Version = "1.0" }; - if (_serverHasExperimental) - { - options.ExperimentalProtocolVersion = DraftVersion; - } }); } [Fact] - public async Task DraftClient_ConnectingToDraftServer_NegotiatesExperimentalVersion() + public async Task DraftClient_ConnectingToDraftServer_NegotiatesDraftVersion() { - _serverHasExperimental = true; StartServer(); - var options = new McpClientOptions { ExperimentalProtocolVersion = DraftVersion }; + var options = new McpClientOptions { ProtocolVersion = DraftVersion }; await using var client = await CreateMcpClientForServer(options); Assert.Equal(DraftVersion, client.NegotiatedProtocolVersion); @@ -52,23 +43,9 @@ public async Task DraftClient_ConnectingToDraftServer_NegotiatesExperimentalVers Assert.Equal(nameof(DraftConnectionTests), client.ServerInfo.Name); } - [Fact] - public async Task DraftClient_ConnectingToLegacyServer_FallsBackToLegacyInitialize() - { - _serverHasExperimental = false; - StartServer(); - - var options = new McpClientOptions { ExperimentalProtocolVersion = DraftVersion }; - await using var client = await CreateMcpClientForServer(options); - - Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion); - Assert.Equal(LatestStableVersion, client.NegotiatedProtocolVersion); - } - [Fact] public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion() { - _serverHasExperimental = true; StartServer(); await using var client = await CreateMcpClientForServer(); @@ -77,11 +54,10 @@ public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion() } [Fact] - public async Task LegacyClient_CanCallServerDiscover_EvenWithoutDraftConfigured() + public async Task LegacyClient_CanCallServerDiscover() { // server/discover is registered unconditionally, so a legacy client can probe it // (e.g., to learn capabilities without doing a second initialize). - _serverHasExperimental = false; StartServer(); await using var client = await CreateMcpClientForServer(); @@ -98,9 +74,8 @@ public async Task LegacyClient_CanCallServerDiscover_EvenWithoutDraftConfigured( } [Fact] - public async Task DraftServer_DiscoverIncludesExperimentalVersion() + public async Task DraftServer_DiscoverIncludesDraftVersion() { - _serverHasExperimental = true; StartServer(); await using var client = await CreateMcpClientForServer(); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 749ef51eb..9997f6c70 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -590,9 +590,9 @@ public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) [Fact] public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol() { - Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1"; - await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" }); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Server.ServerOptions.ProtocolVersion = "2026-07-28"; + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2026-07-28" }); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 83f9e610f..3dd944ce5 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -22,7 +22,7 @@ public void McpErrorCode_HeaderMismatch_HasCorrectValue() } [Theory] - [InlineData("DRAFT-2026-v1", true)] + [InlineData("2026-07-28", true)] [InlineData("2025-11-25", false)] [InlineData("2025-06-18", false)] [InlineData("2024-11-05", false)] diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 7417f19cc..2307533a5 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -95,14 +95,14 @@ public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCal // input resolution failures back to the server. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { throw new InvalidOperationException("Client-side elicitation failure"); }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); // The client handler throws during input resolution, so the exception // escapes ResolveInputRequestAsync and surfaces directly to the caller. @@ -130,7 +130,7 @@ public async Task SendMessageAsync_WithJsonRpcRequest_ThrowsAlways() // SendMessageAsync should throw InvalidOperationException if the message is a // JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -155,7 +155,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); clientOptions.Handlers.SamplingHandler = (request, progress, ct) => @@ -193,7 +193,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() Id = discoverRequest.Id, Result = JsonSerializer.SerializeToNode(new DiscoverResult { - SupportedVersions = new List { "DRAFT-2026-v1" }, + SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), @@ -202,7 +202,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() // Client is now connected with MRTR negotiated (no initialized notification under draft). await using var client = await clientTask; - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); // Now simulate the non-compliant server sending a legacy elicitation/create request var legacyRequest = new JsonRpcRequest @@ -250,7 +250,7 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Client does NOT set DRAFT-2026-v1 - standard protocol only + // Client does NOT set 2026-07-28 - standard protocol only var clientOptions = new McpClientOptions(); clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult @@ -428,7 +428,7 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - // Initialize handshake - negotiate DRAFT-2026-v1 so the client treats InputRequiredResult as MRTR. + // Initialize handshake - negotiate 2026-07-28 so the client treats InputRequiredResult as MRTR. var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); Assert.NotNull(initLine); var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); @@ -440,7 +440,7 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro Id = initRequest.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ServerCapabilities { Tools = new() }, ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } }, McpJsonUtilities.DefaultOptions), @@ -451,7 +451,7 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro Assert.NotNull(initializedLine); await using var client = await clientTask; - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var cancellationToken = TestContext.Current.CancellationToken; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 1956887ac..6790489c3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -79,7 +79,7 @@ private async Task AssertNoMatchAsync( // moves to the standard JSON-RPC code see -32602 (McpErrorCode.InvalidParams). [Theory] [InlineData("2025-11-25", McpErrorCode.ResourceNotFound)] - [InlineData("DRAFT-2026-v1", McpErrorCode.InvalidParams)] + [InlineData("2026-07-28", McpErrorCode.InvalidParams)] public async Task ResourceNotFound_ErrorCode_IsVersionGated(string serverProtocolVersion, McpErrorCode expectedCode) { var resource = McpServerResource.Create( diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs index 23921b670..48bbae157 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -16,7 +16,7 @@ public static void DiscoverRequestParams_SerializationRoundTrip_WithMeta() { Meta = new JsonObject { - [NotificationMethods.ProtocolVersionMetaKey] = "2026-06-XX", + [NotificationMethods.ProtocolVersionMetaKey] = "2026-07-28", [NotificationMethods.ClientInfoMetaKey] = new JsonObject { ["name"] = "test-client", @@ -31,7 +31,7 @@ public static void DiscoverRequestParams_SerializationRoundTrip_WithMeta() Assert.NotNull(deserialized); Assert.NotNull(deserialized.Meta); - Assert.Equal("2026-06-XX", (string)deserialized.Meta[NotificationMethods.ProtocolVersionMetaKey]!); + Assert.Equal("2026-07-28", (string)deserialized.Meta[NotificationMethods.ProtocolVersionMetaKey]!); } [Fact] @@ -39,7 +39,7 @@ public static void DiscoverResult_SerializationRoundTrip_PreservesAllProperties( { var original = new DiscoverResult { - SupportedVersions = new List { "2025-11-25", "2026-06-XX" }, + SupportedVersions = new List { "2025-11-25", "2026-07-28" }, Capabilities = new ServerCapabilities { Tools = new ToolsCapability { ListChanged = true }, @@ -52,7 +52,7 @@ public static void DiscoverResult_SerializationRoundTrip_PreservesAllProperties( var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - Assert.Equal(["2025-11-25", "2026-06-XX"], deserialized.SupportedVersions); + Assert.Equal(["2025-11-25", "2026-07-28"], deserialized.SupportedVersions); Assert.NotNull(deserialized.Capabilities.Tools); Assert.True(deserialized.Capabilities.Tools.ListChanged); Assert.Equal("test-server", deserialized.ServerInfo.Name); @@ -64,7 +64,7 @@ public static void DiscoverResult_SerializationRoundTrip_WithMinimalProperties() { var original = new DiscoverResult { - SupportedVersions = new List { "2026-06-XX" }, + SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "minimal-server", Version = "1.0" }, }; @@ -74,7 +74,7 @@ public static void DiscoverResult_SerializationRoundTrip_WithMinimalProperties() Assert.NotNull(deserialized); Assert.Single(deserialized.SupportedVersions); - Assert.Equal("2026-06-XX", deserialized.SupportedVersions[0]); + Assert.Equal("2026-07-28", deserialized.SupportedVersions[0]); Assert.Null(deserialized.Instructions); } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs index 092ebc624..fd82f9e0b 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs @@ -14,7 +14,7 @@ public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_Pr var original = new UnsupportedProtocolVersionErrorData { Supported = new List { "2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25" }, - Requested = "2026-06-XX", + Requested = "2026-07-28", }; var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); @@ -23,7 +23,7 @@ public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_Pr Assert.NotNull(deserialized); Assert.Equal(4, deserialized.Supported.Count); Assert.Contains("2025-11-25", deserialized.Supported); - Assert.Equal("2026-06-XX", deserialized.Requested); + Assert.Equal("2026-07-28", deserialized.Requested); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs index e61b9b055..597d242e9 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs @@ -23,7 +23,7 @@ public static void SubscriptionsListenRequestParams_SerializationRoundTrip_Prese }, Meta = new JsonObject { - [NotificationMethods.ProtocolVersionMetaKey] = "2026-06-XX", + [NotificationMethods.ProtocolVersionMetaKey] = "2026-07-28", [NotificationMethods.LogLevelMetaKey] = "info", }, }; @@ -37,7 +37,7 @@ public static void SubscriptionsListenRequestParams_SerializationRoundTrip_Prese Assert.True(deserialized.Notifications.ResourcesListChanged); Assert.NotNull(deserialized.Notifications.ResourceSubscriptions); Assert.Equal(["file:///foo.txt", "file:///bar.txt"], deserialized.Notifications.ResourceSubscriptions); - Assert.Equal("2026-06-XX", (string)deserialized.Meta![NotificationMethods.ProtocolVersionMetaKey]!); + Assert.Equal("2026-07-28", (string)deserialized.Meta![NotificationMethods.ProtocolVersionMetaKey]!); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs index 662ffdb27..ca6134a24 100644 --- a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs @@ -9,15 +9,15 @@ namespace ModelContextProtocol.Tests.Server; /// Verifies that the server-to-client request methods (, /// , /// ) keep working when the negotiated protocol revision is -/// DRAFT-2026-v1 on a stateful session - for example, stdio. +/// 2026-07-28 on a stateful session - for example, stdio. /// /// -/// Under DRAFT-2026-v1 the spec removes the corresponding server-to-client request methods, but +/// Under 2026-07-28 the spec removes the corresponding server-to-client request methods, but /// the SDK only fails fast in stateless mode (where the existing ThrowIf*Unsupported guards already /// throw "X is not supported in stateless mode" because is /// ). Stdio is implicitly stateful - one per process - so the /// legacy elicitation/create / sampling/createMessage / roots/list flow still works. -/// A future PR is expected to force DRAFT-2026-v1 Streamable HTTP servers to stateless mode, at which +/// A future PR is expected to force 2026-07-28 Streamable HTTP servers to stateless mode, at which /// point those configurations will start throwing through the existing stateless guard. /// public sealed class DraftProtocolBackcompatTests : ClientServerTestBase @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }); mcpServerBuilder.WithTools([ @@ -47,7 +47,7 @@ public async Task ElicitAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability(), @@ -69,7 +69,7 @@ public async Task SampleAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Sampling = new SamplingCapability(), @@ -96,7 +96,7 @@ public async Task RequestRootsAsync_OnStatefulDraftSession_ResolvesViaLegacyRequ StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Roots = new RootsCapability(), diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs index 9a408ce78..d4839f58a 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -191,7 +191,7 @@ public async Task CallToolAsync_CancellationDuringMrtrRetry_ThrowsOperationCance StartServer(); var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { // Cancel the token during the callback. The retry loop will throw @@ -219,7 +219,7 @@ public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() StartServer(); var elicitHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = async (request, ct) => { // Signal that the MRTR round trip reached the client, then block indefinitely. @@ -263,7 +263,7 @@ public async Task CancellationNotification_DuringInFlightMrtrRetry_CancelsHandle // (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -301,7 +301,7 @@ public async Task CancellationNotification_ForExpiredRequestId_DoesNotAffectHand StartServer(); int elicitationCount = 0; - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { Interlocked.Increment(ref elicitationCount); @@ -345,7 +345,7 @@ public async Task DisposeAsync_WaitsForMrtrHandler_BeforeReturning() StartServer(); bool handlerCompleted = false; - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -387,7 +387,7 @@ public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() // (after resuming from ElicitAsync), the error is logged at Error level. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -417,7 +417,7 @@ public async Task IncompleteResultException_IsNotLoggedAtErrorLevel() // not an error. It should not be logged via ToolCallError at Error level. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs index 664429b13..a263e289b 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -23,7 +23,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs index fd9098734..9c83a3306 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs @@ -26,7 +26,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -73,14 +73,14 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() // When both sides are on the experimental protocol, the server should use MRTR // (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { return new ValueTask(new ElicitResult { Action = "accept" }); }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("elicit-tool", new Dictionary { ["message"] = "test" }, @@ -95,7 +95,7 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() { StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.SamplingHandler = (request, progress, ct) => { var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; @@ -107,7 +107,7 @@ public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("sample-tool", new Dictionary { ["prompt"] = "test" }, @@ -126,7 +126,7 @@ public async Task OutgoingFilter_SeesIncompleteResultResponse() var sawIncompleteResult = false; StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { // If we reach this handler, it means the client received an InputRequiredResult diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs index d8fa6f32b..14b5dd3a9 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -9,7 +9,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the legacy MRTR backcompat resolver in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync. -/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful - +/// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful - /// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler /// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks /// a non-draft version during initialize negotiation. diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs index 1836d4d13..fc9c26fc2 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs @@ -49,7 +49,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); // Outgoing filter: detect InputRequiredResult responses and track per session. @@ -131,7 +131,7 @@ public async Task OutgoingFilter_TracksIncompleteResultsPerSession() // Verify that an outgoing message filter can observe InputRequiredResult responses // and track the pending MRTR flow count per session using context.Server.SessionId. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -165,7 +165,7 @@ public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit() _maxFlowsPerSession = 0; StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); From cb239304a8d8d2fc26bfd35ac3e426c82c16cfa9 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 5 Jun 2026 16:26:26 -0700 Subject: [PATCH 04/23] Flip Stateless default to true and obsolete stateful Streamable HTTP options (MCP9005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default `HttpServerTransportOptions.Stateless` to true so new code on the 2026-07-28 draft revision (SEP-2567) is sessionless from the start. Mark the surface that only makes sense in the legacy stateful HTTP mode as obsolete behind the new MCP9005 diagnostic so callers see a deprecation hint but can still pin Stateless = false to keep using session-based behaviors during back-compat: * `HttpServerTransportOptions.EventStreamStore` (resumability) * `HttpServerTransportOptions.SessionMigrationHandler` (multi-node migration) * `HttpServerTransportOptions.PerSessionExecutionContext` * `HttpServerTransportOptions.IdleTimeout` * `HttpServerTransportOptions.MaxIdleSessionCount` Internal infrastructure that legitimately reads those options for the back-compat stateful path now suppresses MCP9005 at the use site. Test projects suppress it globally via NoWarn because the suite intentionally exercises both modes. Update tests/samples that previously relied on the implicit `Stateless = false` default to set it explicitly: * TestSseServer.Program — SSE always needs stateful state shared across GET/POST. * ConformanceServer.Program — resumability + OAuth conformance scenarios are stateful. * ResumabilityIntegrationTestsBase — resumability is a stateful concern. * SseIntegrationTests / MapMcpSseTests — SSE requires stateful. * OAuthTestBase — OAuth flow uses the GET /sse session-based endpoint. * MrtrProtocolTests / SessionMigrationTests / StreamableHttpServerConformanceTests — these tests intentionally drive the legacy stateful session machinery. * DraftHttpHandlerTests — tests draft rejection of GET/DELETE endpoints, which are only mapped when Stateless = false. Rework HTTP header conformance helpers (HttpHeaderConformanceTests + StreamableHttpServerConformanceTests) to stop asserting an mcp-session-id response header from draft/non-draft initialize, because the sessionless default means none is returned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Common/Obsoletions.cs | 4 ++++ .../HttpServerTransportOptions.cs | 16 ++++++++++++++-- .../HttpServerTransportOptionsSetup.cs | 2 ++ .../IdleTrackingBackgroundService.cs | 2 ++ .../StatefulSessionManager.cs | 2 ++ .../StreamableHttpHandler.cs | 8 ++++++++ .../DraftHttpHandlerTests.cs | 7 ++++++- .../HttpHeaderConformanceTests.cs | 11 +++++------ .../MapMcpSseTests.cs | 8 ++++---- .../ModelContextProtocol.AspNetCore.Tests.csproj | 2 ++ .../MrtrProtocolTests.cs | 4 ++-- .../OAuth/OAuthTestBase.cs | 2 +- .../ResumabilityIntegrationTestsBase.cs | 2 ++ .../SessionMigrationTests.cs | 2 +- .../SseIntegrationTests.cs | 13 +++++++------ .../StreamableHttpServerConformanceTests.cs | 6 +++--- ...ModelContextProtocol.ConformanceServer.csproj | 1 + .../Program.cs | 7 ++++++- .../Program.cs | 8 +++++++- .../ModelContextProtocol.Tests.csproj | 2 +- 20 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 46ea782d8..bf95e3abb 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -33,4 +33,8 @@ internal static class Obsoletions public const string EnableLegacySse_DiagnosticId = "MCP9004"; public const string EnableLegacySse_Message = "Legacy SSE transport has no built-in request backpressure and should only be used with completely trusted clients in isolated processes. Use Streamable HTTP instead."; public const string EnableLegacySse_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; + + public const string LegacyStatefulHttp_DiagnosticId = "MCP9005"; + public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; + public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 648cb86df..8a09be900 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -50,7 +50,9 @@ public class HttpServerTransportOptions /// allowing for load balancing without session affinity. /// /// - /// if the server runs in a stateless mode; if the server tracks state between requests. The default is . + /// if the server runs in a stateless mode; if the server tracks state between requests. + /// The default is as of the 2026-07-28 draft protocol revision (SEP-2567); + /// set to only when you need to support legacy clients that rely on session affinity. /// /// /// If , will be null, and the "MCP-Session-Id" header will not be used, @@ -58,8 +60,13 @@ public class HttpServerTransportOptions /// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses /// might arrive at another ASP.NET Core application process. /// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests. + /// + /// Requests that declare the 2026-07-28 draft protocol revision via the MCP-Protocol-Version header + /// are always routed through the stateless path regardless of this property's value, because that revision + /// removes Mcp-Session-Id entirely (SEP-2567). + /// /// - public bool Stateless { get; set; } + public bool Stateless { get; set; } = true; /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) @@ -112,6 +119,7 @@ public class HttpServerTransportOptions /// If this property is not set, the server will attempt to resolve an from DI. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISseEventStreamStore? EventStreamStore { get; set; } /// @@ -128,6 +136,7 @@ public class HttpServerTransportOptions /// If this property is not set, the server will attempt to resolve an from DI. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISessionMigrationHandler? SessionMigrationHandler { get; set; } /// @@ -144,6 +153,7 @@ public class HttpServerTransportOptions /// Enabling a per-session can be useful for setting variables /// that persist for the entire session, but it prevents you from using IHttpContextAccessor in handlers. /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public bool PerSessionExecutionContext { get; set; } /// @@ -162,6 +172,7 @@ public class HttpServerTransportOptions /// tied to the open GET /sse request, and they are removed immediately when the client disconnects. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2); /// @@ -182,6 +193,7 @@ public class HttpServerTransportOptions /// exactly as long as the SSE connection is open. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public int MaxIdleSessionCount { get; set; } = 10_000; /// diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs index b4ce545f8..00b03e4d2 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs @@ -12,7 +12,9 @@ internal sealed class HttpServerTransportOptionsSetup(IServiceProvider servicePr { public void Configure(HttpServerTransportOptions options) { +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. options.EventStreamStore ??= serviceProvider.GetService(); options.SessionMigrationHandler ??= serviceProvider.GetService(); +#pragma warning restore MCP9005 } } diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index d68f83e5d..6ea99df75 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -18,12 +18,14 @@ public IdleTrackingBackgroundService( ILogger logger) { // Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown. +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (options.Value.IdleTimeout != Timeout.InfiniteTimeSpan) { ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero); } ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0); +#pragma warning restore MCP9005 _sessions = sessions; _options = options; diff --git a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs index 880bd04a5..da542b804 100644 --- a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs +++ b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs @@ -17,9 +17,11 @@ internal sealed partial class StatefulSessionManager( private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider; +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout; private readonly long _idleTimeoutTicks = GetIdleTimeoutInTimestampTicks(httpServerTransportOptions.Value.IdleTimeout, httpServerTransportOptions.Value.TimeProvider); private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount; +#pragma warning restore MCP9005 private readonly object _idlePruningLock = new(); private readonly List _idleTimestamps = []; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 87732bdf8..00f9343ef 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -307,10 +307,12 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask TryMigrateSessionAsync(HttpContext context, string sessionId) { +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (HttpServerTransportOptions.SessionMigrationHandler is not { } handler) { return null; } +#pragma warning restore MCP9005 var migrationLock = _migrationLocks.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1)); await migrationLock.WaitAsync(context.RequestAborted); @@ -414,6 +416,7 @@ private async ValueTask StartNewSessionAsync(HttpContext if (!isStateless) { sessionId = MakeNewSessionId(); +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. transport = new(loggerFactory) { SessionId = sessionId, @@ -423,6 +426,7 @@ private async ValueTask StartNewSessionAsync(HttpContext ? (initParams, ct) => handler.OnSessionInitializedAsync(context, sessionId, initParams, ct) : null, }; +#pragma warning restore MCP9005 context.Response.Headers[McpSessionIdHeaderName] = sessionId; } @@ -493,8 +497,10 @@ private async ValueTask MigrateSessionAsync( var transport = new StreamableHttpServerTransport(loggerFactory) { SessionId = sessionId, +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext, EventStreamStore = HttpServerTransportOptions.EventStreamStore, +#pragma warning restore MCP9005 }; // Initialize the transport with the migrated session's init params. @@ -511,7 +517,9 @@ private async ValueTask MigrateSessionAsync( private async ValueTask GetEventStreamReaderAsync(HttpContext context, string lastEventId) { +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (HttpServerTransportOptions.EventStreamStore is not { } eventStreamStore) +#pragma warning restore MCP9005 { await WriteJsonRpcErrorAsync(context, "Bad Request: This server does not support resuming streams.", diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs index 51ffb9b88..4d83a410e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs @@ -24,7 +24,12 @@ private async Task StartAsync() Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" }; - }).WithHttpTransport(); + }).WithHttpTransport(options => + { + // Map the GET/DELETE endpoints so we can exercise the draft-mode rejection paths + // (these endpoints are not registered in stateless mode, which is the new default). + options.Stateless = false; + }); _app = Builder.Build(); _app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 50e4ab79c..73c23807b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -437,9 +437,9 @@ private async Task InitializeWithDraftVersionAsync() using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + // Draft protocol revision (SEP-2567) is sessionless: the server does not return a + // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 + // to route through the sessionless path. } private async Task InitializeWithNonDraftVersionAsync() @@ -449,9 +449,8 @@ private async Task InitializeWithNonDraftVersionAsync() using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + // Server is stateless by default (SEP-2567), so initializing with the non-draft protocol does not return + // a mcp-session-id header. Subsequent requests are independent, just like the draft path. } private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs index b796d78c2..05f9bdff3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -21,7 +21,7 @@ protected override void ConfigureStateless(HttpServerTransportOptions options) [InlineData("/mcp/secondary")] public async Task Allows_Customizing_Route(string pattern) { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(pattern); @@ -53,7 +53,7 @@ public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePat Name = "TestCustomRouteServer", Version = "1.0.0", }; - }).WithHttpTransport(options => options.EnableLegacySse = true); + }).WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(routePattern); @@ -83,7 +83,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_InSseMode() return "Complete"; }, options: new() { Name = "polling_tool" }); - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true).WithTools([pollingTool]); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }).WithTools([pollingTool]); await using var app = Builder.Build(); app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index acdcfa456..320acce6f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -7,6 +7,8 @@ false true ModelContextProtocol.AspNetCore.Tests + + $(NoWarn);MCP9005 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 65a149e0d..65cd8909b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -56,7 +56,7 @@ private async Task StartAsync() Name = "throwing-tool", Description = "A tool that throws immediately" }), - ]).WithHttpTransport(); + ]).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); _app.MapMcp(); @@ -262,7 +262,7 @@ static string (RequestContext context) => Name = "backcompat-roots-tool", Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", }), - ]).WithHttpTransport(); + ]).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); _app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 3c1919b0b..f9a4b64c0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -62,7 +62,7 @@ protected OAuthTestBase(ITestOutputHelper outputHelper, bool configureMcpMetadat }); Builder.Services.AddAuthorization(); - Builder.Services.AddMcpServer().WithHttpTransport(); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.Stateless = false); } public async ValueTask DisposeAsync() diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 9738ffda3..b64f12d95 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -490,6 +490,8 @@ protected async Task CreateServerAsync( var serverBuilder = Builder.Services.AddMcpServer() .WithHttpTransport(options => { + // Resumability is a stateful concern; pin Stateless = false now that the new default is true. + options.Stateless = false; options.EventStreamStore = eventStreamStore; configureTransport?.Invoke(options); }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs index a06a5d129..7609e8215 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs @@ -222,7 +222,7 @@ private async Task StartAsync(ISessionMigrationHandler? migrationHandler = null) Name = "SessionMigrationTestServer", Version = "1.0.0", }; - }).WithTools(Tools).WithHttpTransport(); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); if (migrationHandler is not null) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs index 800a6ce96..bd47bdb74 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -31,7 +31,7 @@ private Task ConnectMcpClientAsync(HttpClient? httpClient = null, Htt [Fact] public async Task ConnectAndReceiveMessage_InMemoryServer() { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -84,6 +84,7 @@ public async Task ConnectAndReceiveNotification_InMemoryServer() .WithHttpTransport(httpTransportOptions => { httpTransportOptions.EnableLegacySse = true; + httpTransportOptions.Stateless = false; #pragma warning disable MCPEXP002 // RunSessionHandler is experimental httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => { @@ -128,7 +129,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() { firstOptionsCallbackCallCount++; }) - .WithHttpTransport(options => options.EnableLegacySse = true) + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }) .WithTools(); Builder.Services.AddMcpServer(options => @@ -172,7 +173,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); @@ -219,7 +220,7 @@ public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() public async Task EmptyAdditionalHeadersKey_Throws_InvalidOperationException() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); @@ -311,7 +312,7 @@ private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints, b [Fact] public async Task Completion_ServerShutdown_ReturnsHttpCompletionDetails() { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 685f9de20..f4009de11 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -36,7 +36,7 @@ private async Task StartAsync() Name = nameof(StreamableHttpServerConformanceTests), Version = "73", }; - }).WithTools(Tools).WithHttpTransport(); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); @@ -812,8 +812,8 @@ private async Task CallInitializeWithDraftVersionAndValidateAsync() var rpcResponse = await AssertSingleSseResponseAsync(response); AssertServerInfo(rpcResponse); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - SetSessionId(sessionId); + // Draft protocol revision (SEP-2567) is sessionless; the server does not return mcp-session-id. + // Subsequent requests carry MCP-Protocol-Version=2026-07-28 to opt back into the draft path. } private static string InitializeRequestDraft => """ diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index 15b2c87f2..de1aee820 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -5,6 +5,7 @@ enable enable Exe + $(NoWarn);MCP9005 diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index f30d58a4d..3a6ed6e51 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -28,7 +28,12 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide builder.Services.AddDistributedMemoryCache(); builder.Services .AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => + { + // ConformanceTests rely on stateful behaviors (resumability, session-scoped subscriptions, OAuth). + // Pin Stateless = false explicitly now that draft (SEP-2567) defaults to true. + options.Stateless = false; + }) .WithDistributedCacheEventStreamStore() .WithTools() .WithTools() diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index e52a8ff1f..8cce9a278 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -459,7 +459,13 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide } builder.Services.AddMcpServer(ConfigureOptions) - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => + { + // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). + // Set Stateless = false explicitly now that draft (SEP-2567) defaults to true. + options.Stateless = false; + options.EnableLegacySse = true; + }); var app = builder.Build(); diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 7f7de2a41..2b6e6ee6f 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -11,7 +11,7 @@ true ModelContextProtocol.Tests - $(NoWarn);NU1903;NU1902 + $(NoWarn);NU1903;NU1902;MCP9005 $(DefineConstants);MCP_TEST_TIME_PROVIDER From 04da5c8ccfcdfb4ac0ac1b61ce893385bc404839 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 5 Jun 2026 17:10:24 -0700 Subject: [PATCH 05/23] Add fallback negotiation, MinProtocolVersion, server-side _meta protocolVersion validation, and raw stream conformance tests Phase 2-5 of the draft (2026-07-28) rollout: - McpClientImpl.ConnectAsync: tighten the draft probe fallback to match the spec's stdio rules. Apply a 5-second probe timeout (bounded by InitializationTimeout), broaden the catch to treat any McpProtocolException OR probe-timeout as a legacy-server signal, and special-case the two modern-server JSON-RPC errors (-32004 retries with supported[]; -32003 surfaces). Honor MinProtocolVersion before falling back to legacy initialize. - McpClientOptions: add MinProtocolVersion public string? with XML docs. Setting this to McpSessionHandler.DraftProtocolVersion disables the automatic legacy fallback. - McpServerImpl.CreateDraftStateSyncFilter: reject any per-request _meta/io.modelcontextprotocol/protocolVersion that is not in SupportedProtocolVersions with UnsupportedProtocolVersionException (-32004). The HTTP handler already validated the MCP-Protocol-Version header; this closes the corresponding gap for stdio/Stream and for HTTP bodies where the header is absent. - HttpTaskIntegrationTests: Tasks pin per-session state into the in-memory store, so the tests require stateful HTTP. With Stateless=true the default after Phase 1, the tests would deadlock on the second request because the task ID wasn't visible to the new stateless server invocation. Opt back into stateful mode with WithHttpTransport(options => options.Stateless = false). - New tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs: drive McpServer directly via paired Pipe streams without going through McpClient. Hand-writes JSON-RPC messages and asserts on the exact bytes the server emits. Covers server/discover -> supportedVersions[], draft tools/call without initialize, -32004 with data.supported on unsupported version, legacy initialize on the same dual-era server, and a mixed Discover->Initialize->ToolsCall sequence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/McpClientImpl.cs | 56 +++++- .../Client/McpClientOptions.cs | 31 +++ .../Server/McpServerImpl.cs | 23 ++- .../HttpTaskIntegrationTests.cs | 17 +- .../Server/RawStreamConformanceTests.cs | 179 ++++++++++++++++++ 5 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index f32d208be..66af13c60 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -686,6 +686,18 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) DiscoverResult? discoverResult = null; bool fallbackToLegacy = 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). + // The probe timeout is bounded by InitializationTimeout, but we cap it at 5s so we + // can quickly fall back when a server isn't going to respond. + var probeTimeout = TimeSpan.FromSeconds(5); + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token); + if (_options.InitializationTimeout > probeTimeout) + { + probeCts.CancelAfter(probeTimeout); + } + try { discoverResult = await SendRequestAsync( @@ -693,20 +705,36 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) new DiscoverRequestParams(), McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, McpJsonUtilities.JsonContext.Default.DiscoverResult, - cancellationToken: initializationCts.Token).ConfigureAwait(false); + cancellationToken: probeCts.Token).ConfigureAwait(false); } - catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound) + catch (UnsupportedProtocolVersionException ex) { - // Server doesn't implement server/discover (likely a legacy server). Fall back - // to the legacy initialize handshake per SEP-2575 §"Supporting Multiple Versions". + // Spec-recognized modern-server signal: -32004 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. fallbackToLegacy = true; + serverSupportedVersions = (IList)ex.Supported; } - catch (UnsupportedProtocolVersionException ex) + catch (MissingRequiredClientCapabilityException) + { + // Spec-recognized modern-server signal: -32003. The server is modern but rejected + // our capability set. Surface as-is (no fallback): the user must add capabilities. + throw; + } + catch (McpProtocolException) { - // Server rejected the experimental protocol version at the transport layer. - // Per SEP-2575, fall back to a mutually-supported version reported in ex.Supported. + // Any other JSON-RPC error from the probe indicates a legacy server (e.g., + // -32601 MethodNotFound, -32602 InvalidParams from a server confused by _meta, + // -32700 ParseError from a server that can't handle our payload shape). + // Per the spec's stdio fallback rules, treat all non-modern errors as a + // legacy-server signal and fall back to the initialize handshake. + fallbackToLegacy = 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; - serverSupportedVersions = (IList)ex.Supported; } if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion)) @@ -730,6 +758,18 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) .FirstOrDefault() ?? McpSessionHandler.LatestProtocolVersion; + // Honor MinProtocolVersion: refuse to fall back below the configured minimum. + // String.Compare is the spec's prescribed ordering for ISO-8601 date-based versions. + if (_options.MinProtocolVersion is { } minVersion && + StringComparer.Ordinal.Compare(fallbackVersion, minVersion) < 0) + { + throw new McpException( + $"Server does not support the configured minimum protocol version '{minVersion}'. " + + (serverSupportedVersions is null + ? "The server appears to be a legacy server that requires the deprecated initialize handshake." + : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); + } + await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); } else diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 6d91f5b03..9548db7dd 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -48,6 +48,37 @@ public sealed class McpClientOptions /// public string? ProtocolVersion { get; set; } + /// + /// Gets or sets the minimum protocol version the client will accept during version negotiation. + /// + /// + /// + /// When negotiating with a server that advertises multiple supported versions, or when falling back + /// to a legacy server, the client will refuse any version older than this minimum and surface an + /// instead. + /// + /// + /// This is useful when the client requires features (such as the draft revision's removal of the + /// initialize handshake or Mcp-Session-Id) that are not available in older protocol + /// revisions. Setting this to disables the + /// automatic legacy-server fallback that otherwise switches to the initialize handshake. + /// + /// + /// If (the default), the client falls back to any version the server + /// advertises, including legacy versions such as 2025-11-25. + /// + /// + /// + /// var clientOptions = new McpClientOptions + /// { + /// ProtocolVersion = McpSessionHandler.DraftProtocolVersion, + /// MinProtocolVersion = McpSessionHandler.DraftProtocolVersion, + /// }; + /// + /// + /// + public string? MinProtocolVersion { get; set; } + /// /// Gets or sets a timeout for the client-server initialization handshake sequence. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f5264ced8..4eb837d6e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -172,7 +172,8 @@ private static JsonRpcMessageFilter ComposeFilters(JsonRpcMessageFilter outer, J /// /// Builds an incoming message filter that, for every JSON-RPC request, synchronizes server-side state /// (, , ) - /// from the per-request _meta values projected onto . + /// from the per-request _meta values projected onto and + /// validates the per-request protocol version. /// /// /// Under the draft protocol revision (SEP-2575) there is no initialize handshake, so these values @@ -187,11 +188,23 @@ private JsonRpcMessageFilter CreateDraftStateSyncFilter() { bool endpointNameNeedsRefresh = false; - if (context.ProtocolVersion is { } protocolVersion && - !string.Equals(_negotiatedProtocolVersion, protocolVersion, StringComparison.Ordinal)) + if (context.ProtocolVersion is { } protocolVersion) { - _negotiatedProtocolVersion = protocolVersion; - _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + // Per SEP-2575, the server MUST reject any request whose per-request + // _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions + // with an UnsupportedProtocolVersionError (-32004) carrying the supported list. + if (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: McpSessionHandler.SupportedProtocolVersions); + } + + if (!string.Equals(_negotiatedProtocolVersion, protocolVersion, StringComparison.Ordinal)) + { + _negotiatedProtocolVersion = protocolVersion; + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + } } if (context.ClientCapabilities is { } clientCapabilities) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs index 2b74fcd14..235021fae 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs @@ -48,7 +48,7 @@ public async Task CallToolAsTask_ReturnsTask_WhenServerSupportsTasksAsync() { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -81,7 +81,7 @@ public async Task GetTaskAsync_ReturnsTaskStatus_WhenTaskExistsAsync() { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -121,7 +121,7 @@ public async Task ListTasksAsync_ReturnsTasks_WhenTasksExistAsync() { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -160,7 +160,7 @@ public async Task CancelTaskAsync_CancelsTask_WhenTaskIsRunningAsync() { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -199,7 +199,7 @@ public async Task GetTaskResultAsync_ReturnsResult_WhenTaskCompletesAsync() { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -240,7 +240,7 @@ public async Task TasksIsolated_BetweenSessions_WhenMultipleClientsConnectAsync( { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -279,7 +279,7 @@ public async Task ServerCapabilities_IncludesTasks_WhenTaskStoreConfiguredAsync( { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -302,7 +302,7 @@ public async Task ListTools_ShowsTaskSupport_WhenToolIsAsyncAsync() { options.TaskStore = taskStore; }) - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = false) .WithTools(); await using var app = Builder.Build(); @@ -340,3 +340,4 @@ public static string SyncTool([Description("Input message")] string message) } } } + diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs new file mode 100644 index 000000000..d24406ec5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -0,0 +1,179 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Wire-format conformance tests for driven directly against the underlying +/// stream — without going through . This exercises the +/// SEP-2575 (sessionless / no-initialize) and SEP-2567 (server/discover) flows by hand-crafting JSON-RPC +/// messages and asserting on the exact responses the server emits. +/// +/// +/// The tests use a paired the way does, but instead +/// of constructing an McpClient we read and write JSON-RPC envelopes directly. This is the closest +/// approximation we have to a third-party / non-SDK client and is what conformance tooling will exercise. +/// +public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + private readonly Pipe _clientToServer = new(); + private readonly Pipe _serverToClient = new(); + private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly Task _serverTask; + private readonly ServiceProvider _services; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + + public RawStreamConformanceTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(XunitLoggerProvider)); + services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "raw-conformance-server", Version = "1.0.0" }; + }) + .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) + .WithTools([ + McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" }), + ]); + + _services = services.BuildServiceProvider(validateScopes: true); + var server = _services.GetRequiredService(); + _serverTask = server.RunAsync(_cts.Token); + + _writer = new StreamWriter(_clientToServer.Writer.AsStream(), new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" }; + _reader = new StreamReader(_serverToClient.Reader.AsStream(), Encoding.UTF8); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _clientToServer.Writer.Complete(); + _serverToClient.Writer.Complete(); + try { await _serverTask; } catch { /* expected on cancellation */ } + await _services.DisposeAsync(); + _cts.Dispose(); + Dispose(); + } + + private async Task SendAsync(string json) => await _writer.WriteLineAsync(json); + + private async Task ReadAsync() + { + var line = await _reader.ReadLineAsync(_cts.Token); + Assert.NotNull(line); + return JsonNode.Parse(line!)!; + } + + private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; + + [Fact] + public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft() + { + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + + var response = await ReadAsync(); + Assert.Equal("2.0", response["jsonrpc"]!.GetValue()); + Assert.Equal(1, response["id"]!.GetValue()); + + var result = response["result"]; + Assert.NotNull(result); + + var supportedVersions = result!["supportedVersions"]!.AsArray() + .Select(n => n!.GetValue()) + .ToList(); + Assert.Contains(DraftVersion, supportedVersions); + + // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. + Assert.NotNull(result["capabilities"]); + Assert.NotNull(result["serverInfo"]); + Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue()); + } + + [Fact] + public async Task DraftToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided() + { + // Spec: under SEP-2575 the client may skip server/discover and go straight to a normal RPC, as long + // as every request carries the full _meta envelope with protocolVersion, clientInfo and capabilities. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hello""}," + + DraftMetaFragment() + "}}"); + + var response = await ReadAsync(); + Assert.Equal(42, response["id"]!.GetValue()); + var result = response["result"]; + Assert.NotNull(result); + var content = result!["content"]!.AsArray(); + Assert.Single(content); + Assert.Equal("echo:hello", content[0]!["text"]!.GetValue()); + } + + [Fact] + public async Task DraftRequest_WithUnsupportedProtocolVersion_ReturnsMinus32004WithSupported() + { + // Server should respond with UnsupportedProtocolVersionError (-32004) and a data.supported[] list. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":7,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + DraftMetaFragment("9999-99-99") + "}}"); + + var response = await ReadAsync(); + Assert.Equal(7, response["id"]!.GetValue()); + var error = response["error"]; + Assert.NotNull(error); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error!["code"]!.GetValue()); + + var data = error["data"]; + Assert.NotNull(data); + Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); + var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + } + + [Fact] + public async Task LegacyInitialize_StillWorks_OnDraftDefaultServer() + { + // Dual-era: a draft-default server (ProtocolVersion = DraftVersion 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""}}}"); + + var response = await ReadAsync(); + Assert.Equal(1, response["id"]!.GetValue()); + var result = response["result"]; + Assert.NotNull(result); + Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue()); + } + + [Fact] + public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() + { + // Dual-era servers must accept draft 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. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + 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""}}}"); + var init = await ReadAsync(); + Assert.NotNull(init["result"]); + Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue()); + + await SendAsync(@"{""jsonrpc"":""2.0"",""method"":""notifications/initialized"",""params"":{}}"); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""after-init""}}}"); + var call = await ReadAsync(); + Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); + } +} From 30782f6102ba03be2fd50fbccc2a4d332f024619 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 5 Jun 2026 17:20:40 -0700 Subject: [PATCH 06/23] Add raw HTTP conformance tests + document MCP9005 Phase 5b + 6 of the SEP-2575/SEP-2567 work. - tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs: 5 new tests that drive the C# server directly with hand-crafted HttpClient requests, no McpClient involvement. Covers draft tools/call with full _meta, server/discover, -32004 on unsupported MCP-Protocol-Version, legacy initialize on a default (stateless+draft) server, and GET returning 405 when not stateful. - docs/list-of-diagnostics.md: add MCP9005 row describing the stateful Streamable HTTP options as back-compat-only knobs since the draft revision is sessionless by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/list-of-diagnostics.md | 1 + .../RawHttpConformanceTests.cs | 189 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 0ea6d746f..d3555ffe2 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -38,3 +38,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | +| `MCP9005` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. The draft protocol revision (`2026-07-28`) is sessionless, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs new file mode 100644 index 000000000..b39873790 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Wire-format conformance tests for the Streamable HTTP server driven directly via , +/// without going through . These hand-craft HTTP +/// requests and assert the exact status codes / response bodies the server emits for the SEP-2575 + +/// SEP-2567 (sessionless, no-initialize) draft revision. +/// +public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private const string ProtocolVersionHeader = "MCP-Protocol-Version"; + + private WebApplication? _app; + + private async Task StartAsync() + { + Builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; + }) + .WithHttpTransport() + .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + + /// + /// Reads either a direct JSON response or a single SSE message containing JSON-RPC and returns the + /// parsed JsonNode. The Streamable HTTP server can return either content type depending on negotiation; + /// raw HttpClient tests should accept either. + /// + private static async Task ReadJsonResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var contentType = response.Content.Headers.ContentType?.MediaType; + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (contentType == "text/event-stream") + { + // Pull the first non-empty data: line out of the SSE payload. + foreach (var line in body.Split('\n')) + { + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + var data = line.Substring("data:".Length).Trim(); + if (data.Length > 0) + { + return JsonNode.Parse(data)!; + } + } + } + throw new InvalidOperationException("SSE response did not contain a JSON data event. Body: " + body); + } + + return JsonNode.Parse(body)!; + } + + private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; + + [Fact] + public async Task DraftToolsCall_WithFullMeta_Succeeds_200() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," + + DraftMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, DraftVersion); + 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.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue()); + + // Per SEP-2567 draft is sessionless: server MUST NOT issue a Mcp-Session-Id. + Assert.False(response.Headers.Contains("mcp-session-id")); + } + + [Fact] + public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, DraftVersion); + 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.Contains(DraftVersion, supported); + } + + [Fact] + public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32004() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + DraftMetaFragment("9999-99-99") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, "9999-99-99"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32004 and a data payload + // listing the supported versions. The dual-era 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()); + + var data = json["error"]!["data"]; + Assert.NotNull(data); + Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); + var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + } + + [Fact] + public async Task LegacyInitialize_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""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue()); + } + + [Fact] + public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405() + { + await StartAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.Accept.Add(new("text/event-stream")); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE + // stream is replaced by subscriptions/listen POST requests. Existing routing in + // McpEndpointRouteBuilderExtensions only maps GET when Stateless == false. + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } +} + From fb9225ed5a8ef7c0f777dcf7495cc52d44da69ea Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 5 Jun 2026 18:10:35 -0700 Subject: [PATCH 07/23] Expose DraftProtocolVersion publicly and update stateless concept doc Closes the doc gap that was left dangling in the prior commits. - src/ModelContextProtocol.Core/McpSession.cs: add public 'LatestProtocolVersion' and 'DraftProtocolVersion' constants so user code can opt into a specific revision without typing string literals. The two constants forward to the existing internal values on 'McpSessionHandler'. - src/ModelContextProtocol.Core/Client/McpClientOptions.cs: retarget XML cref + example to 'McpSession.DraftProtocolVersion' (the new public constant) instead of the internal 'McpSessionHandler.DraftProtocolVersion'. - docs/concepts/stateless/stateless.md: flip every stale 'Stateless = false is the default' claim, rewrite the 'Why isn't stateless the default?' note, mark MRTR as merged (no longer 'proposed'), update the property reference table with 'true' default and 'MCP9005' callouts on stateful-only knobs, and add a new 'The 2026-07-28 draft revision' subsection covering wire-level changes, server stateless routing, client probe-and-fallback negotiation (HTTP + stdio), and 'MinProtocolVersion' opt-out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/stateless/stateless.md | 67 +++++++++++++------ .../Client/McpClientOptions.cs | 6 +- src/ModelContextProtocol.Core/McpSession.cs | 20 ++++++ 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 2732c9dcd..53622a6e3 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -9,7 +9,7 @@ uid: stateless The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests _to_ the client, push [unsolicited notifications](#how-streamable-http-delivers-messages), or maintain per-client state across requests. -When sessions are enabled (the current C# SDK default), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. 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 — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). +When sessions are enabled (`Stateless = false`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. 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 — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). [Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http @@ -24,19 +24,44 @@ When sessions are enabled (the current C# SDK default), the server creates and t > [!NOTE] -> **Why isn't stateless the C# SDK default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features — see [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing an explicit setting. +> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful for back-compat with the `2025-11-25` (and older) protocol revisions, which require the `Mcp-Session-Id` header. The `2026-07-28` draft revision removes that header (SEP-2567) and the `initialize` handshake (SEP-2575) entirely, so the SDK now defaults to `true` to match the new wire format. You can still opt back into sessions with `Stateless = false` to keep using legacy-protocol features like server-to-client requests (sampling, elicitation, roots), unsolicited notifications, or per-client isolation — see [Stateful mode (sessions)](#stateful-mode-sessions). ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The current C# SDK default is `Stateless = false` (sessions enabled), but **we expect this default to change** once mechanisms like [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) bring server-to-client interactions (sampling, elicitation, roots) to stateless mode. 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` draft 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 ([sampling](xref:sampling), [elicitation](xref:elicitation), [roots](xref:roots), [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions). [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) provides a sessionless alternative for sampling, elicitation, and roots. We recommend every server set `Stateless` explicitly rather than relying on the default: -- **`Stateless = true`** — the best forward-compatible choice. Your server opts out of sessions entirely. No matter how the SDK default changes in the future, your behavior stays the same. 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 honored. The `2026-07-28` draft 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 draft 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 = false`** — the right choice when your server depends on sessions for features like sampling, elicitation, roots, unsolicited notifications, or per-client isolation. Setting this explicitly protects your server from a future default change. 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 will always honor your server's session. Once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or a similar mechanism is available, you may be able to migrate server-to-client interactions to stateless mode and drop sessions entirely — but until then, explicit `Stateless = false` is the safe choice. See [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions) for more on MRTR. +- **`Stateless = false`** — the right choice when your server depends on sessions for features like sampling, elicitation, roots, unsolicited notifications, or per-client isolation. Setting this explicitly protects your server from a future default change. 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 will always honor your server's session. With [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) now merged, sampling, elicitation, and roots are also available to stateless servers — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). Unsolicited notifications and resource subscriptions still require sessions. Note that even with `Stateless = false`, draft (`2026-07-28`) requests are still served sessionlessly because the protocol forbids the session header — the stateful path activates only when a client falls back to a legacy revision. > [!TIP] -> If you're not sure which to pick, start with `Stateless = true`. You can switch to `Stateless = false` later if you discover you need server-to-client requests or unsolicited notifications. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. +> If you're not sure which to pick, leave the default (`Stateless = true`). You can switch to `Stateless = false` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. + +### The 2026-07-28 draft revision + +The `2026-07-28` draft 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 the draft on the wire. Any HTTP POST that arrives with the draft `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 draft requests on a stateful server are still served sessionlessly because the protocol forbids the session header. + +**Stateful options marked obsolete.** Because the draft revision is unconditionally sessionless, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9005` 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. + +**Client side — automatic fallback.** Clients automatically probe the draft revision first and fall back to the `initialize` handshake when the server doesn't support it: + +- **HTTP**: the client sends its first request with the draft `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32004` / `-32003` / `-32001` 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 the draft revision; a `-32004` 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. + +The era is cached per instance, so the probe cost is paid only on the first connect. + +**Opting out of fallback.** Set to when you want the client to refuse to fall back. The connect call throws an instead of silently degrading. This is useful for strict-modern production code and for tests that need to assert draft-only behavior. + +```csharp +var clientOptions = new McpClientOptions +{ + ProtocolVersion = McpSession.DraftProtocolVersion, + MinProtocolVersion = McpSession.DraftProtocolVersion, +}; +``` ### Migrating from legacy SSE @@ -93,7 +118,7 @@ When - [Roots](xref:roots) (`RequestRootsAsync`) - Ping — the server cannot ping the client to verify connectivity - The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to bring these capabilities to stateless mode, but it is not yet available. + [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) brings sampling, elicitation, and roots to stateless mode when both client and server opt in — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). - **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. - **No concurrent client isolation.** Every request is independent — the server cannot distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client. - **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means. @@ -115,17 +140,13 @@ Most MCP servers fall into this category. Tools that call APIs, query databases, ### Stateless alternatives for server-to-client interactions - -> [!NOTE] -> Multi Round-Trip Requests (MRTR) is a proposed experimental feature that is not yet available. See PR [#1458](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for the reference implementation and specification proposal. +The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is a sessionless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. -The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is a proposed alternative that works with stateless servers by inverting the communication model — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. - -This means servers that need user confirmation, LLM reasoning, or other client input can still run in stateless mode when both sides support MRTR. +This means servers that need user confirmation, LLM reasoning, or other client input can run in stateless mode when both sides support MRTR. ## Stateful mode (sessions) -When is `false` (the default), the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: +When is `false`, the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: - Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream - [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the GET stream @@ -154,7 +175,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing | | **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize | | **Memory** | Per-request only | Per-session (default: up to 10,000 sessions × 2 hours) | -| **Server-to-client requests** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | +| **Server-to-client requests** | Not supported by the legacy protocol; available via [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for sampling, elicitation, and roots | Supported (sampling, elicitation, roots) | | **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | | **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | @@ -396,14 +417,16 @@ builder.Services.AddMcpServer() | Property | Type | Default | Description | |----------|------|---------|-------------| -| | `bool` | `false` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests. | -| | `TimeSpan` | 2 hours | Duration of inactivity before a session is closed. Checked every 5 seconds. | -| | `int` | 10,000 | Maximum idle sessions before the oldest are forcibly terminated. | -| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode, this runs on every HTTP request. | +| | `bool` | `true` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests on the legacy protocol. Required by the `2026-07-28` draft revision. | +| | `TimeSpan` | 2 hours | _Stateful only (`MCP9005`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. | +| | `int` | 10,000 | _Stateful only (`MCP9005`)._ Maximum idle sessions before the oldest are forcibly terminated. | +| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all draft-revision requests), this runs on every HTTP request. | | | `Func?` | `null` | *(Experimental)* Custom session lifecycle handler. Consider `ConfigureSessionOptions` instead. | -| | `ISessionMigrationHandler?` | `null` | Enables cross-instance session migration. Can also be registered in DI. | -| | `ISseEventStreamStore?` | `null` | Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | -| | `bool` | `false` | Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | +| | `ISessionMigrationHandler?` | `null` | _Stateful only (`MCP9005`)._ Enables cross-instance session migration. Can also be registered in DI. | +| | `ISseEventStreamStore?` | `null` | _Stateful only (`MCP9005`)._ Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | +| | `bool` | `false` | _Stateful only (`MCP9005`)._ Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | + +The properties marked _Stateful only_ above carry diagnostic [`MCP9005`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served sessionlessly (every draft-revision request, plus every request on a server with `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. ### ConfigureSessionOptions diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 9548db7dd..c80e1d67b 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -60,7 +60,7 @@ public sealed class McpClientOptions /// /// This is useful when the client requires features (such as the draft revision's removal of the /// initialize handshake or Mcp-Session-Id) that are not available in older protocol - /// revisions. Setting this to disables the + /// revisions. Setting this to disables the /// automatic legacy-server fallback that otherwise switches to the initialize handshake. /// /// @@ -71,8 +71,8 @@ public sealed class McpClientOptions /// /// var clientOptions = new McpClientOptions /// { - /// ProtocolVersion = McpSessionHandler.DraftProtocolVersion, - /// MinProtocolVersion = McpSessionHandler.DraftProtocolVersion, + /// ProtocolVersion = McpSession.DraftProtocolVersion, + /// MinProtocolVersion = McpSession.DraftProtocolVersion, /// }; /// /// diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 73d99da71..a7a735d7f 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -28,6 +28,26 @@ namespace ModelContextProtocol; /// public abstract partial class McpSession : IAsyncDisposable { + /// The latest stable protocol revision this SDK supports. + /// + /// Set or + /// to this value to explicitly pin to the current stable revision instead of accepting whatever + /// the runtime negotiates. + /// + public const string LatestProtocolVersion = McpSessionHandler.LatestProtocolVersion; + + /// The in-progress draft protocol revision this SDK supports. + /// + /// Setting or + /// to this value opts the session into the draft revision. The draft revision removes the + /// initialize handshake (SEP-2575) and the Mcp-Session-Id header (SEP-2567), so a draft + /// HTTP server is sessionless on the wire regardless of HttpServerTransportOptions.Stateless. + /// Clients automatically fall back to the legacy initialize flow when the server does not + /// support the draft revision; set to this value + /// to disable that fallback. + /// + public const string DraftProtocolVersion = McpSessionHandler.DraftProtocolVersion; + /// Gets an identifier associated with the current MCP session. /// /// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE. From c2878b7108b14e1ce9afdac399f298660199b081 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sat, 6 Jun 2026 15:51:37 -0700 Subject: [PATCH 08/23] Fix draft client rejecting downgraded version after legacy fallback When a client configured with ProtocolVersion = DraftProtocolVersion probed a legacy server with server/discover and fell back to the initialize handshake, PerformLegacyInitializeAsync rejected the server's response because the post- handshake validation compared _options.ProtocolVersion ('2026-07-28') against the legacy server's negotiated version (e.g. Python's '2025-06-18'). The strict comparison was correct for legacy explicit pinning but wrong for the draft- fallback path, where the spec requires the client to accept whatever supported version the legacy server advertises. Now we only require an exact match when the user pinned a legacy (non-draft) version; otherwise we accept any version in SupportedProtocolVersions. We also enforce MinProtocolVersion against the negotiated response in case the server downgrades further than the version we requested. Adds DraftProtocolFallbackTests covering: * -32601 MethodNotFound fallback with version downgrade * -32602 InvalidParams fallback * MinProtocolVersion refuses fallback below the configured minimum * Legacy explicit-pin still requires exact version match Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/McpClientImpl.cs | 26 ++- .../Client/DraftProtocolFallbackTests.cs | 184 ++++++++++++++++++ 2 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 8571e0bcc..3bb7dc49d 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -839,15 +839,35 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella _serverInfo = initializeResponse.ServerInfo; _serverInstructions = initializeResponse.Instructions; - bool isResponseProtocolValid = - _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : - McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + // When the user explicitly pinned a legacy (non-draft) protocol version, the server MUST + // respect it. When the user pinned the draft 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 draft client must be able to downgrade to whatever + // legacy version the server advertises. + bool isResponseProtocolValid; + if (_options.ProtocolVersion is { } optionsProtocol && optionsProtocol != McpSessionHandler.DraftProtocolVersion) + { + isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; + } + else + { + isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + } if (!isResponseProtocolValid) { LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); } + // If the user set a MinProtocolVersion, also enforce it against the negotiated response + // (the server could have downgraded further than the version we asked for). + if (_options.MinProtocolVersion is { } minVersion && + StringComparer.Ordinal.Compare(initializeResponse.ProtocolVersion, minVersion) < 0) + { + throw new McpException( + $"Server negotiated protocol version '{initializeResponse.ProtocolVersion}' is below the configured minimum '{minVersion}'."); + } + _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; diff --git a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs new file mode 100644 index 000000000..27e1525f6 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs @@ -0,0 +1,184 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Regression tests for the draft-protocol-to-legacy fallback path in +/// . These verify that a client configured with +/// McpClientOptions.ProtocolVersion = McpSession.DraftProtocolVersion +/// correctly probes for a draft-aware server with server/discover, falls +/// back to the legacy initialize handshake when the server is legacy, +/// and accepts whatever supported protocol version the legacy server +/// negotiates - including a version different from the one the client +/// originally requested. +/// +/// +/// The originally shipped logic in PerformLegacyInitializeAsync compared +/// the server's response against _options.ProtocolVersion, which under +/// draft is "2026-07-28". When the legacy server downgraded to (say) +/// "2025-06-18", the comparison threw, even though the legacy +/// negotiation succeeded. These tests guard against that regression. +/// +public class DraftProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + [Fact] + public async Task DraftClient_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(transport.ServerDiscoverProbed); + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task DraftClient_OnInvalidParams_FallsBackTo_Initialize() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: "2025-11-25", + probeErrorCode: (int)McpErrorCode.InvalidParams); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task DraftClient_WithMinProtocolVersion_RefusesFallback_BelowMinimum() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + MinProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.Contains("minimum", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task LegacyClient_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"); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = "2025-11-25", + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Minimal in-memory transport that simulates a legacy server: rejects + /// server/discover (with a configurable JSON-RPC error code) and + /// responds to initialize with a configurable protocol version. + /// + private sealed class LegacyServerTestTransport( + string serverNegotiatedVersion, + int probeErrorCode = (int)McpErrorCode.MethodNotFound) : IClientTransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + + public string Name => "legacy-server-test-transport"; + + public bool ServerDiscoverProbed { get; private set; } + + public bool LegacyInitializeReceived { 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: + ServerDiscoverProbed = true; + _ = WriteAsync(new JsonRpcError + { + Id = discoverReq.Id, + Error = new JsonRpcErrorDetail + { + Code = probeErrorCode, + Message = probeErrorCode == (int)McpErrorCode.MethodNotFound + ? "Method not found" + : "Invalid params", + }, + }); + break; + + case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: + LegacyInitializeReceived = true; + _ = WriteAsync(new JsonRpcResponse + { + Id = initReq.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = serverNegotiatedVersion, + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }); + break; + } + } + + private Task WriteAsync(JsonRpcMessage message) + => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); + + private sealed class TransportChannel( + Channel incoming, + LegacyServerTestTransport 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; + } + } + } +} From c4662a2bdfbefdf8e3fb59e6cd52b0dd2f79b6d8 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sat, 6 Jun 2026 16:02:21 -0700 Subject: [PATCH 09/23] Gate built-in ping handler to legacy protocol versions The ping RPC was removed in the draft 2026-07-28 revision (SEP-2575). TypeScript and Go SDKs both reject ping under draft; C# was responding to ping unconditionally with the comment 'must always be handled', which was a holdover from the older spec. The built-in handler now throws McpProtocolException(MethodNotFound) for any per-request protocol version >= DraftProtocolVersion, falling back to the session-level NegotiatedProtocolVersion when the per-request _meta is absent (legacy sessions). Liveness on draft sessions belongs to transport- and request-level timeouts, not a removed MCP RPC. Adds PingProtocolGatingTests covering: * Ping under draft returns MethodNotFound * Ping under legacy still succeeds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpSessionHandler.cs | 19 ++++++- .../Server/PingProtocolGatingTests.cs | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index a12b7453f..d404a95cb 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -141,10 +141,25 @@ public McpSessionHandler( _outgoingMessageFilter = outgoingMessageFilter ?? (next => next); _logger = logger; - // Per the MCP spec, ping may be initiated by either party and must always be handled. + // ping was removed in the draft protocol revision (SEP-2575). Under draft, return + // MethodNotFound; under legacy, the per-spec behavior is to always answer with PingResult. + // Liveness on draft sessions belongs to transport- and request-level timeouts, not a + // dedicated MCP RPC. _requestHandlers.Set( RequestMethods.Ping, - (request, _, cancellationToken) => new ValueTask(new PingResult()), + (request, jsonRpcRequest, cancellationToken) => + { + string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; + if (perRequestVersion is not null && + StringComparer.Ordinal.Compare(perRequestVersion, DraftProtocolVersion) >= 0) + { + throw new McpProtocolException( + $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", + McpErrorCode.MethodNotFound); + } + + return new ValueTask(new PingResult()); + }, McpJsonUtilities.JsonContext.Default.JsonNode, McpJsonUtilities.JsonContext.Default.PingResult); diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs new file mode 100644 index 000000000..45c978be2 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the built-in ping handler is gated by protocol version. +/// SEP-2575 (the draft 2026-07-28 revision) removes ping; servers must +/// respond with -32601 MethodNotFound. Legacy protocol versions still +/// support ping per the spec. +/// +public sealed class PingProtocolGatingTests : ClientServerTestBase +{ + public PingProtocolGatingTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + } + + [Fact] + public async Task Ping_OnDraftSession_ReturnsMethodNotFound() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }); + + var ex = await Assert.ThrowsAsync(async () => + await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Ping_OnLegacySession_StillSucceeds() + { + // Default server config; client pinned to 2025-11-25. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "2025-11-25", + }); + + var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result); + } +} From 1b8dd92ff2acae675d2298c5ed2726f420509413 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 15:09:30 -0700 Subject: [PATCH 10/23] Merge per-request client capabilities instead of overwriting them PR #1579's GetMetaWithTaskCapability writes a partial SEP-2575 capabilities envelope (only `extensions.io.modelcontextprotocol/tasks`) on every `tools/call`, regardless of negotiated protocol version. The server's `CreateDraftStateSyncFilter` was treating the envelope as authoritative and overwriting the session-scoped `_clientCapabilities` with the partial value - wiping out whatever the initialize handshake had captured. Most visibly, a legacy client that handshook with `Elicitation = new()` would lose elicitation support the moment it issued a tools/call, and the back-compat MRTR resolver would then fail with "Client does not support elicitation requests". Switch the per-request synchronization to a defensive merge that preserves fields the envelope leaves null and additively merges extension keys. Gate the merge behind `IsStatefulSession()` so per-request envelope state doesn't leak into `_clientCapabilities` on stateless HTTP sessions (where StatelessServerTests rely on the null invariant to surface "X is not supported in stateless mode" errors). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Server/McpServerImpl.cs | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 59946fbfd..b90e51393 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -201,9 +201,22 @@ private JsonRpcMessageFilter CreateDraftStateSyncFilter() } } - if (context.ClientCapabilities is { } clientCapabilities) + if (context.ClientCapabilities is { } clientCapabilities && IsStatefulSession()) { - _clientCapabilities = clientCapabilities; + // Defensive merge instead of overwrite. SEP-2575 says the per-request envelope is + // the client's full capabilities, but PR #1579's GetMetaWithTaskCapability emits a + // partial envelope (only extensions.io.modelcontextprotocol/tasks) on every + // tools/call regardless of the negotiated protocol version. If we overwrote here, + // a legacy client that called initialize with { Elicitation = new() } would lose + // its elicitation capability the moment it issued a tools/call. Merging non-null + // fields preserves whatever the initialize handshake (or a prior, more complete + // envelope) established. + // + // The IsStatefulSession() gate prevents leaking per-request capability state into + // _clientCapabilities under StreamableHttpServerTransport { Stateless = true } + // (where _clientCapabilities is otherwise null and StatelessServerTests rely on + // that invariant to surface the "X is not supported in stateless mode" errors). + _clientCapabilities = MergeClientCapabilities(_clientCapabilities, clientCapabilities); } if (context.ClientInfo is { } clientInfo && @@ -225,6 +238,51 @@ private JsonRpcMessageFilter CreateDraftStateSyncFilter() }; } + /// + /// Merges per-request envelope values onto the existing + /// session-scoped capabilities, preserving fields that the envelope leaves unset. + /// + /// + /// SEP-2575 treats the per-request envelope as the client's full capabilities for the request, but + /// at least one extension (SEP-2663 Tasks) emits a partial envelope advertising only + /// extensions.io.modelcontextprotocol/tasks = {} on every tools/call. Overwriting the + /// captured initialize-time capabilities with that partial envelope would silently drop other + /// declared capabilities (e.g., elicitation), so we merge per-field instead. + /// + private static ClientCapabilities MergeClientCapabilities(ClientCapabilities? existing, ClientCapabilities envelope) + { + if (existing is null) + { + return envelope; + } + + IDictionary? mergedExtensions = existing.ExtensionsCore; + if (envelope.ExtensionsCore is { Count: > 0 } envelopeExtensions) + { + if (mergedExtensions is null) + { + mergedExtensions = new Dictionary(envelopeExtensions); + } + else + { + // Per-request extensions are additive; don't strip ones declared at initialize. + foreach (var kvp in envelopeExtensions) + { + mergedExtensions[kvp.Key] = kvp.Value; + } + } + } + + return new ClientCapabilities + { + Roots = envelope.Roots ?? existing.Roots, + Sampling = envelope.Sampling ?? existing.Sampling, + Elicitation = envelope.Elicitation ?? existing.Elicitation, + Experimental = envelope.Experimental ?? existing.Experimental, + ExtensionsCore = mergedExtensions, + }; + } + /// public override string? SessionId => _sessionTransport.SessionId; From 51571c6365e045148f8b1e19343c9e0489bae3db Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 3 Jun 2026 15:20:40 -0700 Subject: [PATCH 11/23] Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results Implements SEP-2549 "TTL for List Results", which lets servers attach optional caching freshness hints to the five cacheable result types: tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. Protocol changes: - Add ICacheableResult with TimeToLive (serialized as integer-millisecond ttlMs) and CacheScope (serialized as cacheScope). - Add the CacheScope enum (public, private) with lowercase wire values. - Implement the interface on the five cacheable result types. - Register CacheScope for source-generated serialization. Both fields are optional and omitted when unset, so the change is fully backward compatible and requires no capability negotiation. The SDK propagates the values without consuming them. Robustness and security: - ttlMs deserialization clamps out-of-range, fractional, and overflowing values (including positive and negative infinity) to TimeSpan.MinValue or MaxValue instead of throwing, so a malformed or hostile hint cannot break reading of the enclosing result. The shared TimeSpanMillisecondsConverter uses the non-throwing TryGetDouble and clamps by token sign, giving identical behavior on .NET and on .NET Framework (whose number parser reports failure on overflow rather than returning infinity). - cacheScope deserialization tolerates unknown or future values by mapping them to null (treated as the public default) instead of failing the whole result, and matches the known values case-insensitively so a mis-cased "private" is honored rather than silently downgraded to public. Tests: - Serialization, round-trip, omission, and clamping edge cases for ttlMs. - Unknown, partial, and case-insensitive cacheScope handling. - Per-page independence of caching hints for pagination. - End-to-end propagation of hints from server to client. - Regression coverage for the shared converter used by McpTask ttl and pollInterval. - Caching conformance scenario wiring, gated to the conformance build that provides it. Verified across net8.0, net9.0, net10.0, and net472, and under Native AOT publish with no trimming or AOT warnings. --- .../McpJsonUtilities.cs | 1 + .../Protocol/CacheScope.cs | 44 +++ .../Protocol/CacheScopeConverter.cs | 63 ++++ .../Protocol/ICacheableResult.cs | 58 ++++ .../Protocol/ListPromptsResult.cs | 12 +- .../Protocol/ListResourceTemplatesResult.cs | 12 +- .../Protocol/ListResourcesResult.cs | 12 +- .../Protocol/ListToolsResult.cs | 12 +- .../Protocol/ReadResourceResult.cs | 12 +- .../Protocol/TimeSpanMillisecondsConverter.cs | 120 ++++++++ tests/Common/Utils/NodeHelpers.cs | 209 +++++++++++-- .../CachingConformanceTests.cs | 139 +++++++++ .../ClientConformanceTests.cs | 2 +- .../ServerConformanceTests.cs | 128 +++----- .../Program.cs | 63 +++- .../CacheableResultClientServerTests.cs | 57 ++++ .../Protocol/CacheableResultTests.cs | 291 ++++++++++++++++++ ...imeSpanMillisecondsConverterSharedTests.cs | 57 ++++ 18 files changed, 1168 insertions(+), 124 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Protocol/CacheScope.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 1625f9243..f8109bae5 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -151,6 +151,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(PingResult))] [JsonSerializable(typeof(ReadResourceRequestParams))] [JsonSerializable(typeof(ReadResourceResult))] + [JsonSerializable(typeof(CacheScope))] [JsonSerializable(typeof(SetLevelRequestParams))] [JsonSerializable(typeof(SubscribeRequestParams))] [JsonSerializable(typeof(UnsubscribeRequestParams))] diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScope.cs b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs new file mode 100644 index 000000000..d87cdd7f9 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Indicates the intended scope of a cached response, analogous to the HTTP +/// Cache-Control: public and Cache-Control: private directives. +/// +/// +/// +/// This is used by to control who may cache a +/// response returned by tools/list, prompts/list, resources/list, +/// resources/templates/list, and resources/read. +/// +/// +/// When the field is absent from a response, clients should treat it as . +/// +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CacheScope +{ + /// + /// The response does not contain user-specific data. Any client, shared gateway, or caching + /// proxy may store and serve the cached response to any user. + /// + /// + /// This is appropriate for lists of tools, prompts, and resource templates that are identical + /// for all users. + /// + [JsonStringEnumMemberName("public")] + Public, + + /// + /// The response contains user-specific data. Only the requesting user's client may cache it. + /// Shared caches (for example, multi-tenant gateways) must not serve the cached response to a + /// different user. + /// + /// + /// This is appropriate for resources/read results that depend on the authenticated user, + /// or for filtered list results that vary per user. + /// + [JsonStringEnumMemberName("private")] + Private +} diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs new file mode 100644 index 000000000..b67ac5576 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Serializes caching-scope hints, tolerating unknown or future values on read. +/// +/// +/// +/// SEP-2549 introduces cacheScope as a forward-looking caching hint. If a server sends an +/// unrecognized scope string (for example, a value added in a later revision of the specification) or a +/// non-string token, this converter maps it to rather than throwing. This prevents +/// a single unexpected hint from breaking deserialization of the entire result (for example, the whole +/// tool list). A result is the same as an absent field, which clients treat as +/// . +/// +/// +/// This converter is applied per-property on the cacheable result types. The +/// enum itself retains a standard string converter for any standalone serialization. +/// +/// +internal sealed class CacheScopeConverter : JsonConverter +{ + public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.String) + { + string? value = reader.GetString(); + + // Match case-insensitively so a non-conforming casing of "private" (a security-relevant hint) + // is honored rather than falling through to null, which clients would treat as "public" and + // could cache user-specific data in a shared cache. Genuinely unknown values still map to null. + if (string.Equals(value, "public", StringComparison.OrdinalIgnoreCase)) + { + return CacheScope.Public; + } + + if (string.Equals(value, "private", StringComparison.OrdinalIgnoreCase)) + { + return CacheScope.Private; + } + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value switch + { + CacheScope.Public => "public", + CacheScope.Private => "private", + _ => throw new JsonException($"Unsupported {nameof(CacheScope)} value: {value}."), + }); + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs new file mode 100644 index 000000000..33169208a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs @@ -0,0 +1,58 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a result that carries time-to-live (TTL) caching hints, allowing clients to cache +/// the response for a period of time before re-fetching. +/// +/// +/// +/// This interface corresponds to the CacheableResult type in the Model Context Protocol +/// schema and is implemented by the results of tools/list, prompts/list, +/// resources/list, resources/templates/list, and resources/read. +/// +/// +/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing +/// list_changed and resources/updated notification mechanisms; both can coexist. A +/// relevant notification invalidates a cached response regardless of any remaining TTL. +/// +/// +public interface ICacheableResult +{ + /// + /// Gets or sets a hint indicating how long the client may cache this response before re-fetching. + /// + /// + /// + /// The semantics are analogous to the HTTP Cache-Control: max-age directive. The value is + /// serialized as an integer number of milliseconds under the ttlMs JSON property. + /// + /// + /// A value of indicates the response should be considered immediately + /// stale; a positive value indicates the client should consider the response fresh for that + /// duration from the time it was received. + /// + /// + /// When this property is (the field was absent from the response), clients + /// should assume a default of (immediately stale) and rely on their + /// own caching heuristics or notifications. A negative value should likewise be treated as + /// . + /// + /// + TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the intended scope of the cached response. + /// + /// + /// + /// When this property is (the field was absent from the response), clients + /// should treat the response as . + /// + /// + /// An unrecognized or future scope value sent by a server (or a non-string value) is tolerated and + /// surfaced as rather than causing deserialization of the whole result to + /// fail, so a single unexpected hint never prevents a client from reading the result. + /// + /// + CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs index 1f648bd5a..a7f26b521 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListPromptsResult : PaginatedResult +public sealed class ListPromptsResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of prompts or prompt templates that the server offers. /// [JsonPropertyName("prompts")] public IList Prompts { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs index 6e422a751..988d6f186 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs @@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListResourceTemplatesResult : PaginatedResult +public sealed class ListResourceTemplatesResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of resource templates that the server offers. @@ -32,4 +32,14 @@ public sealed class ListResourceTemplatesResult : PaginatedResult /// [JsonPropertyName("resourceTemplates")] public IList ResourceTemplates { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs index 16d01491c..54c1df601 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListResourcesResult : PaginatedResult +public sealed class ListResourcesResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of resources that the server offers. /// [JsonPropertyName("resources")] public IList Resources { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs index a2f03b853..55eed5ddb 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListToolsResult : PaginatedResult +public sealed class ListToolsResult : PaginatedResult, ICacheableResult { /// /// Gets or sets the server's response to a tools/list request from the client. /// [JsonPropertyName("tools")] public IList Tools { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs index 084322fde..53e138806 100644 --- a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol; /// /// See the schema for details. /// -public sealed class ReadResourceResult : Result +public sealed class ReadResourceResult : Result, ICacheableResult { /// /// Gets or sets a list of objects that this resource contains. @@ -20,4 +20,14 @@ public sealed class ReadResourceResult : Result /// [JsonPropertyName("contents")] public IList Contents { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs new file mode 100644 index 000000000..18386d326 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs @@ -0,0 +1,120 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Provides a JSON converter for that serializes as integer milliseconds. +/// +/// +/// This converter serializes TimeSpan values as the total number of milliseconds (as an integer), +/// and deserializes integer millisecond values back to TimeSpan. System.Text.Json automatically +/// handles nullable TimeSpan properties using this converter. Millisecond values that fall outside +/// the range representable by are clamped to +/// / rather than throwing, so an +/// oversized or malformed hint can never break deserialization of the enclosing result. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class TimeSpanMillisecondsConverter : JsonConverter +{ + /// + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Number) + { + if (reader.TryGetInt64(out long milliseconds)) + { + return FromMillisecondsClamped(milliseconds); + } + + // Non-integer value: fractional, or a magnitude too large to represent. Use the non-throwing + // TryGetDouble so an out-of-range exponent never breaks deserialization. Note that different + // runtimes disagree on out-of-range doubles: in-box .NET returns +/-Infinity, whereas .NET + // Framework's parser reports failure. Handle both so behavior is identical everywhere. + if (reader.TryGetDouble(out double value)) + { + if (double.IsPositiveInfinity(value)) + { + return TimeSpan.MaxValue; + } + + if (double.IsNegativeInfinity(value)) + { + return TimeSpan.MinValue; + } + + return FromTicksClamped(value * TimeSpan.TicksPerMillisecond); + } + + // The runtime could not represent the number as a double at all (e.g. .NET Framework on an + // overflowing exponent). Clamp by the sign of the raw token. + return IsNegativeNumberToken(ref reader) ? TimeSpan.MinValue : TimeSpan.MaxValue; + } + + throw new JsonException($"Unable to convert {reader.TokenType} to TimeSpan."); + } + + private static bool IsNegativeNumberToken(ref Utf8JsonReader reader) + { + ReadOnlySpan token = reader.HasValueSequence ? reader.ValueSequence.First.Span : reader.ValueSpan; + return !token.IsEmpty && token[0] == (byte)'-'; + } + + // Largest whole-millisecond count representable as a TimeSpan (TimeSpan.MaxValue.Ticks / TicksPerMillisecond). + private const long MaxWholeMilliseconds = long.MaxValue / TimeSpan.TicksPerMillisecond; + + // Converts an integer millisecond count to a TimeSpan, clamping out-of-range values to + // TimeSpan.MinValue/MaxValue instead of throwing. A malformed or oversized hint (for example a + // hostile or buggy server returning an enormous ttlMs) must never break deserialization of the + // whole result; per SEP-2549 clients should handle unexpected TTL values gracefully. + private static TimeSpan FromMillisecondsClamped(long milliseconds) + { + if (milliseconds > MaxWholeMilliseconds) + { + return TimeSpan.MaxValue; + } + + if (milliseconds < -MaxWholeMilliseconds) + { + return TimeSpan.MinValue; + } + + return TimeSpan.FromTicks(milliseconds * TimeSpan.TicksPerMillisecond); + } + + // Converts a (possibly fractional or out-of-range) tick count to a TimeSpan, clamping instead of + // throwing. The caller passes a value already scaled into tick-space (milliseconds * TicksPerMillisecond) + // because TimeSpan is backed by a long tick count, so comparing against long.MaxValue/MinValue is the + // exact test for whether the final (long) cast would overflow. The comparisons MUST run before that cast: + // double arithmetic saturates to +/-Infinity on overflow rather than throwing, and both infinities fall + // into the clamp branches here (+Infinity >= long.MaxValue, -Infinity <= long.MinValue); if Infinity + // instead reached "(long)ticks" the unchecked conversion would silently yield long.MinValue. NaN is not + // reachable from valid JSON (the only multiplicand is a non-zero constant) but is mapped to zero + // defensively so a non-numeric hint can never break deserialization. + private static TimeSpan FromTicksClamped(double ticks) + { + if (double.IsNaN(ticks)) + { + return TimeSpan.Zero; + } + + if (ticks >= long.MaxValue) + { + return TimeSpan.MaxValue; + } + + if (ticks <= long.MinValue) + { + return TimeSpan.MinValue; + } + + return TimeSpan.FromTicks((long)ticks); + } + + /// + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue((long)value.TotalMilliseconds); + } +} diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index ef1686abb..76ea98630 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; namespace ModelContextProtocol.Tests.Utils; @@ -78,16 +80,25 @@ public static void EnsureNpmDependenciesInstalled() /// /// The name of the binary in node_modules/.bin (e.g. "conformance"). /// The arguments to pass to the binary. + /// + /// When (the default) and the MCP_CONFORMANCE_PROTOCOL_VERSION + /// environment variable is set, a "--spec-version <value>" argument is appended. + /// Pass for scenarios that pin their own spec version (e.g. the + /// draft-only caching scenario) to avoid a conflicting duplicate flag. + /// /// A configured ProcessStartInfo for running the binary. - public static ProcessStartInfo ConformanceTestStartInfo(string arguments) + public static ProcessStartInfo ConformanceTestStartInfo(string arguments, bool appendProtocolVersionFromEnv = true) { EnsureNpmDependenciesInstalled(); // If MCP_CONFORMANCE_PROTOCOL_VERSION is set, pass it as --spec-version to the runner. - var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION"); - if (!string.IsNullOrEmpty(protocolVersion)) + if (appendProtocolVersionFromEnv) { - arguments += $" --spec-version {protocolVersion}"; + var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION"); + if (!string.IsNullOrEmpty(protocolVersion)) + { + arguments += $" --spec-version {protocolVersion}"; + } } var repoRoot = FindRepoRoot(); @@ -168,41 +179,201 @@ public static bool IsNodeInstalled() } /// - /// Checks whether the SEP-2243 conformance scenarios are available by reading - /// the conformance package version from the repo's package.json. + /// 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 - /// require a conformance package version that includes SEP-2243 support. + /// 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>. + /// + public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + + /// + /// 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>. /// - public static bool HasSep2243Scenarios() + public static bool HasCachingScenario() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + + /// + /// Returns when the conformance package installed in node_modules + /// has a version greater than or equal to . + /// + private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion) + { + var version = GetInstalledConformanceVersion(); + return version is not null && version >= minimumVersion; + } + + /// + /// 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. + /// + private static Version? GetInstalledConformanceVersion() { try { var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine(repoRoot, "package.json"); + var packageJsonPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); + + // Only trigger an install if the package isn't already present. This avoids a + // gate check inadvertently running 'npm ci' (which would restore the pinned + // version and clobber a locally installed private build). if (!File.Exists(packageJsonPath)) { - return false; + EnsureNpmDependenciesInstalled(); } - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("dependencies", out var deps) && - deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) + if (!File.Exists(packageJsonPath)) { - var versionStr = versionElement.GetString(); - if (versionStr is not null && Version.TryParse(versionStr, out var version)) + 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)) { - // SEP-2243 scenarios are expected in conformance package >= 0.2.0 - return version >= new Version(0, 2, 0); + return version; } } - return false; + return null; } catch + { + return null; + } + } + + /// + /// Runs the conformance runner ("conformance <arguments>") in server mode and returns + /// whether it succeeded along with the captured stdout/stderr. Centralizes the process + /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) + /// shared by the server-side conformance tests. + /// + /// Arguments to pass to the conformance runner. + /// Optional callback invoked for each captured stdout/stderr line. + /// + /// Forwarded to . + /// + /// Token used to cancel the run. + public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync( + string arguments, + Action? onLine = null, + bool appendProtocolVersionFromEnv = true, + CancellationToken cancellationToken = default) + { + var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + using var process = new Process { StartInfo = startInfo }; + + // Protect callbacks with try/catch so a callback that throws on a background thread + // (e.g. ITestOutputHelper after the test completes) does not crash the test host. + DataReceivedEventHandler outputHandler = (sender, e) => + { + if (e.Data != null) + { + try { onLine?.Invoke(e.Data); } catch { } + outputBuilder.AppendLine(e.Data); + } + }; + + DataReceivedEventHandler errorHandler = (sender, e) => + { + if (e.Data != null) + { + try { onLine?.Invoke(e.Data); } catch { } + errorBuilder.AppendLine(e.Data); + } + }; + + process.OutputDataReceived += outputHandler; + process.ErrorDataReceived += errorHandler; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(5)); + try + { +#if NET + 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. + await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); + if (!process.HasExited) + { + throw new OperationCanceledException(); + } +#endif + } + catch (OperationCanceledException) + { +#if NET + process.Kill(entireProcessTree: true); +#else + process.Kill(); +#endif + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + return ( + false, + outputBuilder.ToString(), + errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."); + } + + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + + var stdoutText = outputBuilder.ToString(); + var stderrText = errorBuilder.ToString(); + + // The Node.js conformance runner can crash during cleanup on Windows with a libuv + // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit + // code even though every conformance check passed. When that happens, fall back to + // parsing the "Test Results:" summary in stdout to decide success. + bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); + + return (success, stdoutText, stderrText); + } + + /// + /// Parses the conformance runner output for a "Test Results:" line such as + /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed + /// and none failed. + /// + private static bool ConformanceOutputIndicatesSuccess(string output) + { + // Match lines like "Passed: 3/3, 0 failed, 0 warnings" + var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); + if (!match.Success) { return false; } + + int passed = int.Parse(match.Groups[1].Value); + int total = int.Parse(match.Groups[2].Value); + int failed = int.Parse(match.Groups[3].Value); + + return passed == total && failed == 0 && total > 0; } /// diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs new file mode 100644 index 000000000..5cdd2948a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.ConformanceTests; + +/// +/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the draft +/// SEP-2549 "caching" conformance scenario requires. Started on demand (so it is not bound +/// when the caching test is skipped) and torn down via . Uses a +/// distinct port range from the stateful ConformanceServerFixture (3001/3002/3003) so +/// the two can run in parallel without TCP conflicts. +/// +internal sealed class StatelessConformanceServer : IAsyncDisposable +{ + // Use different ports for each target framework to allow parallel execution across the + // multi-targeted test processes, offset from a caller-supplied base port so independent + // stateless servers (e.g. caching vs. SEP-2243) do not collide. net10.0 -> +0, + // net9.0 -> +1, net8.0 -> +2. + private static int GetPortForTargetFramework(int basePort) + { + var testBinaryDir = AppContext.BaseDirectory; + var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + + var offset = targetFramework switch + { + "net10.0" => 0, + "net9.0" => 1, + "net8.0" => 2, + _ => 0 // Default fallback + }; + + return basePort + offset; + } + + private readonly Task _serverTask; + private readonly CancellationTokenSource _serverCts; + + public string ServerUrl { get; } + + private StatelessConformanceServer(string serverUrl, Task serverTask, CancellationTokenSource serverCts) + { + ServerUrl = serverUrl; + _serverTask = serverTask; + _serverCts = serverCts; + } + + public static async Task StartAsync(CancellationToken cancellationToken, int basePort = 3011) + { + var serverUrl = $"http://localhost:{GetPortForTargetFramework(basePort)}"; + var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // "--stateless true" opts this server instance into the SEP-2575 stateless lifecycle + // (see ConformanceServer.Program), without mutating process-wide environment state. + var serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( + ["--urls", serverUrl, "--stateless", "true"], cancellationToken: serverCts.Token)); + + // Wait for the server to be ready (retry for up to 30 seconds). + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + + while (stopwatch.Elapsed < timeout) + { + try + { + await httpClient.GetAsync($"{serverUrl}/health", cancellationToken); + return new StatelessConformanceServer(serverUrl, serverTask, serverCts); + } + catch (HttpRequestException) + { + // Connection refused means the server is not ready yet. + } + catch (TaskCanceledException) + { + // Timeout means the server might be processing; give it more time. + } + + await Task.Delay(500, cancellationToken); + } + + serverCts.Cancel(); + serverCts.Dispose(); + throw new InvalidOperationException("Stateless ConformanceServer failed to start within the timeout period"); + } + + public async ValueTask DisposeAsync() + { + _serverCts.Cancel(); + try + { + await _serverTask.WaitAsync(TestConstants.DefaultTimeout); + } + catch + { + // Ignore exceptions during shutdown. + } + _serverCts.Dispose(); + } +} + +/// +/// Runs the official MCP conformance "caching" scenario (SEP-2549: TTL for List Results, +/// added in conformance PR #275) against the SDK's ConformanceServer, verifying that the SDK +/// correctly emits the ttlMs and cacheScope caching hints on cacheable results +/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). +/// +/// +/// The scenario is draft-only (introduced in DRAFT-2026-v1) and uses the stateless lifecycle. +/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when +/// running against the currently-pinned package, so it activates automatically once a +/// conformance package containing the caching scenario is installed (including a local private +/// build installed via npm install --no-save <path-to-conformance>). The stateless +/// server is started only after the gates pass, so a skipped run binds no port. +/// +public class CachingConformanceTests(ITestOutputHelper output) +{ + [Fact] + 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)."); + + await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); + + // The caching scenario only exists in the draft spec, so pin the spec version + // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a + // conflicting duplicate --spec-version flag). + var result = await NodeHelpers.RunServerConformanceAsync( + $"server --url {server.ServerUrl} --scenario caching --spec-version DRAFT-2026-v1", + line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success, + $"SEP-2549 caching conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 7b2be118b..f389ffbba 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -63,7 +63,7 @@ public async Task RunConformanceTest(string scenario) } // HTTP Standardization (SEP-2243) - [Theory(Skip = "SEP-2243 conformance scenarios not yet available.", SkipUnless = nameof(HasSep2243Scenarios))] + [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-custom-headers")] [InlineData("http-invalid-tool-headers")] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index fd7795567..efbf8467f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,7 +1,5 @@ using System.Diagnostics; using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -37,8 +35,10 @@ private static int GetPortForTargetFramework() public async ValueTask InitializeAsync() { _serverCts = new CancellationTokenSource(); + // Explicitly pass "--stateless false" so this stateful fixture is immune to a globally + // set MCP_CONFORMANCE_STATELESS environment variable (the command-line switch wins). _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + ["--urls", ServerUrl, "--stateless", "false"], cancellationToken: _serverCts.Token)); // Wait for server to be ready (retry for up to 30 seconds) var timeout = TimeSpan.FromSeconds(30); @@ -139,9 +139,19 @@ public async Task RunPendingConformanceTest_ServerSsePolling() 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 yet available."); + Assert.SkipWhen( + !NodeHelpers.HasSep2243Scenarios(), + "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + + // SEP-2243 is a draft (DRAFT-2026-v1) scenario that uses the stateless lifecycle, so it + // requires a stateless server (a stateful server rejects the un-initialized list/call + // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with + // the stateful class fixture (300x) or the caching stateless server (301x). + await using var server = await StatelessConformanceServer.StartAsync( + TestContext.Current.CancellationToken, basePort: 3021); - var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation"); + var result = await RunStatelessConformanceTestAsync( + $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -151,9 +161,15 @@ public async Task RunConformanceTest_HttpHeaderValidation() 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 yet available."); + Assert.SkipWhen( + !NodeHelpers.HasSep2243Scenarios(), + "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + + await using var server = await StatelessConformanceServer.StartAsync( + TestContext.Current.CancellationToken, basePort: 3024); - var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation"); + var result = await RunStatelessConformanceTestAsync( + $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version DRAFT-2026-v1"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -189,94 +205,20 @@ public async Task RunMrtrConformanceTest(string scenario) private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { - var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments); - - var outputBuilder = new StringBuilder(); - var errorBuilder = new StringBuilder(); - - var process = new Process { StartInfo = startInfo }; - - // Protect callbacks with try/catch to prevent ITestOutputHelper from - // throwing on a background thread if events arrive after the test completes. - DataReceivedEventHandler outputHandler = (sender, e) => - { - if (e.Data != null) - { - try { output.WriteLine(e.Data); } catch { } - outputBuilder.AppendLine(e.Data); - } - }; - - DataReceivedEventHandler errorHandler = (sender, e) => - { - if (e.Data != null) - { - try { output.WriteLine(e.Data); } catch { } - errorBuilder.AppendLine(e.Data); - } - }; - - process.OutputDataReceived += outputHandler; - process.ErrorDataReceived += errorHandler; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - try - { - await process.WaitForExitAsync(cts.Token); - } - catch (OperationCanceledException) - { - process.Kill(entireProcessTree: true); - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; - return ( - Success: false, - Output: outputBuilder.ToString(), - Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed." - ); - } - - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; - - var stdoutText = outputBuilder.ToString(); - var stderrText = errorBuilder.ToString(); - - // The Node.js conformance runner can crash during cleanup on Windows with a libuv - // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit - // code even though every conformance check passed. When that happens, fall back to - // parsing the "Test Results:" summary in stdout to decide success. - bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); - - return ( - Success: success, - Output: stdoutText, - Error: stderrText - ); + return await NodeHelpers.RunServerConformanceAsync( + arguments, + line => { try { output.WriteLine(line); } catch { } }, + cancellationToken: TestContext.Current.CancellationToken); } - /// - /// Parses the conformance runner output for a "Test Results:" line such as - /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed - /// and none failed. - /// - private static bool ConformanceOutputIndicatesSuccess(string output) + // For draft scenarios that pin --spec-version explicitly, suppress the + // MCP_CONFORMANCE_PROTOCOL_VERSION override so a duplicate --spec-version is not appended. + private async Task<(bool Success, string Output, string Error)> RunStatelessConformanceTestAsync(string arguments) { - // Match lines like "Passed: 3/3, 0 failed, 0 warnings" - var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); - if (!match.Success) - { - return false; - } - - int passed = int.Parse(match.Groups[1].Value); - int total = int.Parse(match.Groups[2].Value); - int failed = int.Parse(match.Groups[3].Value); - - return passed == total && failed == 0 && total > 0; + return await NodeHelpers.RunServerConformanceAsync( + arguments, + line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); } } diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 3a6ed6e51..e0aa5a3a4 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -25,15 +25,28 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide // because .NET does not have a built-in concurrent HashSet ConcurrentDictionary> subscriptions = new(); + // Allow running the server in the SEP-2575 stateless lifecycle, which the draft + // "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false" + // command-line switch (read via configuration) takes precedence so an in-process test + // fixture can opt in or out per-instance deterministically; when it is not supplied, + // fall back to the MCP_CONFORMANCE_STATELESS environment variable for standalone runs. + // The default (no switch, no env var) remains the stateful server that serves the + // active conformance suite unchanged. + var statelessConfig = builder.Configuration["stateless"]; + var stateless = statelessConfig is not null + ? string.Equals(statelessConfig, "true", StringComparison.OrdinalIgnoreCase) + : string.Equals( + Environment.GetEnvironmentVariable("MCP_CONFORMANCE_STATELESS"), + "true", + StringComparison.OrdinalIgnoreCase); + builder.Services.AddDistributedMemoryCache(); builder.Services .AddMcpServer() - .WithHttpTransport(options => - { - // ConformanceTests rely on stateful behaviors (resumability, session-scoped subscriptions, OAuth). - // Pin Stateless = false explicitly now that draft (SEP-2567) defaults to true. - options.Stateless = false; - }) + // Default (no --stateless switch, no env var) is the stateful server that ConformanceTests + // rely on (resumability, session-scoped subscriptions, OAuth). The "--stateless" switch / env + // var opts into the SEP-2567/SEP-2549 stateless lifecycle for the caching conformance scenario. + .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() .WithTools() .WithTools() @@ -50,6 +63,44 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide await request.EnablePollingAsync(TimeSpan.FromMilliseconds(500), cancellationToken); } + return result; + }) + // SEP-2549: advertise TTL/cacheScope caching hints on cacheable results. The + // conformance server's tools, prompts, resources, and resource templates are the + // same for every caller, so they are cacheable with a "public" scope. + .AddListToolsFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListPromptsFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListResourcesFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListResourceTemplatesFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddReadResourceFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(1); + result.CacheScope = CacheScope.Public; return result; })) .WithPrompts() diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs new file mode 100644 index 000000000..38e690d07 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// End-to-end tests verifying that SEP-2549 caching hints set by a server on cacheable results +/// are observed by a connected client. +/// +public class CacheableResultClientServerTests(ITestOutputHelper testOutputHelper) + : ClientServerTestBase(testOutputHelper) +{ + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithListToolsHandler((_, _) => new ValueTask(new ListToolsResult + { + Tools = [new Tool { Name = "echo" }], + TimeToLive = TimeSpan.FromMinutes(5), + CacheScope = CacheScope.Public, + })) + .WithReadResourceHandler((request, _) => new ValueTask(new ReadResourceResult + { + Contents = [new TextResourceContents { Uri = request.Params!.Uri!, Text = "hi" }], + TimeToLive = TimeSpan.FromSeconds(30), + CacheScope = CacheScope.Private, + })); + } + + [Fact] + public async Task ListTools_PropagatesCachingHints_ToClient() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListToolsAsync( + new ListToolsRequestParams(), + TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.FromMinutes(5), result.TimeToLive); + Assert.Equal(CacheScope.Public, result.CacheScope); + } + + [Fact] + public async Task ReadResource_PropagatesCachingHints_ToClient() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ReadResourceAsync( + "test://resource", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.FromSeconds(30), result.TimeToLive); + Assert.Equal(CacheScope.Private, result.CacheScope); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs new file mode 100644 index 000000000..4f8d8cb82 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs @@ -0,0 +1,291 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Tests for the SEP-2549 caching hints (ttlMs and cacheScope) carried by +/// implementations: the results of tools/list, +/// prompts/list, resources/list, resources/templates/list, and +/// resources/read. +/// +public static class CacheableResultTests +{ + public static IEnumerable CacheableResultTypes() + { + yield return new object[] { typeof(ListToolsResult) }; + yield return new object[] { typeof(ListPromptsResult) }; + yield return new object[] { typeof(ListResourcesResult) }; + yield return new object[] { typeof(ListResourceTemplatesResult) }; + yield return new object[] { typeof(ReadResourceResult) }; + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_SerializesTtlMsAsIntegerMilliseconds(Type type) + { + var result = (ICacheableResult)Activator.CreateInstance(type)!; + result.TimeToLive = TimeSpan.FromMilliseconds(300_000); + result.CacheScope = CacheScope.Public; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind()); + Assert.Equal(300_000, node["ttlMs"]!.GetValue()); + Assert.Equal("public", node["cacheScope"]!.GetValue()); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_PrivateScope_RoundTrips(Type type) + { + var result = (ICacheableResult)Activator.CreateInstance(type)!; + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // A TTL of zero is meaningful (immediately stale) and must still be emitted. + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(0, node["ttlMs"]!.GetValue()); + Assert.Equal("private", node["cacheScope"]!.GetValue()); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_OmitsCachingHints_WhenUnset(Type type) + { + object result = Activator.CreateInstance(type)!; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // Backward compatibility: servers that do not set the hints must not emit them. + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesMissingHints_AsNull(Type type) + { + // A response from a server that predates SEP-2549 contains neither field. + string json = "{}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesNegativeTtl(Type type) + { + // Per SEP-2549, a negative ttlMs is preserved on the DTO; callers SHOULD treat it as zero. + string json = "{\"ttlMs\":-5,\"cacheScope\":\"public\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(-5), deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesOversizedTtl_ClampsInsteadOfThrowing(Type type) + { + // A hostile or buggy server could return a ttlMs that is a valid JSON integer but exceeds the + // range representable by TimeSpan. Deserialization must not throw (which would break reading the + // entire list); the value is clamped to TimeSpan.MaxValue instead. + string json = "{\"ttlMs\":9999999999999999,\"cacheScope\":\"public\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.MaxValue, deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesLargeNegativeTtl_ClampsToMinValue(Type type) + { + string json = "{\"ttlMs\":-9999999999999999}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.MinValue, deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesMaxRepresentableTtl_DoesNotThrow(Type type) + { + // The largest whole-millisecond count that fits in a TimeSpan must round-trip without clamping. + long maxWholeMs = long.MaxValue / TimeSpan.TicksPerMillisecond; + string json = $"{{\"ttlMs\":{maxWholeMs}}}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromTicks(maxWholeMs * TimeSpan.TicksPerMillisecond), deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesHugeFloatTtl_ClampsInsteadOfThrowing(Type type) + { + // A fractional/exponent ttlMs whose tick count overflows to +Infinity is clamped to MaxValue + // rather than throwing. (1e400 is beyond double range, so GetDouble() itself returns +Infinity.) + Assert.Equal( + TimeSpan.MaxValue, + DeserializeTtl(type, "{\"ttlMs\":1e400}")); + + // 1e308 is finite but overflows once scaled into tick-space. + Assert.Equal( + TimeSpan.MaxValue, + DeserializeTtl(type, "{\"ttlMs\":1e308}")); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesNegativeInfinityFloatTtl_ClampsToMinValue(Type type) + { + // A large negative exponent ttlMs yields -Infinity from GetDouble(); it must clamp to MinValue, + // not silently become long.MinValue ticks via the cast. + Assert.Equal( + TimeSpan.MinValue, + DeserializeTtl(type, "{\"ttlMs\":-1e400}")); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesUnknownCacheScope_AsNull(Type type) + { + // A future/unknown cacheScope string must not break deserialization of the entire result; it is + // tolerated and surfaced as null (equivalent to an absent field, which clients treat as public). + foreach (string scope in new[] { "\"shared\"", "\"\"", "123", "true", "null" }) + { + string json = $"{{\"cacheScope\":{scope}}}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.CacheScope); + } + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesCacheScope_CaseInsensitively(Type type) + { + // Casing of the security-relevant "private" hint must be honored rather than silently dropped to + // null (which clients treat as public), so matching is case-insensitive on read. + foreach (string scope in new[] { "PUBLIC", "Public", "pUbLiC" }) + { + var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(CacheScope.Public, result.CacheScope); + } + + foreach (string scope in new[] { "PRIVATE", "Private", "pRiVaTe" }) + { + var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(CacheScope.Private, result.CacheScope); + } + } + + private static TimeSpan? DeserializeTtl(Type type, string json) => + ((ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!).TimeToLive; + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesFractionalTtl(Type type) + { + string json = "{\"ttlMs\":1.5}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromTicks((long)(1.5 * TimeSpan.TicksPerMillisecond)), deserialized.TimeToLive); + } + + [Fact] + public static void CacheScope_SerializesAsLowercaseStrings() + { + Assert.Equal("\"public\"", JsonSerializer.Serialize(CacheScope.Public, McpJsonUtilities.DefaultOptions)); + Assert.Equal("\"private\"", JsonSerializer.Serialize(CacheScope.Private, McpJsonUtilities.DefaultOptions)); + Assert.Equal(CacheScope.Public, JsonSerializer.Deserialize("\"public\"", McpJsonUtilities.DefaultOptions)); + Assert.Equal(CacheScope.Private, JsonSerializer.Deserialize("\"private\"", McpJsonUtilities.DefaultOptions)); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesTtlWithoutCacheScope(Type type) + { + // ttlMs present, cacheScope absent: the SEP says an absent scope defaults to "public", + // but the SDK only propagates the wire value, so the DTO reports null (caller applies default). + string json = "{\"ttlMs\":1000}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromSeconds(1), deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesCacheScopeWithoutTtl(Type type) + { + // cacheScope present, ttlMs absent: a server may classify cacheability without a freshness hint. + string json = "{\"cacheScope\":\"private\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_PaginatedPages_CarryIndependentCachingHints(Type type) + { + // SEP-2549: each paginated page independently carries its own ttlMs/cacheScope. + // Two result instances representing consecutive pages must round-trip distinct hints. + var page1 = (ICacheableResult)Activator.CreateInstance(type)!; + page1.TimeToLive = TimeSpan.FromMinutes(10); + page1.CacheScope = CacheScope.Public; + + var page2 = (ICacheableResult)Activator.CreateInstance(type)!; + page2.TimeToLive = TimeSpan.FromSeconds(5); + page2.CacheScope = CacheScope.Private; + + var rt1 = (ICacheableResult)JsonSerializer.Deserialize( + JsonSerializer.Serialize(page1, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!; + var rt2 = (ICacheableResult)JsonSerializer.Deserialize( + JsonSerializer.Serialize(page2, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!; + + Assert.Equal(TimeSpan.FromMinutes(10), rt1.TimeToLive); + Assert.Equal(CacheScope.Public, rt1.CacheScope); + Assert.Equal(TimeSpan.FromSeconds(5), rt2.TimeToLive); + Assert.Equal(CacheScope.Private, rt2.CacheScope); + } + + [Fact] + public static void CacheableResult_MaxValueTtl_WriteThenRead_IsStableAcrossRoundTrips() + { + // Writing TimeSpan.MaxValue truncates the sub-millisecond remainder to a whole-millisecond + // integer (922337203685477 ms), so the first round-trip is slightly less than MaxValue. + // Critically, once written this value is a fixed point: further round-trips do not drift. + var first = RoundTrip(new ListToolsResult { TimeToLive = TimeSpan.MaxValue }); + var second = RoundTrip(new ListToolsResult { TimeToLive = first.TimeToLive }); + + Assert.NotEqual(TimeSpan.MaxValue, first.TimeToLive); + Assert.Equal(first.TimeToLive, second.TimeToLive); + } + + private static ListToolsResult RoundTrip(ListToolsResult result) => + JsonSerializer.Deserialize( + JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)!; +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs new file mode 100644 index 000000000..9cea4caa6 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs @@ -0,0 +1,57 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Regression tests guarding the behavior that is +/// shared between SEP-2549 caching hints and 's ttl/pollInterval. +/// The converter's read path was hardened to clamp out-of-range values instead of throwing; these tests +/// ensure that change did not alter behavior for normal McpTask values and that the clamping also +/// protects McpTask deserialization from hostile/oversized inputs. +/// +public static class TimeSpanMillisecondsConverterSharedTests +{ + private const string TaskEnvelope = + "{{\"taskId\":\"t1\",\"status\":\"working\",\"createdAt\":\"2024-01-01T00:00:00Z\"," + + "\"lastUpdatedAt\":\"2024-01-01T00:00:00Z\",{0}}}"; + + [Fact] + public static void McpTask_NormalTtlAndPollInterval_RoundTripUnchanged() + { + string json = string.Format(TaskEnvelope, "\"ttl\":86400000,\"pollInterval\":1000"); + + var task = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + + Assert.Equal(TimeSpan.FromDays(1), task.TimeToLive); + Assert.Equal(TimeSpan.FromSeconds(1), task.PollInterval); + + // Re-serialize and confirm the integer millisecond values are preserved exactly. + string reserialized = JsonSerializer.Serialize(task, McpJsonUtilities.DefaultOptions); + var rt = JsonSerializer.Deserialize(reserialized, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromDays(1), rt.TimeToLive); + Assert.Equal(TimeSpan.FromSeconds(1), rt.PollInterval); + } + + [Fact] + public static void McpTask_OversizedTtl_ClampsInsteadOfThrowing() + { + // The same hardening that protects cacheable results must also keep McpTask deserialization + // from throwing on an out-of-range ttl. + string json = string.Format(TaskEnvelope, "\"ttl\":9999999999999999"); + + var task = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + + Assert.Equal(TimeSpan.MaxValue, task.TimeToLive); + } + + [Fact] + public static void McpTask_NegativeInfinityPollInterval_ClampsToMinValue() + { + string json = string.Format(TaskEnvelope, "\"pollInterval\":-1e400"); + + var task = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + + Assert.Equal(TimeSpan.MinValue, task.PollInterval); + } +} From 58512d8a7a9fa387b4f8800e5ad6ebaa462ae21d Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 3 Jun 2026 15:48:45 -0700 Subject: [PATCH 12/23] Address review feedback on cacheScope converter and conformance gate - CacheScopeConverter.Read now consumes non-string tokens with reader.Skip() before returning null. Previously an object or array value for cacheScope left the reader mispositioned and threw "read too much or not enough", breaking deserialization of the whole result. Added object and array cases to the tolerant-deserialization test. - GetInstalledConformanceVersion no longer calls EnsureNpmDependenciesInstalled. The version check backs Theory skip gates and must be side-effect-free; it now returns null when the conformance package is absent. The actual scenario run path still restores npm dependencies via ConformanceTestStartInfo. --- .../Protocol/CacheScopeConverter.cs | 7 +++++++ tests/Common/Utils/NodeHelpers.cs | 12 ++++-------- .../Protocol/CacheableResultTests.cs | 3 ++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs index b67ac5576..ef61df263 100644 --- a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs +++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs @@ -40,8 +40,15 @@ internal sealed class CacheScopeConverter : JsonConverter { return CacheScope.Private; } + + return null; } + // Any non-string token (number, bool, object, array) is an unrecognized hint. Consume the whole + // value, including the contents of an object or array, so the reader is left correctly positioned + // before mapping to null. Skipping is required for container tokens: returning without consuming + // them would leave the reader mispositioned and break deserialization of the enclosing result. + reader.Skip(); return null; } diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 76ea98630..374184cfa 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -224,14 +224,10 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion var packageJsonPath = Path.Combine( repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); - // Only trigger an install if the package isn't already present. This avoids a - // gate check inadvertently running 'npm ci' (which would restore the pinned - // version and clobber a locally installed private build). - if (!File.Exists(packageJsonPath)) - { - EnsureNpmDependenciesInstalled(); - } - + // 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 + // scenario is simply gated off); the actual scenario run path restores npm dependencies + // separately via ConformanceTestStartInfo. if (!File.Exists(packageJsonPath)) { return null; diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs index 4f8d8cb82..aba38bfa0 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs @@ -172,7 +172,8 @@ public static void CacheableResult_DeserializesUnknownCacheScope_AsNull(Type typ { // A future/unknown cacheScope string must not break deserialization of the entire result; it is // tolerated and surfaced as null (equivalent to an absent field, which clients treat as public). - foreach (string scope in new[] { "\"shared\"", "\"\"", "123", "true", "null" }) + // Non-string tokens, including objects and arrays, must likewise be tolerated and fully consumed. + foreach (string scope in new[] { "\"shared\"", "\"\"", "123", "true", "null", "{}", "[]", "{\"a\":1}", "[1,2]" }) { string json = $"{{\"cacheScope\":{scope}}}"; From ed8561fa1048f24368cadaf673645963415964c1 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 15:13:51 -0700 Subject: [PATCH 13/23] Drop TimeSpanMillisecondsConverterSharedTests after McpTask removal PR #1579 (SEP-2663) replaced the SEP-1686 McpTask type with CreateTaskResult and switched its ttl/pollInterval to bare `long?` properties, so the TimeSpanMillisecondsConverter no longer has a second consumer. The shared regression suite cherry-picked from PR #1623 references the now-removed McpTask type and stops compiling. The converter's clamp-instead-of-throw branches are still fully exercised by CacheableResultTests (oversized, large-negative, +Inf, -Inf round-trips), so no coverage is lost. Drop the file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...imeSpanMillisecondsConverterSharedTests.cs | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs diff --git a/tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs deleted file mode 100644 index 9cea4caa6..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -/// -/// Regression tests guarding the behavior that is -/// shared between SEP-2549 caching hints and 's ttl/pollInterval. -/// The converter's read path was hardened to clamp out-of-range values instead of throwing; these tests -/// ensure that change did not alter behavior for normal McpTask values and that the clamping also -/// protects McpTask deserialization from hostile/oversized inputs. -/// -public static class TimeSpanMillisecondsConverterSharedTests -{ - private const string TaskEnvelope = - "{{\"taskId\":\"t1\",\"status\":\"working\",\"createdAt\":\"2024-01-01T00:00:00Z\"," + - "\"lastUpdatedAt\":\"2024-01-01T00:00:00Z\",{0}}}"; - - [Fact] - public static void McpTask_NormalTtlAndPollInterval_RoundTripUnchanged() - { - string json = string.Format(TaskEnvelope, "\"ttl\":86400000,\"pollInterval\":1000"); - - var task = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; - - Assert.Equal(TimeSpan.FromDays(1), task.TimeToLive); - Assert.Equal(TimeSpan.FromSeconds(1), task.PollInterval); - - // Re-serialize and confirm the integer millisecond values are preserved exactly. - string reserialized = JsonSerializer.Serialize(task, McpJsonUtilities.DefaultOptions); - var rt = JsonSerializer.Deserialize(reserialized, McpJsonUtilities.DefaultOptions)!; - Assert.Equal(TimeSpan.FromDays(1), rt.TimeToLive); - Assert.Equal(TimeSpan.FromSeconds(1), rt.PollInterval); - } - - [Fact] - public static void McpTask_OversizedTtl_ClampsInsteadOfThrowing() - { - // The same hardening that protects cacheable results must also keep McpTask deserialization - // from throwing on an out-of-range ttl. - string json = string.Format(TaskEnvelope, "\"ttl\":9999999999999999"); - - var task = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; - - Assert.Equal(TimeSpan.MaxValue, task.TimeToLive); - } - - [Fact] - public static void McpTask_NegativeInfinityPollInterval_ClampsToMinValue() - { - string json = string.Format(TaskEnvelope, "\"pollInterval\":-1e400"); - - var task = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; - - Assert.Equal(TimeSpan.MinValue, task.PollInterval); - } -} From 8ff1735efc705fd0241ad6033fd992d3fc17a181 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 15:19:17 -0700 Subject: [PATCH 14/23] Add ttlMs and cacheScope to DiscoverResult per spec PR #2855 SEP-2549 (PR #1623 cherry-picked in 51571c63) added the ICacheableResult contract with tlMs and cacheScope to the five list/read results, but the spec was subsequently amended by spec PR #2855 to also require both fields on server/discover responses. Implement that on DiscoverResult and emit safe defaults from the built-in handler so existing servers keep their "do not cache" behavior while remaining wire-compliant under draft. Changes: - `DiscoverResult` now implements `ICacheableResult` and carries `TimeToLive`/`CacheScope` properties with the same wire shape as the list/read results. - `ICacheableResult` xmldoc updated to mention `server/discover` alongside the existing list/read implementers. - `McpServerImpl.ConfigureDiscover` emits `ttlMs: 0` + `cacheScope: "private"` (immediately stale, not shareable) on the built-in handler. The values match halter73's design call on PR #1623: the safest defaults preserve today's behavior without requiring server authors to opt-in to caching, while still satisfying the wire requirement under draft. - `RawHttpConformanceTests.ServerDiscover_RawPost_ReturnsDiscoverResult` and `RawStreamConformanceTests.ServerDiscover_ReturnsSupportedVersionsIncludingDraft` now assert the fields are emitted with the expected values. - New `DiscoverResultCacheableTests` exercises the round-trip on `DiscoverResult` (the existing parameterized `CacheableResultTests` cannot cover it because `DiscoverResult` has required CLR properties that block reflection-based `Activator.CreateInstance`). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Protocol/DiscoverResult.cs | 23 +++- .../Protocol/ICacheableResult.cs | 5 +- .../Server/McpServerImpl.cs | 5 + .../RawHttpConformanceTests.cs | 7 + .../Protocol/DiscoverResultCacheableTests.cs | 125 ++++++++++++++++++ .../Server/RawStreamConformanceTests.cs | 7 + 6 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs index c2c5833bf..7a4e75453 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.Protocol; /// to learn what a server supports without performing the legacy initialize handshake. /// /// -public sealed class DiscoverResult : Result +public sealed class DiscoverResult : Result, ICacheableResult { /// /// Gets or sets the list of MCP protocol version strings that the server supports. @@ -43,4 +43,25 @@ public sealed class DiscoverResult : Result /// [JsonPropertyName("instructions")] public string? Instructions { get; set; } + + /// + /// + /// Spec PR #2855 makes ttlMs a required field on . The + /// server emits a safe default (, i.e. immediately stale) on + /// draft sessions when the application has not set an explicit value, preserving today's + /// "do not cache" behavior while satisfying the wire requirement. + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + /// + /// Spec PR #2855 makes cacheScope a required field on . The + /// server emits a safe default () on draft sessions + /// when the application has not set an explicit value. + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs index 33169208a..8b4986a3a 100644 --- a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs @@ -7,8 +7,9 @@ namespace ModelContextProtocol.Protocol; /// /// /// This interface corresponds to the CacheableResult type in the Model Context Protocol -/// schema and is implemented by the results of tools/list, prompts/list, -/// resources/list, resources/templates/list, and resources/read. +/// schema and is implemented by the results of server/discover, tools/list, +/// prompts/list, resources/list, resources/templates/list, and +/// resources/read. /// /// /// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index b90e51393..71873927b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -444,6 +444,11 @@ private void ConfigureDiscover(McpServerOptions options) Capabilities = ServerCapabilities ?? new(), ServerInfo = options.ServerInfo ?? DefaultImplementation, Instructions = options.ServerInstructions, + // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to + // the safest values (immediately stale, not shareable) so existing servers keep + // their "do not cache" behavior while satisfying the wire requirement. + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, }); }, McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index b39873790..909325c66 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -6,6 +6,7 @@ using ModelContextProtocol.Tests.Utils; using System.Net; using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.AspNetCore.Tests; @@ -126,6 +127,12 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Contains(DraftVersion, 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. + Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind()); + Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue()); + Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs new file mode 100644 index 000000000..001c05f95 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -0,0 +1,125 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Targeted tests for the SEP-2549 caching hints (ttlMs and cacheScope) on +/// . Spec PR #2855 promotes both fields to required on the discover +/// response. has required CLR properties for +/// , , and +/// , which prevents reuse of the parameterized +/// helper (it instantiates via reflection). This file covers the +/// same property-shape assertions for . +/// +public static class DiscoverResultCacheableTests +{ + private static DiscoverResult NewDiscoverResult() => new() + { + SupportedVersions = ["2025-11-25", McpSession.DraftProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, + }; + + [Fact] + public static void DiscoverResult_SerializesTtlMsAsIntegerMilliseconds() + { + var result = NewDiscoverResult(); + result.TimeToLive = TimeSpan.FromMilliseconds(300_000); + result.CacheScope = CacheScope.Public; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind()); + Assert.Equal(300_000, node["ttlMs"]!.GetValue()); + Assert.Equal("public", node["cacheScope"]!.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_PrivateScope_RoundTrips() + { + var result = NewDiscoverResult(); + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(0, node["ttlMs"]!.GetValue()); + Assert.Equal("private", node["cacheScope"]!.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_OmitsCachingHints_WhenUnset() + { + var result = NewDiscoverResult(); + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // Backward compatibility: servers that do not set the hints must not emit them. + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_DeserializesMissingHints_AsNull() + { + // A response from a pre-PR-#2855 server may omit both fields. Deserialization must succeed + // and surface them as null so callers can apply their own defaults. + string json = + """ + { + "supportedVersions": ["2025-11-25"], + "capabilities": {}, + "serverInfo": {"name": "x", "version": "1"} + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_DeserializesUnknownCacheScope_AsNull() + { + // A future or unknown cacheScope string must not break deserialization of the entire result. + string json = + """ + { + "supportedVersions": ["2025-11-25"], + "capabilities": {}, + "serverInfo": {"name": "x", "version": "1"}, + "cacheScope": "shared" + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_ImplementsICacheableResult() + { + // Compile-time assertion that DiscoverResult participates in the shared cacheability surface + // alongside the list/read result types. + Assert.IsAssignableFrom(NewDiscoverResult()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 6ca6322cd..ccc87d9d3 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -5,6 +5,7 @@ using ModelContextProtocol.Tests.Utils; using System.IO.Pipelines; using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; @@ -101,6 +102,12 @@ public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft() Assert.NotNull(result["capabilities"]); Assert.NotNull(result["serverInfo"]); Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue()); + + // 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. + Assert.Equal(JsonValueKind.Number, result["ttlMs"]!.GetValueKind()); + Assert.Equal(0, result["ttlMs"]!.GetValue()); + Assert.Equal("private", result["cacheScope"]!.GetValue()); } [Fact] From e162a8aaa377bf48d19a3fda887751d5881bd16a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 15:56:14 -0700 Subject: [PATCH 15/23] Add regression tests for SEP-2575 _meta envelope on list-style requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec PR #2759 promotes params._meta to required on tools/list, resources/list, resources/templates/list, prompts/list, and server/discover under draft. The C# client already injects the SEP-2575 envelope on every outgoing request via McpSessionHandler.InjectDraftMeta when the session has negotiated draft; this test file pins that behavior so future refactors cannot silently regress the envelope on list-style requests when the caller passes no params/RequestOptions. Six tests under ClientServerTestBase: - DraftClient_ListTools_NoOptions_EmitsRequiredMeta - DraftClient_ListPrompts_NoOptions_EmitsRequiredMeta - DraftClient_ListResources_NoOptions_EmitsRequiredMeta - DraftClient_ListResourceTemplates_NoOptions_EmitsRequiredMeta - DraftClient_ServerDiscover_EmitsRequiredMeta - LegacyClient_ListTools_DoesNotEmitDraftMeta (negative control) The four list-method tests attach server-side request filters that capture request.Params?.Meta and assert the three required SEP-2575 keys (protocolVersion, clientInfo, clientCapabilities) are present plus that protocolVersion matches the negotiated draft revision. The server/discover test asserts round-trip success — if the client had omitted _meta, the server would have rejected with -32602/-32003 rather than returning a DiscoverResult; the wire-level shape is covered by the existing RawHttp/RawStream conformance tests. The legacy negative-control test pins that the draft-meta injector is gated correctly on the negotiated protocol version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/DraftListMetaEmissionTests.cs | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs diff --git a/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs new file mode 100644 index 000000000..03fedb053 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Verifies that the C# client emits the SEP-2575 _meta envelope on every list-style +/// request (and on server/discover) under the draft protocol revision, even when the +/// caller supplies no RequestOptions / no params. +/// +/// +/// Spec PR #2759 promotes params._meta to required on tools/list, +/// resources/list, resources/templates/list, prompts/list, and +/// server/discover under draft. This test class drives the C# client through +/// with the draft revision negotiated, attaches a request +/// filter on each list endpoint that captures the incoming _meta envelope, and asserts +/// the three required SEP-2575 keys are present: +/// io.modelcontextprotocol/protocolVersion, +/// io.modelcontextprotocol/clientInfo, and +/// io.modelcontextprotocol/clientCapabilities. +/// +public class DraftListMetaEmissionTests : ClientServerTestBase +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + // Captured _meta envelopes for each request method we exercise. Populated by the per-method + // server-side filters and asserted from each test method. + private readonly Dictionary _capturedMeta = new(StringComparer.Ordinal); + + public DraftListMetaEmissionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithRequestFilters(filters => + { + filters.AddListToolsFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ToolsList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListPromptsFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.PromptsList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListResourcesFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ResourcesList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListResourceTemplatesFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ResourcesTemplatesList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + }); + + // No-op list handlers (so the requests complete) — content is irrelevant; we only assert the + // incoming envelope. + mcpServerBuilder + .WithListToolsHandler((_, _) => new ValueTask(new ListToolsResult { Tools = [] })) + .WithListPromptsHandler((_, _) => new ValueTask(new ListPromptsResult { Prompts = [] })) + .WithListResourcesHandler((_, _) => new ValueTask(new ListResourcesResult { Resources = [] })) + .WithListResourceTemplatesHandler((_, _) => new ValueTask( + new ListResourceTemplatesResult { ResourceTemplates = [] })); + } + + [Fact] + public async Task DraftClient_ListTools_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ToolsList); + } + + [Fact] + public async Task DraftClient_ListPrompts_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.PromptsList); + } + + [Fact] + public async Task DraftClient_ListResources_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ResourcesList); + } + + [Fact] + public async Task DraftClient_ListResourceTemplates_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ResourcesTemplatesList); + } + + [Fact] + public async Task DraftClient_ServerDiscover_EmitsRequiredMeta() + { + // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, + // which still flows through the client's draft-meta injector. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + // 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. + // Easier path: rely on the existing JsonRpcRequest capture in the message context — see the + // raw conformance tests for the wire-level proof. For this in-process test, we instead drive + // the request and rely on the response being a valid DiscoverResult; the draft meta injector + // would otherwise have failed the server's per-request envelope validation. + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + Assert.NotNull(response.Result); + var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; + Assert.Contains(DraftVersion, discover.SupportedVersions); + + // The server enforces draft envelope shape per request; if the client had omitted _meta, the + // request would have failed with -32602 / -32003 rather than returning a DiscoverResult. The + // successful round-trip is the assertion. + } + + [Fact] + public async Task LegacyClient_ListTools_DoesNotEmitDraftMeta() + { + // Sanity guard: the legacy (non-draft) client must NOT emit the SEP-2575 envelope — the meta + // injector is gated on the negotiated protocol version. If this ever started writing draft keys + // under legacy protocols, every legacy server would reject the request. + StartServer(); + await using var client = await CreateMcpClientForServer(); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var meta = _capturedMeta[RequestMethods.ToolsList]; + if (meta is not null) + { + Assert.False(meta.ContainsKey(NotificationMethods.ProtocolVersionMetaKey)); + Assert.False(meta.ContainsKey(NotificationMethods.ClientInfoMetaKey)); + Assert.False(meta.ContainsKey(NotificationMethods.ClientCapabilitiesMetaKey)); + } + } + + private void AssertDraftMetaPresent(string method) + { + Assert.True(_capturedMeta.TryGetValue(method, out var meta), $"No capture for {method}"); + Assert.NotNull(meta); + Assert.True(meta!.ContainsKey(NotificationMethods.ProtocolVersionMetaKey), + $"Missing protocolVersion key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(NotificationMethods.ClientInfoMetaKey), + $"Missing clientInfo key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(NotificationMethods.ClientCapabilitiesMetaKey), + $"Missing clientCapabilities key on {method} _meta envelope"); + + // The protocolVersion value must match the negotiated draft version. + Assert.Equal(DraftVersion, meta[NotificationMethods.ProtocolVersionMetaKey]!.GetValue()); + } +} From ae1edef4a2d6e12ae268864b9de5887a5614d44c Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 15:57:08 -0700 Subject: [PATCH 16/23] Clarify stdio fallback comment per spec PR #2844 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe-error fallback comment in McpClientImpl.cs listed -32601/-32602/-32700 as the exemplar error codes that trigger the legacy-server fallback. Spec PR #2844 (June 3 2026) clarifies that the fallback MUST NOT be keyed to a single error code: any non-modern JSON-RPC error or probe timeout means legacy. Our code already does the spec-conformant thing — the catch block is keyed on the McpProtocolException base class, not specific error codes — but the enumeration in the comment was misleading. Replace it with text that makes the spec-conformant intent explicit and notes that the two modern-server signals (-32004 UnsupportedProtocolVersion, -32003 MissingRequiredClientCapability) are caught upstream and never reach this branch. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/McpClientImpl.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index efd0ef367..22987bee0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -342,11 +342,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } catch (McpProtocolException) { - // Any other JSON-RPC error from the probe indicates a legacy server (e.g., - // -32601 MethodNotFound, -32602 InvalidParams from a server confused by _meta, - // -32700 ParseError from a server that can't handle our payload shape). - // Per the spec's stdio fallback rules, treat all non-modern errors as a - // legacy-server signal and fall back to the initialize handshake. + // 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. + // 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 two modern-server + // signals (-32004 UnsupportedProtocolVersion, -32003 + // MissingRequiredClientCapability) are caught above and never reach here. fallbackToLegacy = true; } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) From ccdd4223d623a12098f81b53758858083e693614 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 16:57:50 -0700 Subject: [PATCH 17/23] Accept null-id JSON-RPC error responses per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON-RPC 2.0 §5.1 requires the server to respond with `id: null` when an error occurs before the request id can be determined (e.g. parse error, invalid request, or transport-level rejection). The `RequestId.Converter` previously threw on a `null` token and `JsonRpcMessage.Converter` rejected responses with both `error` and a null `id`, so any peer that legitimately produced that shape (e.g. Python's `simple-streamablehttp-stateless` on a 400) was surfaced as `HttpRequestException` instead of the structured JSON-RPC error that the SEP-2575 fallback logic needs. Cross-SDK testing against Python's `main` branch reproduces the shape: `{""jsonrpc"":""2.0"",""id"":null,""error"":{""code"":-32600,...}}`. Accept that shape so the draft-to-legacy fallback path can recognize it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Protocol/JsonRpcMessage.cs | 13 ++++++++ .../Protocol/RequestId.cs | 3 +- .../Protocol/JsonRpcMessageConverterTests.cs | 32 +++++++++++++++++++ .../Protocol/RequestIdTests.cs | 11 +++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 1dfef5de1..4bbe22ac2 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -200,6 +200,19 @@ public sealed class Converter : JsonConverter throw new JsonException("Response must have either result or error"); } + if (error is not null) + { + // 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). + return new JsonRpcError + { + Id = id, + Error = error + }; + } + // Error: Messages with neither id nor method are invalid throw new JsonException("Invalid JSON-RPC message format"); } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestId.cs b/src/ModelContextProtocol.Core/Protocol/RequestId.cs index 47a6fde61..b20216ba3 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestId.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestId.cs @@ -66,7 +66,8 @@ public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, Js { JsonTokenType.String => new(reader.GetString()!), JsonTokenType.Number => new(reader.GetInt64()), - _ => throw new JsonException("requestId must be a string or an integer"), + JsonTokenType.Null => default, + _ => throw new JsonException("requestId must be a string, integer, or null"), }; } diff --git a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs index ddab6b142..b8061cc19 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs @@ -760,4 +760,36 @@ public static void Deserialize_ErrorWithArrayData_IsValid() var error = (JsonRpcError)message; Assert.NotNull(error.Error.Data); } + + [Fact] + public static void Deserialize_ErrorWithNullId_IsValid() + { + // Per JSON-RPC 2.0 §5.1, when an error occurs before the request id can be determined + // (parse error or invalid request), the server MUST respond with id=null. This shape is + // produced by some peers (e.g. Python's simple-streamablehttp-stateless on a draft probe) + // and must be accepted so the HTTP-fallback path can recognize the structured signal. + string json = """{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request"}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(message); + var error = Assert.IsType(message); + Assert.Equal(default(RequestId), error.Id); + Assert.Equal(-32600, error.Error.Code); + Assert.Equal("Bad Request", error.Error.Message); + } + + [Fact] + public static void Deserialize_ErrorWithMissingId_IsValid() + { + // Some peers omit `id` entirely on pre-routing errors; treat as null per JSON-RPC 2.0 §5.1. + string json = """{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(message); + var error = Assert.IsType(message); + Assert.Equal(default(RequestId), error.Id); + Assert.Equal(-32700, error.Error.Code); + } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs index e426c7469..8ef150fe6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs @@ -35,4 +35,15 @@ public void Int64Ctor_Roundtrips() Assert.Equal(id, JsonSerializer.Deserialize(JsonSerializer.Serialize(id, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)); } + + [Fact] + public void Null_DeserializesAsDefault() + { + // Per JSON-RPC 2.0 §5.1, error responses produced before the request id can be determined + // MUST carry id=null. Deserialization needs to tolerate that shape so callers can handle + // such error envelopes (instead of throwing on the bare RequestId conversion). + var id = JsonSerializer.Deserialize("null", McpJsonUtilities.DefaultOptions); + Assert.Equal(default(RequestId), id); + Assert.Null(id.Id); + } } From 00d57f71fec066956b26987d9f4258584995ec5d Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 16:58:02 -0700 Subject: [PATCH 18/23] Surface JSON-RPC errors in HTTP 400 bodies as McpProtocolException Per spec PR #2844 (HTTP backwards compatibility for SEP-2575), a 400 Bad Request that carries a JSON-RPC error envelope means the peer is signalling something application-level about the request shape (not a transport failure). The connect-time fallback path needs to see the structured exception so it can decide between retrying with a server-advertised version (-32004), surfacing a capability gap to the caller (-32003, -32001), or falling back to legacy `initialize` for any other JSON-RPC error code (e.g. -32600 from a legacy server that doesn't understand the draft `_meta` envelope). Previously only the three modern draft error codes were surfaced; everything else became `HttpRequestException` and bypassed the fallback chain. Now any JSON-RPC error in a 400 body becomes `McpProtocolException`, plus the three modern codes are surfaced for non-400 status codes for robustness (servers occasionally pair them with other 4xx codes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../StreamableHttpClientSessionTransport.cs | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index b3eb00718..e13745f97 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -64,12 +64,18 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation // Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it. using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false); - // For unsuccessful responses, surface structured JSON-RPC errors with codes introduced by the - // draft protocol revision (SEP-2575) — UnsupportedProtocolVersion (-32004) and - // MissingRequiredClientCapability (-32003) — as typed McpProtocolException so the client's - // connection logic can react (e.g., fall back to legacy initialize on version mismatch). - // Other JSON-RPC errors carried in 4xx/5xx bodies (e.g., 403 forbidden, 404 session-not-found) - // continue to surface as HttpRequestException to preserve back-compat with existing behavior. + // 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 draft-protocol error codes + // (-32004 UnsupportedProtocolVersion, -32003 MissingRequiredClientCapability, + // -32001 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from + // legacy servers that don't understand the draft _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 draft-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 && response.Content.Headers.ContentType?.MediaType == "application/json") { @@ -85,7 +91,8 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation if (!string.IsNullOrEmpty(body) && TryParseJsonRpcError(body, out var parsedError) && - ShouldSurfaceAsStructuredException((McpErrorCode)parsedError.Error.Code)) + (response.StatusCode == HttpStatusCode.BadRequest || + IsModernDraftErrorCode((McpErrorCode)parsedError.Error.Code))) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -94,8 +101,10 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool ShouldSurfaceAsStructuredException(McpErrorCode code) => - code is McpErrorCode.UnsupportedProtocolVersion or McpErrorCode.MissingRequiredClientCapability; + private static bool IsModernDraftErrorCode(McpErrorCode code) => + code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; private static bool TryParseJsonRpcError(string body, out JsonRpcError parsedError) { From 276bde45f63490448468bbc37b2dcc844dfbfe52 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 16:58:14 -0700 Subject: [PATCH 19/23] Surface HeaderMismatch errors instead of falling back to legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpClientImpl.ConnectAsync's draft probe path catches McpProtocolException to fall back to a legacy `initialize` exchange. The handler had passthrough cases for the two modern draft error codes whose semantics are not ""I'm a legacy server"" (UnsupportedProtocolVersion -32004 and MissingRequiredClientCapability -32003) but was missing the third: HeaderMismatch -32001 from SEP-2243. That code means the body's `_meta.io.modelcontextprotocol/protocolVersion` does not match the `MCP-Protocol-Version` HTTP header — which is a client-side bug that should surface to the caller, not a signal to retry with `initialize`. Add the third passthrough so all three modern draft codes propagate, with an in-memory transport regression test that exercises the path against a server which only returns `-32001`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/McpClientImpl.cs | 13 ++++++++-- .../Client/DraftProtocolFallbackTests.cs | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 22987bee0..f08fbf7bf 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -340,6 +340,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // 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: -32001. The server is modern but 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. + throw; + } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code — @@ -347,9 +355,10 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // 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 two modern-server + // payload shape, or any other transport-defined error. The three modern-server // signals (-32004 UnsupportedProtocolVersion, -32003 - // MissingRequiredClientCapability) are caught above and never reach here. + // MissingRequiredClientCapability, -32001 HeaderMismatch) are caught above and + // never reach here. fallbackToLegacy = true; } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) diff --git a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs index 27e1525f6..f3ac08861 100644 --- a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs @@ -96,6 +96,30 @@ public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task DraftClient_OnHeaderMismatch_Surfaces_NoFallback() + { + // The peer is modern (returns the spec-defined -32001 HeaderMismatch on the probe). + // Falling back to legacy 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( + serverNegotiatedVersion: "2025-11-25", + probeErrorCode: (int)McpErrorCode.HeaderMismatch); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.True(transport.ServerDiscoverProbed); + Assert.False(transport.LegacyInitializeReceived); + Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); + } + /// /// Minimal in-memory transport that simulates a legacy server: rejects /// server/discover (with a configurable JSON-RPC error code) and From 3778e00e7e1534de200d596c2cfa88e26e7d352f Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 16:58:39 -0700 Subject: [PATCH 20/23] Recognize JSON-RPC errors in AutoDetect HTTP transport (no SSE fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoDetectingClientSessionTransport's job is to pick between StreamableHttp (post-SEP-2243) and legacy SSE based on the server's response to the first POST. The detection logic only checked the HTTP status code, so a draft server returning `400 Bad Request` with a JSON-RPC error body (e.g. `-32004` UnsupportedProtocolVersion or `-32600` from a legacy server that didn't understand the draft `_meta` envelope) was being treated as ""this isn't StreamableHttp"" — and the transport silently fell back to SSE, breaking fallback negotiation against any HTTP/1.1 peer that follows the spec. Detect a JSON-RPC error envelope in the 400 body, adopt StreamableHttp, then re-throw the structured exception so McpClientImpl can dispatch on the error code (retry with advertised version, surface capability gap, or fall back to `initialize`). Use a deferred throw guarded by `catch when (ActiveTransport is null)` to preserve transport ownership across the deferred error path. Cross-SDK validation: against vanilla Go SDK `origin/main` HTTP everything server, default `--http-mode autodetect` now successfully adopts StreamableHttp, recognizes `-32004` with `data.supported`, retries with `2025-11-25`, and lists 10 tools. Against Python `main`'s `simple-streamablehttp-stateless`, the same flow recognizes the `-32600` returned from the draft probe, adopts StreamableHttp, and falls back to `initialize` with `2025-06-18` to complete the handshake. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AutoDetectingClientSessionTransport.cs | 65 +++- .../DraftHttpFallbackTests.cs | 305 ++++++++++++++++++ 2 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 209d644d2..09d580979 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Net; +using System.Net.Http; +using System.Text.Json; using System.Threading.Channels; namespace ModelContextProtocol.Client; @@ -62,6 +64,7 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can { // Try StreamableHttp first var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory); + McpProtocolException? structuredError = null; try { @@ -73,22 +76,76 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; } + else if (await TryGetJsonRpcErrorFromResponseAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) + { + // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server + // — it just rejected our specific request (e.g., -32004 UnsupportedProtocolVersion, + // -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other + // application-level error). Don't fall back to SSE — that would mask the real signal + // and surface a misleading "session id required" error from the SSE GET path. + // Adopt the Streamable HTTP transport and surface the structured exception to the + // caller so the connect-time fallback logic can react per spec PR #2844. + LogUsingStreamableHttp(_name); + ActiveTransport = streamableHttpTransport; + structuredError = McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } else { - // If the status code is not success, fall back to SSE + // Non-JSON-RPC error response: either the server doesn't speak MCP at all, or this + // is an older deployment that expects the SSE transport (which establishes its + // protocol via GET /sse rather than POST). Fall back to SSE per the original + // behavior. LogStreamableHttpFailed(_name, response.StatusCode); await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false); } } - catch + catch when (ActiveTransport is null) { - // If nothing threw inside the try block, we've either set streamableHttpTransport as the - // ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block. + // Only dispose the Streamable HTTP transport when we didn't adopt it. If we set + // ActiveTransport above (success path OR structured-error path), the transport's + // lifetime is owned by the outer transport from this point on. await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); throw; } + + if (structuredError is not null) + { + throw structuredError; + } + } + + private static async Task TryGetJsonRpcErrorFromResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentType?.MediaType != "application/json") + { + return null; + } + + string body; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + return null; + } + + if (string.IsNullOrEmpty(body)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.JsonRpcMessage) as JsonRpcError; + } + catch + { + return null; + } } private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs new file mode 100644 index 000000000..55401c15a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs @@ -0,0 +1,305 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Regression tests for the draft-protocol-to-legacy fallback path 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 draft probe; vanilla Go does the same on POST /) so the client's HTTP-fallback +/// logic can be exercised in isolation without the cross-SDK harness. +/// +/// +/// +/// Two latent bugs were discovered during cross-SDK testing and fixed by the SEP-2575 / SEP-2567 +/// branch: +/// +/// +/// +/// only surfaced the three modern draft +/// error codes (-32004, -32003, -32001) as ; +/// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server +/// that doesn't understand the draft _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. +/// +/// +/// 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 +/// either fail with "session id required" or succeed against a different endpoint and lose +/// the actual signal. +/// +/// +/// +public class DraftHttpFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private async Task StartServerAsync(RequestDelegate handler) + { + Builder.Services.Configure(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); + + _app = Builder.Build(); + _app.MapPost("/mcp", handler); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + + private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatusCode statusCode, int code, string message) + { + var rpcError = new JsonRpcError + { + Id = default, + Error = new JsonRpcErrorDetail { Code = code, Message = message }, + }; + + context.Response.StatusCode = (int)statusCode; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), context.RequestAborted); + } + + /// + /// Mimics Python's simple-streamablehttp-stateless on a draft probe: returns + /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the + /// initial server/discover, then performs a normal legacy initialize handshake + /// when the client falls back. + /// + [Fact] + public async Task DraftClient_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + { + var ct = TestContext.Current.CancellationToken; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is not JsonRpcRequest request) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + // Draft probe: simulate a legacy 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) + { + await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, code: -32600, message: "Bad Request: Unsupported protocol version: draft"); + return; + } + + // Legacy initialize: respond with the highest version the legacy server speaks. + if (request.Method == RequestMethods.Initialize) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-06-18", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "legacy", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + if (request.Method == RequestMethods.ToolsList) + { + var response = new JsonRpcResponse + { + Id = request.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; + }); + + // Default AutoDetect transport — exercises BOTH fixes (AutoDetect adopting StreamableHttp + // on JSON-RPC-error 400, and SendMessageAsync surfacing -32600 as McpProtocolException). + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + + // Sanity: subsequent traffic still works post-fallback. + var tools = await client.ListToolsAsync(cancellationToken: ct); + Assert.Empty(tools); + } + + /// + /// Mimics vanilla Go: returns 400 + JSON-RPC -32004 with + /// data.supported[] on a draft probe so the client retries legacy + /// initialize with one of the advertised versions. + /// + [Fact] + public async Task DraftClient_OnUnsupportedProtocolVersion_AdoptsStreamableHttp_NoSseFallback() + { + var ct = TestContext.Current.CancellationToken; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is not JsonRpcRequest request) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + if (request.Method == RequestMethods.ServerDiscover) + { + // -32004 with the spec-shaped data: client should retry with one of supported[]. + // 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 = "draft", + }, GetJsonTypeInfo()); + + var rpcError = new JsonRpcError + { + Id = request.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = "Unsupported protocol version", + Data = data, + }, + }; + + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), ct); + return; + } + + if (request.Method == RequestMethods.Initialize) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-11-25", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + 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); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + /// + /// A 400 with a JSON-RPC -32001 HeaderMismatch envelope must be surfaced to the + /// caller (no legacy fallback) — falling back wouldn't fix a malformed envelope. + /// + [Fact] + public async Task DraftClient_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() + { + var ct = TestContext.Current.CancellationToken; + bool initializeReceived = false; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) + { + initializeReceived = true; + } + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }) + { + await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, + code: (int)McpErrorCode.HeaderMismatch, + message: "Header mismatch: MCP-Protocol-Version did not match body _meta"); + 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 = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); + Assert.False(initializeReceived); + } +} From d539e7fd580dbdc6cafd2a0a84f76e555a115a2a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 17:35:58 -0700 Subject: [PATCH 21/23] Bump @modelcontextprotocol/conformance pin from 0.1.16 to 0.2.0-alpha.2 The latest published prerelease (0.2.0-alpha.2, Jun 3 2026) adds gated scenarios for SEP-2243 (HTTP headers), SEP-2549 (caching), and SEP-2322 (MRTR/incomplete result). Pinning to it makes the 14 currently-skipped scenarios available to HasSep2243Scenarios()/HasCachingScenario()/HasMrtrScenarios(). However, alpha.2 still ships the placeholder wire string 'DRAFT-2026-v1' for draft scenarios, while this SDK (and the conformance main branch, awaiting alpha.3 publish) emits the spec-ratified '2026-07-28'. Without an additional gate, the 14 newly-activated scenarios all fail with mismatched draft wire strings. The next commit tightens the gates with a HasMatchingDraftWireVersion() guard so they remain skipped on alpha.2 and activate on alpha.3+ (or on a local build of conformance main installed via 'npm install --no-save'). Baseline test surface unchanged: 126 pass / 14 skip / 2 pre-existing skip in ConformanceTests on net9.0 (one pre-existing failure in HttpHeaderConformanceTests.Server_RejectsInvalidUtf8EncodedHeaderValue predates this change). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 28 ++++++++++++++-------------- package.json | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 521815617..77ce83884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "csharp-sdk", + "name": "halter73-expert-train", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.1.16", + "@modelcontextprotocol/conformance": "0.2.0-alpha.2", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,18 +23,18 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.1.16", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.16.tgz", - "integrity": "sha512-GI7qiN0r39/MH2srVUR3AXaEN0YLCro20lIBbnvc1frBhszenxvUifBuTzxeVQVagILfBzCIcnungUOma8OrgA==", + "version": "0.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.2.tgz", + "integrity": "sha512-/8bde9d0mfsvgd9IwQgNIl1AS9uNOp/+ZG+2nNRWXtPs6xrz/cNp4ObBMmGY9kP8dkDaF3bvjtC/2Hj8TStMRg==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", "commander": "^14.0.2", - "eventsource-parser": "^3.0.6", + "eventsource-parser": "^3.0.8", "express": "^5.1.0", "jose": "^6.1.2", - "undici": "^7.19.0", + "undici": "^7.25.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, @@ -602,9 +602,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -1428,9 +1428,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index dd8dedfe3..21d33001c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.1.16", + "@modelcontextprotocol/conformance": "0.2.0-alpha.2", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } From f3698c719df9e0fc61e8f9eaf7901eab26eb3456 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 17:36:12 -0700 Subject: [PATCH 22/23] Tighten conformance gates with HasMatchingDraftWireVersion guard The previous gates activated draft-only scenarios as soon as the conformance package version reached 0.2.0. That works for conformance >= 0.2.0-alpha.3 (or a local build of main) where the bundled DRAFT_PROTOCOL_VERSION constant matches this SDK's value, but breaks under 0.2.0-alpha.2 because alpha.2 still ships the placeholder 'DRAFT-2026-v1' wire string while this SDK only accepts the ratified '2026-07-28'. Add HasMatchingDraftWireVersion() that greps the bundled node_modules/@modelcontextprotocol/conformance/dist/index.js for this SDK's McpHttpHeaders.DraftProtocolVersion (the bundle is minified so we can't grep the constant name, but the literal version string survives bundling and is specific enough to be reliable). AND it into the three gates: HasSep2243Scenarios(), HasCachingScenario(), HasMrtrScenarios(). Also unify HasMrtrScenarios() with HasSep2243Scenarios()/HasCachingScenario(): read the installed version from node_modules instead of the pinned version from package.json. This lets a local 'npm install --no-save ' activate MRTR scenarios the same way it already activates SEP-2243/caching. Under 0.2.0-alpha.2 (this PR's pin), the 14 gated draft scenarios all SKIP cleanly instead of failing on wire-string mismatch. Once 0.2.0-alpha.3 publishes (or a local main build is installed), the gates auto-activate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Common/Utils/NodeHelpers.cs | 92 +++++++++++++++++++------------ 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 374184cfa..b549bdd76 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Tests.Utils; @@ -187,8 +188,12 @@ public static bool IsNodeInstalled() /// 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>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . /// - public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + public static bool HasSep2243Scenarios() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance @@ -197,8 +202,47 @@ public static bool IsNodeInstalled() /// 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>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . /// - public static bool HasCachingScenario() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + public static bool HasCachingScenario() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); + + /// + /// Returns when the installed conformance package's bundled + /// dist emits the same draft protocol version string as this SDK + /// (). Used to suppress draft-only + /// conformance scenarios when the published conformance binary is still pinned to a + /// stale wire string (for example, conformance 0.2.0-alpha.2 ships + /// "DRAFT-2026-v1" while this SDK speaks "2026-07-28"). + /// + /// + /// This check is a pragmatic alternative to inspecting the conformance package's + /// internal constants: the bundled dist/index.js is minified so we can't grep + /// the constant name, but the literal version string survives bundling and is unique + /// enough to be a reliable signal. + /// + public static bool HasMatchingDraftWireVersion() + { + try + { + var repoRoot = FindRepoRoot(); + var distPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); + if (!File.Exists(distPath)) + { + return false; + } + + var bundled = File.ReadAllText(distPath); + return bundled.Contains(McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); + } + catch + { + return false; + } + } /// /// Returns when the conformance package installed in node_modules @@ -373,42 +417,20 @@ private static bool ConformanceOutputIndicatesSuccess(string output) } /// - /// Checks whether the SEP-2322 (Multi Round-Trip Requests / IncompleteResult) - /// conformance scenarios are available by reading the conformance package version - /// from the repo's package.json. MRTR scenarios require a conformance package version - /// that includes SEP-2322 support (see + /// 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>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . /// public static bool HasMrtrScenarios() - { - try - { - var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine(repoRoot, "package.json"); - if (!File.Exists(packageJsonPath)) - { - return false; - } - - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("dependencies", out var deps) && - deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) - { - var versionStr = versionElement.GetString(); - if (versionStr is not null && Version.TryParse(versionStr, out var version)) - { - // SEP-2322 scenarios are expected in conformance package >= 0.2.0 - return version >= new Version(0, 2, 0); - } - } - - return false; - } - catch - { - return false; - } - } + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { From d9277e0cc06f382e4c1263a5aa1f6508d45ef31a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 11 Jun 2026 17:50:45 -0700 Subject: [PATCH 23/23] tests: align conformance fixtures with @modelcontextprotocol/conformance main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conformance suite's main branch (post 0.2.0-alpha.2) made two breaking changes that our pinned wire-name / spec-version contracts had to follow: 1. `DRAFT-2026-v1` wire literal flipped to `2026-07-28` (commit 6f83abf in the conformance repo). 2. `incomplete-result-*` server scenarios renamed to `input-required-result-*` and extended from 8 to 14 scenarios (PR #262 in the conformance repo, follow-on to our own MRTR rename in PR #1458 where `IncompleteResult` became `InputRequiredResult`). Track A (the published 0.2.0-alpha.2 pin) is unaffected and continues to skip these tests via the wire-version-match gate introduced in `f3698c71`. Track B (a private install of the conformance `main` branch with the four merged sessionless/MRTR/error-code/tasks PRs) now exercises 12 of the 14 MRTR scenarios end-to-end against this SDK's `ConformanceServer`. Changes in this commit: - `IncompleteResultTools.cs` and `IncompleteResultPrompts.cs` — rename 6 tool wire names plus 1 prompt wire name from `test_incomplete_result_*` (and `test_tool_with_elicitation`) to `test_input_required_result_*` so the conformance scenarios can find them. The C# class names are intentionally left as `Incomplete*` to keep the diff minimal; the comment block above `RunMrtrConformanceTest` documents the asymmetry. - `ServerConformanceTests.cs` — replace the 8-row MRTR theory with the 14-row theory matching the new conformance scenario set; flip the two `--spec-version DRAFT-2026-v1` references to `2026-07-28`; mark two scenarios skipped that require server-side patterns the `ConformanceServer` tools don't yet implement (HMAC-signed requestState for `input-required-result-tampered-state`; per-request capability gating for `input-required-result-capability-check`). Those scenarios are still feature-flagged behind the wire-version gate so they only attempt to run when the installed conformance package speaks `2026-07-28`. - `CachingConformanceTests.cs` — flip the `--spec-version` reference and rewrite the doc remarks to explain the wire-version-match gate. Validation: `dotnet test` over the targeted slice (5 conformance test classes) under serial run reports 17 pass / 1 fail / 2 skip against the private `compat/conformance-draft` build. The single failure (`Sep2243.http-custom-headers`) is a pre-existing C# SDK client bug exposed by the conformance-pin bump: the client sends no `Mcp-Param-*` headers when the server's tool advertises `x-mcp-header` annotations. The bug exists on `origin/main` (`git diff origin/main..HEAD -- src/ModelContextProtocol.Core/Client` on these files is empty) and is unrelated to the SEP-2575 / SEP-2567 draft work in this PR. Tracking as a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CachingConformanceTests.cs | 16 +++--- .../ServerConformanceTests.cs | 49 +++++++++++++------ .../Prompts/IncompleteResultPrompts.cs | 4 +- .../Tools/IncompleteResultTools.cs | 18 +++---- 4 files changed, 53 insertions(+), 34 deletions(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 5cdd2948a..27cba0d40 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -105,12 +105,14 @@ public async ValueTask DisposeAsync() /// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). /// /// -/// The scenario is draft-only (introduced in DRAFT-2026-v1) and uses the stateless lifecycle. -/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when -/// running against the currently-pinned package, so it activates automatically once a -/// conformance package containing the caching scenario is installed (including a local private -/// build installed via npm install --no-save <path-to-conformance>). The stateless -/// server is started only after the gates pass, so a skipped run binds no port. +/// The scenario is draft-only (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) +/// AND on the installed package emitting the draft wire string this SDK speaks (so it stays +/// skipped under conformance 0.2.0-alpha.2 which still ships the placeholder +/// DRAFT-2026-v1). It activates automatically once a conformance package emitting +/// 2026-07-28 is installed (e.g. via +/// npm install --no-save <path-to-conformance>). The stateless server is +/// started only after the gates pass, so a skipped run binds no port. /// public class CachingConformanceTests(ITestOutputHelper output) { @@ -128,7 +130,7 @@ public async Task RunCachingConformanceTest() // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a // conflicting duplicate --spec-version flag). var result = await NodeHelpers.RunServerConformanceAsync( - $"server --url {server.ServerUrl} --scenario caching --spec-version DRAFT-2026-v1", + $"server --url {server.ServerUrl} --scenario caching --spec-version 2026-07-28", line => { try { output.WriteLine(line); } catch { } }, appendProtocolVersionFromEnv: false, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index efbf8467f..22c86521c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -143,7 +143,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() !NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - // SEP-2243 is a draft (DRAFT-2026-v1) scenario that uses the stateless lifecycle, so it + // SEP-2243 is a draft (2026-07-28) scenario that uses the stateless lifecycle, so it // requires a stateless server (a stateful server rejects the un-initialized list/call // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with // the stateful class fixture (300x) or the caching stateless server (301x). @@ -151,7 +151,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() TestContext.Current.CancellationToken, basePort: 3021); var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1"); + $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -169,32 +169,49 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() TestContext.Current.CancellationToken, basePort: 3024); var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version DRAFT-2026-v1"); + $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } - // SEP-2322 (Multi Round-Trip Requests / IncompleteResult) conformance scenarios. + // SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) conformance scenarios. // The csharp-sdk ConformanceServer surfaces the matching tools/prompts via - // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts. + // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts + // (the class names predate the conformance-suite rename from "incomplete-result-*" to + // "input-required-result-*"; the wire-level tool names now match the new convention). // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28 // so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the - // upstream conformance package ships with SEP-2322 scenarios - // (https://github.com/modelcontextprotocol/conformance/pull/188). + // installed conformance package ships SEP-2322 scenarios and emits this SDK's + // draft wire string (see ). + // + // Two scenarios (input-required-result-tampered-state and input-required-result-capability-check) + // require advanced server-side logic not yet built into the ConformanceServer: + // - tampered-state: HMAC integrity protection on requestState. Server-implementer concern + // outside the SDK wire surface; would need a sample tool implementing the pattern. + // - capability-check: per-request reading of clientCapabilities to gate which inputRequests + // are returned. SDK exposes capabilities via JsonRpcMessageContext but no current tool + // conditionally emits inputRequests based on them. + // These rows are skipped until matching tool implementations are added. [Theory] - [InlineData("incomplete-result-basic-elicitation")] - [InlineData("incomplete-result-basic-sampling")] - [InlineData("incomplete-result-basic-list-roots")] - [InlineData("incomplete-result-request-state")] - [InlineData("incomplete-result-multiple-input-requests")] - [InlineData("incomplete-result-multi-round")] - [InlineData("incomplete-result-missing-input-response")] - [InlineData("incomplete-result-non-tool-request")] + [InlineData("input-required-result-basic-elicitation")] + [InlineData("input-required-result-basic-sampling")] + [InlineData("input-required-result-basic-list-roots")] + [InlineData("input-required-result-request-state")] + [InlineData("input-required-result-multiple-input-requests")] + [InlineData("input-required-result-multi-round")] + [InlineData("input-required-result-missing-input-response")] + [InlineData("input-required-result-non-tool-request")] + [InlineData("input-required-result-result-type")] + [InlineData("input-required-result-unsupported-methods")] + [InlineData("input-required-result-tampered-state", Skip = "Requires HMAC-protected requestState pattern in ConformanceServer tools (not yet implemented).")] + [InlineData("input-required-result-capability-check", Skip = "Requires per-request capability-aware inputRequest gating in ConformanceServer tools (not yet implemented).")] + [InlineData("input-required-result-ignore-extra-params")] + [InlineData("input-required-result-validate-input")] 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 not yet available in the published @modelcontextprotocol/conformance package (or installed version uses a stale draft wire string)."); var result = await RunConformanceTestsAsync( $"server --url {fixture.ServerUrl} --scenario {scenario}"); diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs index 4dfe6dfb0..0fcb05711 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs @@ -16,8 +16,8 @@ namespace ConformanceServer.Prompts; [McpServerPromptType] public sealed class IncompleteResultPrompts { - [McpServerPrompt(Name = "test_incomplete_result_prompt")] - [Description("SEP-2322 D1: prompts/get returns IncompleteResult until user_context is supplied.")] + [McpServerPrompt(Name = "test_input_required_result_prompt")] + [Description("SEP-2322 D1: prompts/get returns InputRequiredResult until user_context is supplied.")] public static GetPromptResult IncompleteResultPrompt(RequestContext context) { if (context.Params!.InputResponses is { } responses && diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs index caf91237a..eb60b7ae1 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs @@ -20,8 +20,8 @@ namespace ConformanceServer.Tools; public sealed class IncompleteResultTools { // ──── A1: Basic Elicitation ───────────────────────────────────────────── - [McpServerTool(Name = "test_tool_with_elicitation")] - [Description("SEP-2322 A1: returns IncompleteResult with elicitation/create keyed 'user_name'.")] + [McpServerTool(Name = "test_input_required_result_elicitation")] + [Description("SEP-2322 A1: returns InputRequiredResult with elicitation/create keyed 'user_name'.")] public static CallToolResult ToolWithElicitation(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -51,8 +51,8 @@ public static CallToolResult ToolWithElicitation(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -81,8 +81,8 @@ public static CallToolResult ToolWithSampling(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -102,7 +102,7 @@ public static CallToolResult ToolWithListRoots(RequestContext context) { @@ -135,7 +135,7 @@ public static CallToolResult ToolWithRequestState(RequestContext context) { @@ -177,7 +177,7 @@ public static CallToolResult ToolWithMultipleInputs(RequestContext incomplete, R2 -> incomplete (new state), R3 -> complete) ───── - [McpServerTool(Name = "test_incomplete_result_multi_round")] + [McpServerTool(Name = "test_input_required_result_multi_round")] [Description("SEP-2322 B3: three-round flow whose requestState changes between rounds.")] public static CallToolResult ToolWithMultiRound(RequestContext context) {