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:
parent
703d842a38
commit
5da0fe506f
3 changed files with 101 additions and 1 deletions
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue