Skip to content

Commit befa31d

Browse files
authored
Send MCP-Protocol-Version header in Streamable HTTP client transport (#500)
1 parent b9b4a23 commit befa31d

File tree

10 files changed

+69
-24
lines changed

10 files changed

+69
-24
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<LangVersion>13</LangVersion>
3+
<LangVersion>preview</LangVersion>
44
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

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

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ internal sealed class StreamableHttpHandler(
2626
ILoggerFactory loggerFactory,
2727
IServiceProvider applicationServices)
2828
{
29+
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
2930
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
3031

3132
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
@@ -70,8 +71,8 @@ await WriteJsonRpcErrorAsync(context,
7071
}
7172
finally
7273
{
73-
// Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the mcp-session-id.
74-
// Non-stateless sessions are 1:1 with the mcp-session-id and outlive the POST request.
74+
// Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the Mcp-Session-Id.
75+
// Non-stateless sessions are 1:1 with the Mcp-Session-Id and outlive the POST request.
7576
// Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService.
7677
if (HttpServerTransportOptions.Stateless)
7778
{
@@ -90,7 +91,7 @@ await WriteJsonRpcErrorAsync(context,
9091
return;
9192
}
9293

93-
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
94+
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
9495
var session = await GetSessionAsync(context, sessionId);
9596
if (session is null)
9697
{
@@ -117,7 +118,7 @@ await WriteJsonRpcErrorAsync(context,
117118

118119
public async Task HandleDeleteRequestAsync(HttpContext context)
119120
{
120-
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
121+
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
121122
if (Sessions.TryRemove(sessionId, out var session))
122123
{
123124
await session.DisposeAsync();
@@ -157,14 +158,14 @@ await WriteJsonRpcErrorAsync(context,
157158
return null;
158159
}
159160

160-
context.Response.Headers["mcp-session-id"] = session.Id;
161+
context.Response.Headers[McpSessionIdHeaderName] = session.Id;
161162
context.Features.Set(session.Server);
162163
return session;
163164
}
164165

165166
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetOrCreateSessionAsync(HttpContext context)
166167
{
167-
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
168+
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
168169

169170
if (string.IsNullOrEmpty(sessionId))
170171
{
@@ -188,11 +189,11 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewS
188189
{
189190
SessionId = sessionId,
190191
};
191-
context.Response.Headers["mcp-session-id"] = sessionId;
192+
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
192193
}
193194
else
194195
{
195-
// "(uninitialized stateless id)" is not written anywhere. We delay writing the mcp-session-id
196+
// "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id
196197
// until after we receive the initialize request with the client info we need to serialize.
197198
sessionId = "(uninitialized stateless id)";
198199
transport = new()
@@ -204,7 +205,7 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewS
204205

205206
var session = await CreateSessionAsync(context, transport, sessionId);
206207

207-
// The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the mcp-session-id.
208+
// The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the MCP-Session-Id.
208209
if (!HttpServerTransportOptions.Stateless)
209210
{
210211
if (!Sessions.TryAdd(sessionId, session))
@@ -299,7 +300,7 @@ private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttp
299300

300301
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
301302
transport.SessionId = Protector.Protect(sessionJson);
302-
context.Response.Headers["mcp-session-id"] = transport.SessionId;
303+
context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
303304
return ValueTask.CompletedTask;
304305
};
305306
}

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
2727
private readonly CancellationTokenSource _connectionCts;
2828
private readonly ILogger _logger;
2929

30+
private string? _negotiatedProtocolVersion;
3031
private Task? _getReceiveTask;
3132

3233
public StreamableHttpClientSessionTransport(
@@ -84,7 +85,7 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
8485
},
8586
};
8687

87-
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId);
88+
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
8889

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

@@ -118,14 +119,17 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
118119
throw new McpException($"Streamable HTTP POST response completed without a reply to request with ID: {rpcRequest.Id}");
119120
}
120121

121-
if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse)
122+
if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse)
122123
{
123-
// We've successfully initialized! Copy session-id and start GET request if any.
124-
if (response.Headers.TryGetValues("mcp-session-id", out var sessionIdValues))
124+
// We've successfully initialized! Copy session-id and protocol version, then start GET request if any.
125+
if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues))
125126
{
126127
SessionId = sessionIdValues.FirstOrDefault();
127128
}
128129

130+
var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult);
131+
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;
132+
129133
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
130134
}
131135

@@ -169,7 +173,7 @@ private async Task ReceiveUnsolicitedMessagesAsync()
169173
// Send a GET request to handle any unsolicited messages not sent over a POST response.
170174
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
171175
request.Headers.Accept.Add(s_textEventStreamMediaType);
172-
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId);
176+
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
173177

174178
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _connectionCts.Token).ConfigureAwait(false);
175179

@@ -244,11 +248,20 @@ private void LogJsonException(JsonException ex, string data)
244248
}
245249
}
246250

247-
internal static void CopyAdditionalHeaders(HttpRequestHeaders headers, IDictionary<string, string>? additionalHeaders, string? sessionId = null)
251+
internal static void CopyAdditionalHeaders(
252+
HttpRequestHeaders headers,
253+
IDictionary<string, string>? additionalHeaders,
254+
string? sessionId = null,
255+
string? protocolVersion = null)
248256
{
249257
if (sessionId is not null)
250258
{
251-
headers.Add("mcp-session-id", sessionId);
259+
headers.Add("Mcp-Session-Id", sessionId);
260+
}
261+
262+
if (protocolVersion is not null)
263+
{
264+
headers.Add("MCP-Protocol-Version", protocolVersion);
252265
}
253266

254267
if (additionalHeaders is null)

src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<PackageId>ModelContextProtocol.Core</PackageId>
88
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
99
<PackageReadmeFile>README.md</PackageReadmeFile>
10-
<LangVersion>preview</LangVersion>
1110
</PropertyGroup>
1211

1312
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">

src/ModelContextProtocol/ModelContextProtocol.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<PackageId>ModelContextProtocol</PackageId>
88
<Description>.NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions.</Description>
99
<PackageReadmeFile>README.md</PackageReadmeFile>
10-
<LangVersion>preview</LangVersion>
1110
</PropertyGroup>
1211

1312
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.AspNetCore.Builder;
22
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Primitives;
34
using ModelContextProtocol.Client;
45

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

144145
Assert.Equal("SseTestServer", mcpClient.ServerInfo.Name);
145146
}
147+
148+
[Fact]
149+
public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitialization()
150+
{
151+
var protocolVersionHeaderValues = new List<string?>();
152+
153+
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
154+
155+
await using var app = Builder.Build();
156+
157+
app.Use(next =>
158+
{
159+
return async context =>
160+
{
161+
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-session-id"]))
162+
{
163+
protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]);
164+
}
165+
166+
await next(context);
167+
};
168+
});
169+
170+
app.MapMcp();
171+
172+
await app.StartAsync(TestContext.Current.CancellationToken);
173+
174+
await using var mcpClient = await ConnectAsync();
175+
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
176+
177+
// The header should be included in the GET request, the initialized notification, and the tools/list call.
178+
Assert.Equal(3, protocolVersionHeaderValues.Count);
179+
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
180+
}
146181
}

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ private ClaimsPrincipal CreateUser(string name)
195195
"TestAuthType", "name", "role"));
196196

197197
[McpServerToolType]
198-
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
198+
protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
199199
{
200200
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
201201
public string EchoWithUserName(string message)

tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
7-
<LangVersion>Latest</LangVersion>
87
<IsPackable>false</IsPackable>
98
<IsTestProject>true</IsTestProject>
109
<RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace>

tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
8-
<LangVersion>Latest</LangVersion>
98

109
<IsPackable>false</IsPackable>
1110
<IsTestProject>true</IsTestProject>

0 commit comments

Comments
 (0)