Validate MCP-Protocol-Version header in Streamable HTTP handler (#1277)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
Copilot 2026-02-18 22:18:00 -05:00 • committed by GitHub
commit 5da0fe506f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 101 additions and 1 deletions

View file

@ -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";
/// <summary>
/// All protocol versions supported by this implementation.
/// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core.
/// </summary>
private static readonly HashSet<string> s_supportedProtocolVersions =
[
"2024-11-05",
"2025-03-26",
"2025-06-18",
"2025-11-25",
];
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
@ -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 @@ internal sealed class StreamableHttpHandler(
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 @@ internal sealed class StreamableHttpHandler(
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))
{
@ -391,6 +422,24 @@ internal sealed class StreamableHttpHandler(
internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
/// <summary>
/// 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.
/// </summary>
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");

View file

@ -31,7 +31,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// <summary>The latest version of the protocol supported by this implementation.</summary>
internal const string LatestProtocolVersion = "2025-11-25";
/// <summary>All protocol versions supported by this implementation.</summary>
/// <summary>
/// All protocol versions supported by this implementation.
/// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler.
/// </summary>
internal static readonly string[] SupportedProtocolVersions =
[
"2024-11-05",

View file

@ -169,6 +169,54 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
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()
{