Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,34 @@ 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>();

public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;

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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -390,6 +421,24 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,

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");

Expand Down
5 changes: 4 additions & 1 deletion src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down