Skip to content

Commit 5da0fe5

Browse files
Copilotstephentoub
andauthored
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>
1 parent 703d842 commit 5da0fe5

File tree

3 files changed

+101
-1
lines changed

3 files changed

+101
-1
lines changed

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,34 @@ internal sealed class StreamableHttpHandler(
2323
ILoggerFactory loggerFactory)
2424
{
2525
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
26+
private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version";
2627
private const string LastEventIdHeaderName = "Last-Event-ID";
2728

29+
/// <summary>
30+
/// All protocol versions supported by this implementation.
31+
/// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core.
32+
/// </summary>
33+
private static readonly HashSet<string> s_supportedProtocolVersions =
34+
[
35+
"2024-11-05",
36+
"2025-03-26",
37+
"2025-06-18",
38+
"2025-11-25",
39+
];
40+
2841
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
2942
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
3043

3144
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
3245

3346
public async Task HandlePostRequestAsync(HttpContext context)
3447
{
48+
if (!ValidateProtocolVersionHeader(context, out var errorMessage))
49+
{
50+
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
51+
return;
52+
}
53+
3554
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
3655
// ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation,
3756
// 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,
7493

7594
public async Task HandleGetRequestAsync(HttpContext context)
7695
{
96+
if (!ValidateProtocolVersionHeader(context, out var errorMessage))
97+
{
98+
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
99+
return;
100+
}
101+
77102
if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType))
78103
{
79104
await WriteJsonRpcErrorAsync(context,
@@ -171,6 +196,12 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
171196

172197
public async Task HandleDeleteRequestAsync(HttpContext context)
173198
{
199+
if (!ValidateProtocolVersionHeader(context, out var errorMessage))
200+
{
201+
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
202+
return;
203+
}
204+
174205
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
175206
if (sessionManager.TryRemove(sessionId, out var session))
176207
{
@@ -391,6 +422,24 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
391422

392423
internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
393424

425+
/// <summary>
426+
/// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
427+
/// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec.
428+
/// </summary>
429+
private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage)
430+
{
431+
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
432+
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
433+
!s_supportedProtocolVersions.Contains(protocolVersionHeader))
434+
{
435+
errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.";
436+
return false;
437+
}
438+
439+
errorMessage = null;
440+
return true;
441+
}
442+
394443
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
395444
=> acceptHeaderValue.MatchesMediaType("application/json");
396445

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
3131
/// <summary>The latest version of the protocol supported by this implementation.</summary>
3232
internal const string LatestProtocolVersion = "2025-11-25";
3333

34-
/// <summary>All protocol versions supported by this implementation.</summary>
34+
/// <summary>
35+
/// All protocol versions supported by this implementation.
36+
/// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler.
37+
/// </summary>
3538
internal static readonly string[] SupportedProtocolVersions =
3639
[
3740
"2024-11-05",

tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,54 @@ public async Task GetRequest_IsAcceptable_WithWildcardOrAddedQualityInAcceptHead
169169
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
170170
}
171171

172+
[Theory]
173+
[InlineData("invalid-version")]
174+
[InlineData("9999-01-01")]
175+
[InlineData("not-a-date")]
176+
public async Task PostRequest_IsBadRequest_WithInvalidProtocolVersionHeader(string invalidVersion)
177+
{
178+
await StartAsync();
179+
180+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", invalidVersion);
181+
182+
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
183+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
184+
}
185+
186+
[Fact]
187+
public async Task PostRequest_Succeeds_WithoutProtocolVersionHeader()
188+
{
189+
await StartAsync();
190+
191+
// No MCP-Protocol-Version header is set - this should be accepted for backwards compatibility
192+
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
193+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
194+
}
195+
196+
[Fact]
197+
public async Task PostRequest_Succeeds_WithValidProtocolVersionHeader()
198+
{
199+
await StartAsync();
200+
201+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-03-26");
202+
203+
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
204+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
205+
}
206+
207+
[Fact]
208+
public async Task GetRequest_IsBadRequest_WithInvalidProtocolVersionHeader()
209+
{
210+
await StartAsync();
211+
212+
await CallInitializeAndValidateAsync();
213+
214+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "invalid-version");
215+
216+
using var response = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
217+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
218+
}
219+
172220
[Fact]
173221
public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
174222
{

0 commit comments

Comments
 (0)