From 3804a1a15204969edc125b6d4fac406a80e9756c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:38:26 +0000 Subject: [PATCH 1/9] Initial plan From f9e33969474d6a7d03e30c65a9a5b81ccb90acc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:45:22 +0000 Subject: [PATCH 2/9] Validate MCP-Protocol-Version header in StreamableHttpHandler If the server receives a request with an invalid or unsupported MCP-Protocol-Version header, respond with 400 Bad Request per the MCP spec. A missing header is allowed for backwards compatibility. Exposes McpSession.SupportedProtocolVersions as a public static property so the AspNetCore handler can validate against it. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 37 ++++++++++++++ src/ModelContextProtocol.Core/McpSession.cs | 5 ++ .../StreamableHttpServerConformanceTests.cs | 48 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 22c861326..bf4e689bd 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -23,6 +23,7 @@ internal sealed class StreamableHttpHandler( ILoggerFactory loggerFactory) { private const string McpSessionIdHeaderName = "Mcp-Session-Id"; + private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version"; private const string LastEventIdHeaderName = "Last-Event-ID"; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); @@ -32,6 +33,12 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { + if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest); + return; + } + // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. // ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation, // so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests, @@ -74,6 +81,12 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { + if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest); + return; + } + if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType)) { await WriteJsonRpcErrorAsync(context, @@ -171,6 +184,12 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { + if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest); + return; + } + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); if (sessionManager.TryRemove(sessionId, out var session)) { @@ -390,6 +409,24 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, internal static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + /// + /// 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. + /// + private static bool ValidateProtocolVersionHeader(HttpContext context, out string errorMessage) + { + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + if (!string.IsNullOrEmpty(protocolVersionHeader) && + !McpSession.SupportedProtocolVersions.Contains(protocolVersionHeader)) + { + errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; + return false; + } + + errorMessage = ""; + return true; + } + private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) => acceptHeaderValue.MatchesMediaType("application/json"); diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 63d218f4e..a94d798bd 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -28,6 +28,11 @@ namespace ModelContextProtocol; /// public abstract partial class McpSession : IAsyncDisposable { + /// + /// Gets the protocol versions supported by this implementation of MCP. + /// + public static IReadOnlyList SupportedProtocolVersions { get; } = McpSessionHandler.SupportedProtocolVersions; + /// Gets an identifier associated with the current MCP session. /// /// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 0c42b3e82..a667302df 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -158,6 +158,54 @@ public async Task GetRequest_IsAcceptable_WithWildcardOrAddedQualityInAcceptHead Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + [Theory] + [InlineData("invalid-version")] + [InlineData("9999-01-01")] + [InlineData("not-a-date")] + public async Task PostRequest_IsBadRequest_WithInvalidProtocolVersionHeader(string invalidVersion) + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", invalidVersion); + + using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostRequest_Succeeds_WithoutProtocolVersionHeader() + { + await StartAsync(); + + // No MCP-Protocol-Version header is set - this should be accepted for backwards compatibility + using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task PostRequest_Succeeds_WithValidProtocolVersionHeader() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-03-26"); + + using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetRequest_IsBadRequest_WithInvalidProtocolVersionHeader() + { + await StartAsync(); + + await CallInitializeAndValidateAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "invalid-version"); + + using var response = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId() { From 0bcfe736c304ccb4b554d5fd508b197d1d44d823 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:58:45 +0000 Subject: [PATCH 3/9] Address PR feedback: move SupportedProtocolVersions to ProtocolVersions class, use nullable out param - Move SupportedProtocolVersions from McpSession to new ProtocolVersions static class in Protocol namespace (following RequestMethods/NotificationMethods pattern) - Revert McpSession.cs to original state - Change out string to out string? with null on success - Add ProtocolVersionsTests for the new public property Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 12 ++++----- src/ModelContextProtocol.Core/McpSession.cs | 5 ---- .../Protocol/ProtocolVersions.cs | 12 +++++++++ .../Protocol/ProtocolVersionsTests.cs | 26 +++++++++++++++++++ 4 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index bf4e689bd..c216f3b24 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -35,7 +35,7 @@ public async Task HandlePostRequestAsync(HttpContext context) { if (!ValidateProtocolVersionHeader(context, out var errorMessage)) { - await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); return; } @@ -83,7 +83,7 @@ public async Task HandleGetRequestAsync(HttpContext context) { if (!ValidateProtocolVersionHeader(context, out var errorMessage)) { - await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); return; } @@ -186,7 +186,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context) { if (!ValidateProtocolVersionHeader(context, out var errorMessage)) { - await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); return; } @@ -413,17 +413,17 @@ 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. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, out string errorMessage) + private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !McpSession.SupportedProtocolVersions.Contains(protocolVersionHeader)) + !ProtocolVersions.SupportedVersions.Contains(protocolVersionHeader)) { errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; return false; } - errorMessage = ""; + errorMessage = null; return true; } diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index a94d798bd..63d218f4e 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -28,11 +28,6 @@ namespace ModelContextProtocol; /// public abstract partial class McpSession : IAsyncDisposable { - /// - /// Gets the protocol versions supported by this implementation of MCP. - /// - public static IReadOnlyList SupportedProtocolVersions { get; } = McpSessionHandler.SupportedProtocolVersions; - /// Gets an identifier associated with the current MCP session. /// /// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE. diff --git a/src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs b/src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs new file mode 100644 index 000000000..a7f24f75a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs @@ -0,0 +1,12 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Provides the protocol versions supported by this implementation of MCP. +/// +public static class ProtocolVersions +{ + /// + /// Gets the protocol versions supported by this implementation of MCP. + /// + public static IReadOnlyList SupportedVersions { get; } = McpSessionHandler.SupportedProtocolVersions; +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs new file mode 100644 index 000000000..10b1dce7e --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs @@ -0,0 +1,26 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests.Protocol; + +public class ProtocolVersionsTests +{ + [Fact] + public void SupportedVersions_IsNotEmpty() + { + Assert.NotEmpty(ProtocolVersions.SupportedVersions); + } + + [Fact] + public void SupportedVersions_ContainsExpectedVersions() + { + Assert.Contains("2024-11-05", ProtocolVersions.SupportedVersions); + Assert.Contains("2025-03-26", ProtocolVersions.SupportedVersions); + Assert.Contains("2025-06-18", ProtocolVersions.SupportedVersions); + } + + [Fact] + public void SupportedVersions_ReturnsSameInstance() + { + Assert.Same(ProtocolVersions.SupportedVersions, ProtocolVersions.SupportedVersions); + } +} From c9dfb9db6e395aa9a1378c771e5f36d61f33ba54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:47:00 +0000 Subject: [PATCH 4/9] Remove public ProtocolVersions class; use InternalsVisibleTo instead The SupportedProtocolVersions doesn't need to be exposed publicly. Instead, use InternalsVisibleTo to let ModelContextProtocol.AspNetCore access the internal McpSessionHandler.SupportedProtocolVersions directly. - Delete ProtocolVersions.cs public static class - Delete ProtocolVersionsTests.cs - Add InternalsVisibleTo in Core csproj for AspNetCore - Reference McpSessionHandler.SupportedProtocolVersions directly Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 2 +- .../ModelContextProtocol.Core.csproj | 4 +++ .../Protocol/ProtocolVersions.cs | 12 --------- .../Protocol/ProtocolVersionsTests.cs | 26 ------------------- 4 files changed, 5 insertions(+), 39 deletions(-) delete mode 100644 src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index c216f3b24..5f0456c4f 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -417,7 +417,7 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, out strin { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !ProtocolVersions.SupportedVersions.Contains(protocolVersionHeader)) + !McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersionHeader)) { errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; return false; diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index bc4ae4c81..43a517487 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -71,6 +71,10 @@ Visible="false" /> + + + + diff --git a/src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs b/src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs deleted file mode 100644 index a7f24f75a..000000000 --- a/src/ModelContextProtocol.Core/Protocol/ProtocolVersions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ModelContextProtocol.Protocol; - -/// -/// Provides the protocol versions supported by this implementation of MCP. -/// -public static class ProtocolVersions -{ - /// - /// Gets the protocol versions supported by this implementation of MCP. - /// - public static IReadOnlyList SupportedVersions { get; } = McpSessionHandler.SupportedProtocolVersions; -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs deleted file mode 100644 index 10b1dce7e..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/ProtocolVersionsTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ModelContextProtocol.Protocol; - -namespace ModelContextProtocol.Tests.Protocol; - -public class ProtocolVersionsTests -{ - [Fact] - public void SupportedVersions_IsNotEmpty() - { - Assert.NotEmpty(ProtocolVersions.SupportedVersions); - } - - [Fact] - public void SupportedVersions_ContainsExpectedVersions() - { - Assert.Contains("2024-11-05", ProtocolVersions.SupportedVersions); - Assert.Contains("2025-03-26", ProtocolVersions.SupportedVersions); - Assert.Contains("2025-06-18", ProtocolVersions.SupportedVersions); - } - - [Fact] - public void SupportedVersions_ReturnsSameInstance() - { - Assert.Same(ProtocolVersions.SupportedVersions, ProtocolVersions.SupportedVersions); - } -} From b233bde053d977ce769a4d21b7c46a97e88f094a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:00:05 +0000 Subject: [PATCH 5/9] Remove InternalsVisibleTo; use local HashSet for protocol version validation Define supported protocol versions as a private HashSet directly in StreamableHttpHandler instead of referencing McpSessionHandler internals or adding any cross-assembly visibility. No new public API surface needed. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 11 ++++++++++- .../ModelContextProtocol.Core.csproj | 4 ---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 5f0456c4f..be2381fe6 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -26,6 +26,15 @@ internal sealed class StreamableHttpHandler( private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version"; private const string LastEventIdHeaderName = "Last-Event-ID"; + /// All protocol versions supported by this implementation. + private static readonly HashSet s_supportedProtocolVersions = + [ + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", + ]; + private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -417,7 +426,7 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, out strin { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersionHeader)) + !s_supportedProtocolVersions.Contains(protocolVersionHeader)) { errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; return false; diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 43a517487..bc4ae4c81 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -71,10 +71,6 @@ Visible="false" /> - - - - From 04eec1c9bedb86d0ec41237e9e2cd17157ac1d0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:05:10 +0000 Subject: [PATCH 6/9] Move SupportedProtocolVersions to McpServer as instance member Add abstract SupportedProtocolVersions property to McpServer, implemented by McpServerImpl (delegating to McpSessionHandler.SupportedProtocolVersions) and DestinationBoundMcpServer. Protocol version validation in StreamableHttpHandler now occurs after session resolution, querying the server instance directly. Remove private HashSet from handler. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 49 ++++++++----------- .../Server/DestinationBoundMcpServer.cs | 1 + .../Server/McpServer.cs | 5 ++ .../Server/McpServerImpl.cs | 3 ++ .../Server/McpServerTests.cs | 1 + 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index be2381fe6..88a224ff1 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -26,15 +26,6 @@ internal sealed class StreamableHttpHandler( private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version"; private const string LastEventIdHeaderName = "Last-Event-ID"; - /// All protocol versions supported by this implementation. - private static readonly HashSet s_supportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - "2025-11-25", - ]; - private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -42,12 +33,6 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) - { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); - return; - } - // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. // ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation, // so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests, @@ -67,6 +52,12 @@ await WriteJsonRpcErrorAsync(context, return; } + if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + return; + } + await using var _ = await session.AcquireReferenceAsync(context.RequestAborted); var message = await ReadJsonRpcMessageAsync(context); @@ -90,12 +81,6 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) - { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); - return; - } - if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType)) { await WriteJsonRpcErrorAsync(context, @@ -111,6 +96,12 @@ await WriteJsonRpcErrorAsync(context, return; } + if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + return; + } + var lastEventId = context.Request.Headers[LastEventIdHeaderName].ToString(); if (!string.IsNullOrEmpty(lastEventId)) { @@ -193,15 +184,15 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) - { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); - return; - } - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); if (sessionManager.TryRemove(sessionId, out var session)) { + if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + return; + } + await session.DisposeAsync(); } } @@ -422,11 +413,11 @@ 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. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage) + private static bool ValidateProtocolVersionHeader(HttpContext context, McpServer server, out string? errorMessage) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !s_supportedProtocolVersions.Contains(protocolVersionHeader)) + !server.SupportedProtocolVersions.Contains(protocolVersionHeader)) { errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; return false; diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 784e0f9a6..4f8914595 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -12,6 +12,7 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; public override LoggingLevel? LoggingLevel => server.LoggingLevel; + public override ICollection SupportedProtocolVersions => server.SupportedProtocolVersions; public override ValueTask DisposeAsync() => server.DisposeAsync(); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 2d8ea6826..19d228e7c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -55,6 +55,11 @@ public abstract partial class McpServer : McpSession /// Gets the last logging level set by the client, or if it's never been set. public abstract LoggingLevel? LoggingLevel { get; } + /// + /// Gets the protocol versions supported by this server implementation. + /// + public abstract ICollection SupportedProtocolVersions { get; } + /// /// Runs the server, listening for and handling client requests. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 04f329437..3de779f26 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -155,6 +155,9 @@ void Register(McpServerPrimitiveCollection? collection, /// public override LoggingLevel? LoggingLevel => _loggingLevel?.Value; + /// + public override ICollection SupportedProtocolVersions => McpSessionHandler.SupportedProtocolVersions; + /// public override async Task RunAsync(CancellationToken cancellationToken = default) { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 637162962..74f66ce0a 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -938,6 +938,7 @@ public override Task SendRequestAsync(JsonRpcRequest request, C public override Implementation? ClientInfo => throw new NotImplementedException(); public override IServiceProvider? Services => throw new NotImplementedException(); public override LoggingLevel? LoggingLevel => throw new NotImplementedException(); + public override ICollection SupportedProtocolVersions => throw new NotImplementedException(); public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public override Task RunAsync(CancellationToken cancellationToken = default) => From bf473f4cb2820d5ef51aee0dc6064f7642dca9f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:07:07 +0000 Subject: [PATCH 7/9] Fix DELETE handler: validate protocol version before removing session Use TryGetValue to check version first, then TryRemove to actually delete. This prevents losing the session if validation fails. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 88a224ff1..a06dd6d1c 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -185,7 +185,7 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - if (sessionManager.TryRemove(sessionId, out var session)) + if (sessionManager.TryGetValue(sessionId, out var session)) { if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage)) { @@ -193,7 +193,10 @@ public async Task HandleDeleteRequestAsync(HttpContext context) return; } - await session.DisposeAsync(); + if (sessionManager.TryRemove(sessionId, out session)) + { + await session.DisposeAsync(); + } } } From c39b1ef2884191a052a7463e3e69010c100486a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:17:34 +0000 Subject: [PATCH 8/9] Change SupportedProtocolVersions from ICollection to IReadOnlyList Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/DestinationBoundMcpServer.cs | 2 +- src/ModelContextProtocol.Core/Server/McpServer.cs | 2 +- src/ModelContextProtocol.Core/Server/McpServerImpl.cs | 2 +- tests/ModelContextProtocol.Tests/Server/McpServerTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 4f8914595..4b28828ad 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -12,7 +12,7 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; public override LoggingLevel? LoggingLevel => server.LoggingLevel; - public override ICollection SupportedProtocolVersions => server.SupportedProtocolVersions; + public override IReadOnlyList SupportedProtocolVersions => server.SupportedProtocolVersions; public override ValueTask DisposeAsync() => server.DisposeAsync(); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 19d228e7c..83d18d8f2 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -58,7 +58,7 @@ public abstract partial class McpServer : McpSession /// /// Gets the protocol versions supported by this server implementation. /// - public abstract ICollection SupportedProtocolVersions { get; } + public abstract IReadOnlyList SupportedProtocolVersions { get; } /// /// Runs the server, listening for and handling client requests. diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 3de779f26..2e12d9fbf 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -156,7 +156,7 @@ void Register(McpServerPrimitiveCollection? collection, public override LoggingLevel? LoggingLevel => _loggingLevel?.Value; /// - public override ICollection SupportedProtocolVersions => McpSessionHandler.SupportedProtocolVersions; + public override IReadOnlyList SupportedProtocolVersions => McpSessionHandler.SupportedProtocolVersions; /// public override async Task RunAsync(CancellationToken cancellationToken = default) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 74f66ce0a..694781e33 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -938,7 +938,7 @@ public override Task SendRequestAsync(JsonRpcRequest request, C public override Implementation? ClientInfo => throw new NotImplementedException(); public override IServiceProvider? Services => throw new NotImplementedException(); public override LoggingLevel? LoggingLevel => throw new NotImplementedException(); - public override ICollection SupportedProtocolVersions => throw new NotImplementedException(); + public override IReadOnlyList SupportedProtocolVersions => throw new NotImplementedException(); public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public override Task RunAsync(CancellationToken cancellationToken = default) => From a3401568377765e3fb1d5f53ff069cedf34c079b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:59:08 +0000 Subject: [PATCH 9/9] Revert SupportedProtocolVersions public API; use private list with sync comments Remove the abstract SupportedProtocolVersions property from McpServer, McpServerImpl, and DestinationBoundMcpServer. Instead, keep a private HashSet in StreamableHttpHandler with the supported protocol versions, and add comments on both lists about keeping them in sync. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 59 +++++++++++-------- .../McpSessionHandler.cs | 5 +- .../Server/DestinationBoundMcpServer.cs | 1 - .../Server/McpServer.cs | 5 -- .../Server/McpServerImpl.cs | 3 - .../Server/McpServerTests.cs | 1 - 6 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index a06dd6d1c..e9aca10ac 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -26,6 +26,18 @@ internal sealed class StreamableHttpHandler( private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version"; private const string LastEventIdHeaderName = "Last-Event-ID"; + /// + /// All protocol versions supported by this implementation. + /// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core. + /// + private static readonly HashSet s_supportedProtocolVersions = + [ + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", + ]; + private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -33,6 +45,12 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { + if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + return; + } + // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. // ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation, // so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests, @@ -52,12 +70,6 @@ await WriteJsonRpcErrorAsync(context, return; } - if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage)) - { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); - return; - } - await using var _ = await session.AcquireReferenceAsync(context.RequestAborted); var message = await ReadJsonRpcMessageAsync(context); @@ -81,6 +93,12 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { + if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + return; + } + if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType)) { await WriteJsonRpcErrorAsync(context, @@ -96,12 +114,6 @@ await WriteJsonRpcErrorAsync(context, return; } - if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage)) - { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); - return; - } - var lastEventId = context.Request.Headers[LastEventIdHeaderName].ToString(); if (!string.IsNullOrEmpty(lastEventId)) { @@ -184,19 +196,16 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - if (sessionManager.TryGetValue(sessionId, out var session)) + if (!ValidateProtocolVersionHeader(context, out var errorMessage)) { - if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage)) - { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); - return; - } + await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + return; + } - if (sessionManager.TryRemove(sessionId, out session)) - { - await session.DisposeAsync(); - } + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + if (sessionManager.TryRemove(sessionId, out var session)) + { + await session.DisposeAsync(); } } @@ -416,11 +425,11 @@ 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. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, McpServer server, out string? errorMessage) + private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !server.SupportedProtocolVersions.Contains(protocolVersionHeader)) + !s_supportedProtocolVersions.Contains(protocolVersionHeader)) { errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; return false; diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 9137218eb..1aa444692 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -31,7 +31,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// The latest version of the protocol supported by this implementation. internal const string LatestProtocolVersion = "2025-11-25"; - /// All protocol versions supported by this implementation. + /// + /// All protocol versions supported by this implementation. + /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. + /// internal static readonly string[] SupportedProtocolVersions = [ "2024-11-05", diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 4b28828ad..784e0f9a6 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -12,7 +12,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; public override LoggingLevel? LoggingLevel => server.LoggingLevel; - public override IReadOnlyList SupportedProtocolVersions => server.SupportedProtocolVersions; public override ValueTask DisposeAsync() => server.DisposeAsync(); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 83d18d8f2..2d8ea6826 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -55,11 +55,6 @@ public abstract partial class McpServer : McpSession /// Gets the last logging level set by the client, or if it's never been set. public abstract LoggingLevel? LoggingLevel { get; } - /// - /// Gets the protocol versions supported by this server implementation. - /// - public abstract IReadOnlyList SupportedProtocolVersions { get; } - /// /// Runs the server, listening for and handling client requests. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2e12d9fbf..04f329437 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -155,9 +155,6 @@ void Register(McpServerPrimitiveCollection? collection, /// public override LoggingLevel? LoggingLevel => _loggingLevel?.Value; - /// - public override IReadOnlyList SupportedProtocolVersions => McpSessionHandler.SupportedProtocolVersions; - /// public override async Task RunAsync(CancellationToken cancellationToken = default) { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 694781e33..637162962 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -938,7 +938,6 @@ public override Task SendRequestAsync(JsonRpcRequest request, C public override Implementation? ClientInfo => throw new NotImplementedException(); public override IServiceProvider? Services => throw new NotImplementedException(); public override LoggingLevel? LoggingLevel => throw new NotImplementedException(); - public override IReadOnlyList SupportedProtocolVersions => throw new NotImplementedException(); public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public override Task RunAsync(CancellationToken cancellationToken = default) =>