Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
44 changes: 42 additions & 2 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ 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";

private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
Expand Down Expand Up @@ -51,6 +52,12 @@ await WriteJsonRpcErrorAsync(context,
return;
}

if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage))
{
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
return;
}

await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);

var message = await ReadJsonRpcMessageAsync(context);
Expand Down Expand Up @@ -89,6 +96,12 @@ await WriteJsonRpcErrorAsync(context,
return;
}

if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage))
{
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
return;
}

var lastEventId = context.Request.Headers[LastEventIdHeaderName].ToString();
if (!string.IsNullOrEmpty(lastEventId))
{
Expand Down Expand Up @@ -172,9 +185,18 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
public async Task HandleDeleteRequestAsync(HttpContext context)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (sessionManager.TryRemove(sessionId, out var session))
if (sessionManager.TryGetValue(sessionId, out var session))
{
await session.DisposeAsync();
if (!ValidateProtocolVersionHeader(context, session.Server, out var errorMessage))
Comment thread
stephentoub marked this conversation as resolved.
Outdated
{
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
return;
}

if (sessionManager.TryRemove(sessionId, out session))
{
await session.DisposeAsync();
}
}
}

Expand Down Expand Up @@ -390,6 +412,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, McpServer server, out string? errorMessage)
{
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
!server.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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
public override McpServerOptions ServerOptions => server.ServerOptions;
public override IServiceProvider? Services => server.Services;
public override LoggingLevel? LoggingLevel => server.LoggingLevel;
public override ICollection<string> SupportedProtocolVersions => server.SupportedProtocolVersions;

public override ValueTask DisposeAsync() => server.DisposeAsync();

Expand Down
5 changes: 5 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public abstract partial class McpServer : McpSession
/// <summary>Gets the last logging level set by the client, or <see langword="null"/> if it's never been set.</summary>
public abstract LoggingLevel? LoggingLevel { get; }

/// <summary>
/// Gets the protocol versions supported by this server implementation.
/// </summary>
public abstract ICollection<string> SupportedProtocolVersions { get; }
Comment thread
stephentoub marked this conversation as resolved.
Outdated

/// <summary>
/// Runs the server, listening for and handling client requests.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ void Register<TPrimitive>(McpServerPrimitiveCollection<TPrimitive>? collection,
/// <inheritdoc />
public override LoggingLevel? LoggingLevel => _loggingLevel?.Value;

/// <inheritdoc />
public override ICollection<string> SupportedProtocolVersions => McpSessionHandler.SupportedProtocolVersions;

/// <inheritdoc />
public override async Task RunAsync(CancellationToken cancellationToken = default)
{
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]
Comment thread
stephentoub marked this conversation as resolved.
[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
1 change: 1 addition & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,7 @@ public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, C
public override Implementation? ClientInfo => throw new NotImplementedException();
public override IServiceProvider? Services => throw new NotImplementedException();
public override LoggingLevel? LoggingLevel => throw new NotImplementedException();
public override ICollection<string> SupportedProtocolVersions => throw new NotImplementedException();
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override Task RunAsync(CancellationToken cancellationToken = default) =>
Expand Down