Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 17, 2025
Merged
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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<LangVersion>13</LangVersion>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class HttpServerTransportOptions
/// </summary>
/// <remarks>
/// 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.
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
/// Defaults to <see langword="false"/>.
Expand Down
21 changes: 11 additions & 10 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal sealed class StreamableHttpHandler(
ILoggerFactory loggerFactory,
IServiceProvider applicationServices)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();

public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
Expand Down Expand Up @@ -70,8 +71,8 @@ await WriteJsonRpcErrorAsync(context,
}
finally
{
// 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.
// 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 get disposed by a DELETE request or the IdleTrackingBackgroundService.
if (HttpServerTransportOptions.Stateless)
{
Expand All @@ -90,7 +91,7 @@ await WriteJsonRpcErrorAsync(context,
return;
}

var sessionId = context.Request.Headers["mcp-session-id"].ToString();
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
var session = await GetSessionAsync(context, sessionId);
if (session is null)
{
Expand All @@ -117,7 +118,7 @@ await WriteJsonRpcErrorAsync(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))
{
await session.DisposeAsync();
Expand Down Expand Up @@ -157,14 +158,14 @@ await WriteJsonRpcErrorAsync(context,
return null;
}

context.Response.Headers["mcp-session-id"] = session.Id;
context.Response.Headers[McpSessionIdHeaderName] = session.Id;
context.Features.Set(session.Server);
return session;
}

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))
{
Expand All @@ -188,11 +189,11 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewS
{
SessionId = sessionId,
};
context.Response.Headers["mcp-session-id"] = sessionId;
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
}
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.
sessionId = "(uninitialized stateless id)";
transport = new()
Expand All @@ -204,7 +205,7 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewS

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 (!Sessions.TryAdd(sessionId, session))
Expand Down Expand Up @@ -299,7 +300,7 @@ private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttp

var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
transport.SessionId = Protector.Protect(sessionJson);
context.Response.Headers["mcp-session-id"] = transport.SessionId;
context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
return ValueTask.CompletedTask;
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
private readonly CancellationTokenSource _connectionCts;
private readonly ILogger _logger;

private string? _negotiatedProtocolVersion;
private Task? _getReceiveTask;

public StreamableHttpClientSessionTransport(
Expand Down Expand Up @@ -84,7 +85,7 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
},
};

CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId);
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);

var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);

Expand Down Expand Up @@ -118,14 +119,17 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
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.
if (response.Headers.TryGetValues("mcp-session-id", out var sessionIdValues))
// 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))
{
SessionId = sessionIdValues.FirstOrDefault();
}

var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult);
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;

_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
}

Expand Down Expand Up @@ -169,7 +173,7 @@ private async Task ReceiveUnsolicitedMessagesAsync()
// Send a GET request to handle any unsolicited messages not sent over a POST response.
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
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);

Expand Down Expand Up @@ -244,11 +248,20 @@ private void LogJsonException(JsonException ex, string data)
}
}

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)
{
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<PackageId>ModelContextProtocol.Core</PackageId>
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<LangVersion>preview</LangVersion>
</PropertyGroup>

<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
Expand Down
1 change: 0 additions & 1 deletion src/ModelContextProtocol/ModelContextProtocol.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<PackageId>ModelContextProtocol</PackageId>
<Description>.NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<LangVersion>preview</LangVersion>
</PropertyGroup>

<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using ModelContextProtocol.Client;

namespace ModelContextProtocol.AspNetCore.Tests;
Expand Down Expand Up @@ -143,4 +144,38 @@ public async Task SseMode_Works_WithSseEndpoint()

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));
}
}
2 changes: 1 addition & 1 deletion tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ private ClaimsPrincipal CreateUser(string name)
"TestAuthType", "name", "role"));

[McpServerToolType]
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
{
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
public string EchoWithUserName(string message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>Latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>Latest</LangVersion>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
Expand Down
Loading