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)
|
ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
|
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
|
||||||
|
private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version";
|
||||||
private const string LastEventIdHeaderName = "Last-Event-ID";
|
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<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
|
||||||
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
|
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
|
||||||
|
|
||||||
|
|
@ -32,6 +45,12 @@ internal sealed class StreamableHttpHandler(
|
||||||
|
|
||||||
public async Task HandlePostRequestAsync(HttpContext context)
|
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.
|
// 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,
|
// 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,
|
// 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)
|
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))
|
if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType))
|
||||||
{
|
{
|
||||||
await WriteJsonRpcErrorAsync(context,
|
await WriteJsonRpcErrorAsync(context,
|
||||||
|
|
@ -171,6 +196,12 @@ internal sealed class StreamableHttpHandler(
|
||||||
|
|
||||||
public async Task HandleDeleteRequestAsync(HttpContext context)
|
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();
|
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
|
||||||
if (sessionManager.TryRemove(sessionId, out var session))
|
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));
|
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)
|
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
|
||||||
=> acceptHeaderValue.MatchesMediaType("application/json");
|
=> 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>
|
/// <summary>The latest version of the protocol supported by this implementation.</summary>
|
||||||
internal const string LatestProtocolVersion = "2025-11-25";
|
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 =
|
internal static readonly string[] SupportedProtocolVersions =
|
||||||
[
|
[
|
||||||
"2024-11-05",
|
"2024-11-05",
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,54 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
|
public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue