diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 22c861326..e9aca10ac 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -23,8 +23,21 @@ 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"; + /// + /// 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(); @@ -32,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, @@ -74,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, @@ -171,6 +196,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 +421,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) && + !s_supportedProtocolVersions.Contains(protocolVersionHeader)) + { + errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; + return false; + } + + errorMessage = null; + return true; + } + private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) => acceptHeaderValue.MatchesMediaType("application/json"); 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/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() {