diff --git a/Directory.Build.props b/Directory.Build.props index 159c4a8f..bca37592 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 13 + preview true enable enable diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 7741193e..b88cfb76 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -28,7 +28,7 @@ public class HttpServerTransportOptions /// /// /// If , 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 . diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index a38fa7c6..aeac38bf 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -26,6 +26,7 @@ internal sealed class StreamableHttpHandler( ILoggerFactory loggerFactory, IServiceProvider applicationServices) { + private const string McpSessionIdHeaderName = "Mcp-Session-Id"; private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); public ConcurrentDictionary> Sessions { get; } = new(StringComparer.Ordinal); @@ -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) { @@ -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) { @@ -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(); @@ -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?> GetOrCreateSessionAsync(HttpContext context) { - var sessionId = context.Request.Headers["mcp-session-id"].ToString(); + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); if (string.IsNullOrEmpty(sessionId)) { @@ -188,11 +189,11 @@ private async ValueTask> 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() @@ -204,7 +205,7 @@ private async ValueTask> 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)) @@ -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; }; } diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 981185dd..d4d03480 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -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( @@ -84,7 +85,7 @@ internal async Task 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); @@ -118,14 +119,17 @@ internal async Task 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(); } @@ -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); @@ -244,11 +248,20 @@ private void LogJsonException(JsonException ex, string data) } } - internal static void CopyAdditionalHeaders(HttpRequestHeaders headers, IDictionary? additionalHeaders, string? sessionId = null) + internal static void CopyAdditionalHeaders( + HttpRequestHeaders headers, + IDictionary? 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) diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index e9edb169..928c76d9 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -7,7 +7,6 @@ ModelContextProtocol.Core Core .NET SDK for the Model Context Protocol (MCP) README.md - preview diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 74604158..994f3dcc 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -7,7 +7,6 @@ ModelContextProtocol .NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions. README.md - preview diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index b2f65f82..b1c1a89d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; using ModelContextProtocol.Client; namespace ModelContextProtocol.AspNetCore.Tests; @@ -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(); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); + + 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)); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index d9d98b74..2690352f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -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) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 76187ee5..bbcac5f5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -4,7 +4,6 @@ net9.0;net8.0 enable enable - Latest false true ModelContextProtocol.AspNetCore.Tests diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index d88ec985..6ddad70f 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -5,7 +5,6 @@ net9.0;net8.0 enable enable - Latest false true