Send MCP-Protocol-Version header in Streamable HTTP client transport (#500)
This commit is contained in:
parent
b9b4a23005
commit
befa31d5c9
10 changed files with 69 additions and 24 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"/>.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'">
|
||||||
|
|
|
||||||
|
|
@ -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'">
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue