Send MCP-Protocol-Version header in Streamable HTTP client transport (#500)

This commit is contained in:
Stephen Halter 2025-06-17 14:27:26 -07:00 • committed by GitHub
commit befa31d5c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 69 additions and 24 deletions

View file

@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<LangVersion>13</LangVersion> <LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

View file

@ -28,7 +28,7 @@ public class HttpServerTransportOptions
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// If <see langword="true"/>, the "/sse" endpoint will be disabled, and client information will be round-tripped as part /// If <see langword="true"/>, the "/sse" endpoint will be disabled, and client information will be round-tripped as part
/// of the "mcp-session-id" header instead of stored in memory. Unsolicited server-to-client messages and all server-to-client /// of the "MCP-Session-Id" header instead of stored in memory. Unsolicited server-to-client messages and all server-to-client
/// requests are also unsupported, because any responses may arrive at another ASP.NET Core application process. /// requests are also unsupported, because any responses may arrive at another ASP.NET Core application process.
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests. /// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
/// Defaults to <see langword="false"/>. /// Defaults to <see langword="false"/>.

View file

@ -26,6 +26,7 @@ internal sealed class StreamableHttpHandler(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IServiceProvider applicationServices) IServiceProvider applicationServices)
{ {
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>(); private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal); public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
@ -70,8 +71,8 @@ internal sealed class StreamableHttpHandler(
} }
finally finally
{ {
// Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the mcp-session-id. // Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the Mcp-Session-Id.
// Non-stateless sessions are 1:1 with the mcp-session-id and outlive the POST request. // Non-stateless sessions are 1:1 with the Mcp-Session-Id and outlive the POST request.
// Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService. // Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService.
if (HttpServerTransportOptions.Stateless) if (HttpServerTransportOptions.Stateless)
{ {
@ -90,7 +91,7 @@ internal sealed class StreamableHttpHandler(
return; return;
} }
var sessionId = context.Request.Headers["mcp-session-id"].ToString(); var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
var session = await GetSessionAsync(context, sessionId); var session = await GetSessionAsync(context, sessionId);
if (session is null) if (session is null)
{ {
@ -117,7 +118,7 @@ internal sealed class StreamableHttpHandler(
public async Task HandleDeleteRequestAsync(HttpContext context) public async Task HandleDeleteRequestAsync(HttpContext context)
{ {
var sessionId = context.Request.Headers["mcp-session-id"].ToString(); var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (Sessions.TryRemove(sessionId, out var session)) if (Sessions.TryRemove(sessionId, out var session))
{ {
await session.DisposeAsync(); await session.DisposeAsync();
@ -157,14 +158,14 @@ internal sealed class StreamableHttpHandler(
return null; return null;
} }
context.Response.Headers["mcp-session-id"] = session.Id; context.Response.Headers[McpSessionIdHeaderName] = session.Id;
context.Features.Set(session.Server); context.Features.Set(session.Server);
return session; return session;
} }
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetOrCreateSessionAsync(HttpContext context) private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetOrCreateSessionAsync(HttpContext context)
{ {
var sessionId = context.Request.Headers["mcp-session-id"].ToString(); var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (string.IsNullOrEmpty(sessionId)) if (string.IsNullOrEmpty(sessionId))
{ {
@ -188,11 +189,11 @@ internal sealed class StreamableHttpHandler(
{ {
SessionId = sessionId, SessionId = sessionId,
}; };
context.Response.Headers["mcp-session-id"] = sessionId; context.Response.Headers[McpSessionIdHeaderName] = sessionId;
} }
else else
{ {
// "(uninitialized stateless id)" is not written anywhere. We delay writing the mcp-session-id // "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id
// until after we receive the initialize request with the client info we need to serialize. // until after we receive the initialize request with the client info we need to serialize.
sessionId = "(uninitialized stateless id)"; sessionId = "(uninitialized stateless id)";
transport = new() transport = new()
@ -204,7 +205,7 @@ internal sealed class StreamableHttpHandler(
var session = await CreateSessionAsync(context, transport, sessionId); var session = await CreateSessionAsync(context, transport, sessionId);
// The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the mcp-session-id. // The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the MCP-Session-Id.
if (!HttpServerTransportOptions.Stateless) if (!HttpServerTransportOptions.Stateless)
{ {
if (!Sessions.TryAdd(sessionId, session)) if (!Sessions.TryAdd(sessionId, session))
@ -299,7 +300,7 @@ internal sealed class StreamableHttpHandler(
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId); var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
transport.SessionId = Protector.Protect(sessionJson); transport.SessionId = Protector.Protect(sessionJson);
context.Response.Headers["mcp-session-id"] = transport.SessionId; context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
}; };
} }

View file

@ -27,6 +27,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
private readonly CancellationTokenSource _connectionCts; private readonly CancellationTokenSource _connectionCts;
private readonly ILogger _logger; private readonly ILogger _logger;
private string? _negotiatedProtocolVersion;
private Task? _getReceiveTask; private Task? _getReceiveTask;
public StreamableHttpClientSessionTransport( public StreamableHttpClientSessionTransport(
@ -84,7 +85,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
}, },
}; };
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId); CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
@ -118,14 +119,17 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
throw new McpException($"Streamable HTTP POST response completed without a reply to request with ID: {rpcRequest.Id}"); throw new McpException($"Streamable HTTP POST response completed without a reply to request with ID: {rpcRequest.Id}");
} }
if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse) if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse)
{ {
// We've successfully initialized! Copy session-id and start GET request if any. // We've successfully initialized! Copy session-id and protocol version, then start GET request if any.
if (response.Headers.TryGetValues("mcp-session-id", out var sessionIdValues)) if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues))
{ {
SessionId = sessionIdValues.FirstOrDefault(); SessionId = sessionIdValues.FirstOrDefault();
} }
var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult);
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;
_getReceiveTask = ReceiveUnsolicitedMessagesAsync(); _getReceiveTask = ReceiveUnsolicitedMessagesAsync();
} }
@ -169,7 +173,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
// Send a GET request to handle any unsolicited messages not sent over a POST response. // Send a GET request to handle any unsolicited messages not sent over a POST response.
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint); using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
request.Headers.Accept.Add(s_textEventStreamMediaType); request.Headers.Accept.Add(s_textEventStreamMediaType);
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId); CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _connectionCts.Token).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _connectionCts.Token).ConfigureAwait(false);
@ -244,11 +248,20 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
} }
} }
internal static void CopyAdditionalHeaders(HttpRequestHeaders headers, IDictionary<string, string>? additionalHeaders, string? sessionId = null) internal static void CopyAdditionalHeaders(
HttpRequestHeaders headers,
IDictionary<string, string>? additionalHeaders,
string? sessionId = null,
string? protocolVersion = null)
{ {
if (sessionId is not null) if (sessionId is not null)
{ {
headers.Add("mcp-session-id", sessionId); headers.Add("Mcp-Session-Id", sessionId);
}
if (protocolVersion is not null)
{
headers.Add("MCP-Protocol-Version", protocolVersion);
} }
if (additionalHeaders is null) if (additionalHeaders is null)

View file

@ -7,7 +7,6 @@
<PackageId>ModelContextProtocol.Core</PackageId> <PackageId>ModelContextProtocol.Core</PackageId>
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description> <Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'"> <PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">

View file

@ -7,7 +7,6 @@
<PackageId>ModelContextProtocol</PackageId> <PackageId>ModelContextProtocol</PackageId>
<Description>.NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions.</Description> <Description>.NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'"> <PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using ModelContextProtocol.Client; using ModelContextProtocol.Client;
namespace ModelContextProtocol.AspNetCore.Tests; namespace ModelContextProtocol.AspNetCore.Tests;
@ -143,4 +144,38 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Assert.Equal("SseTestServer", mcpClient.ServerInfo.Name); Assert.Equal("SseTestServer", mcpClient.ServerInfo.Name);
} }
[Fact]
public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitialization()
{
var protocolVersionHeaderValues = new List<string?>();
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
await using var app = Builder.Build();
app.Use(next =>
{
return async context =>
{
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-session-id"]))
{
protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]);
}
await next(context);
};
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var mcpClient = await ConnectAsync();
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// The header should be included in the GET request, the initialized notification, and the tools/list call.
Assert.Equal(3, protocolVersionHeaderValues.Count);
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
}
} }

View file

@ -195,7 +195,7 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
"TestAuthType", "name", "role")); "TestAuthType", "name", "role"));
[McpServerToolType] [McpServerToolType]
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor) protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
{ {
[McpServerTool, Description("Echoes the input back to the client with their user name.")] [McpServerTool, Description("Echoes the input back to the client with their user name.")]
public string EchoWithUserName(string message) public string EchoWithUserName(string message)

View file

@ -4,7 +4,6 @@
<TargetFrameworks>net9.0;net8.0</TargetFrameworks> <TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>Latest</LangVersion>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace> <RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace>

View file

@ -5,7 +5,6 @@
<TargetFrameworks>net9.0;net8.0</TargetFrameworks> <TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>Latest</LangVersion>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>