Make "Stateless" mode sessionless (#760)
- This removes the requirement to configure data protection which is the motivating reason for this change - This means that McpServer.ClientInfo will always be null in stateless mode
This commit is contained in:
parent
740a7d0346
commit
47b9582fd7
9 changed files with 28 additions and 72 deletions
|
|
@ -29,7 +29,6 @@ public static class HttpMcpServerBuilderExtensions
|
|||
builder.Services.TryAddSingleton<StreamableHttpHandler>();
|
||||
builder.Services.TryAddSingleton<SseHandler>();
|
||||
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
|
||||
builder.Services.AddDataProtection();
|
||||
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Stateless;
|
||||
|
||||
internal sealed class StatelessSessionId
|
||||
{
|
||||
[JsonPropertyName("clientInfo")]
|
||||
public Implementation? ClientInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("userIdClaim")]
|
||||
public UserIdClaim? UserIdClaim { get; init; }
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Stateless;
|
||||
|
||||
[JsonSerializable(typeof(StatelessSessionId))]
|
||||
internal sealed partial class StatelessSessionIdJsonContext : JsonSerializerContext;
|
||||
|
|
@ -1,16 +1,13 @@
|
|||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using ModelContextProtocol.AspNetCore.Stateless;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore;
|
||||
|
|
@ -20,7 +17,6 @@ internal sealed class StreamableHttpHandler(
|
|||
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
|
||||
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
|
||||
StatefulSessionManager sessionManager,
|
||||
IDataProtectionProvider dataProtection,
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceProvider applicationServices)
|
||||
{
|
||||
|
|
@ -31,8 +27,6 @@ internal sealed class StreamableHttpHandler(
|
|||
|
||||
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
|
||||
|
||||
private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId");
|
||||
|
||||
public async Task HandlePostRequestAsync(HttpContext context)
|
||||
{
|
||||
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
|
||||
|
|
@ -128,17 +122,6 @@ internal sealed class StreamableHttpHandler(
|
|||
await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
|
||||
return null;
|
||||
}
|
||||
else if (HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
var sessionJson = Protector.Unprotect(sessionId);
|
||||
var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId);
|
||||
var transport = new StreamableHttpServerTransport
|
||||
{
|
||||
Stateless = true,
|
||||
SessionId = sessionId,
|
||||
};
|
||||
session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId);
|
||||
}
|
||||
else if (!sessionManager.TryGetValue(sessionId, out session))
|
||||
{
|
||||
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
|
||||
|
|
@ -170,6 +153,13 @@ internal sealed class StreamableHttpHandler(
|
|||
{
|
||||
return await StartNewSessionAsync(context);
|
||||
}
|
||||
else if (HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
// In stateless mode, we should not be getting existing sessions via sessionId
|
||||
// This path should not be reached in stateless mode
|
||||
await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest);
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
return await GetSessionAsync(context, sessionId);
|
||||
|
|
@ -193,14 +183,12 @@ internal sealed class StreamableHttpHandler(
|
|||
}
|
||||
else
|
||||
{
|
||||
// "(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)";
|
||||
// In stateless mode, each request is independent. Don't set any session ID on the transport.
|
||||
sessionId = "";
|
||||
transport = new()
|
||||
{
|
||||
Stateless = true,
|
||||
};
|
||||
ScheduleStatelessSessionIdWrite(context, transport);
|
||||
}
|
||||
|
||||
return await CreateSessionAsync(context, transport, sessionId);
|
||||
|
|
@ -209,21 +197,19 @@ internal sealed class StreamableHttpHandler(
|
|||
private async ValueTask<StreamableHttpSession> CreateSessionAsync(
|
||||
HttpContext context,
|
||||
StreamableHttpServerTransport transport,
|
||||
string sessionId,
|
||||
StatelessSessionId? statelessId = null)
|
||||
string sessionId)
|
||||
{
|
||||
var mcpServerServices = applicationServices;
|
||||
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
|
||||
if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null)
|
||||
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null)
|
||||
{
|
||||
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
|
||||
|
||||
if (statelessId is not null)
|
||||
if (HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
// The session does not outlive the request in stateless mode.
|
||||
mcpServerServices = context.RequestServices;
|
||||
mcpServerOptions.ScopeRequests = false;
|
||||
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo;
|
||||
}
|
||||
|
||||
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
|
||||
|
|
@ -235,7 +221,7 @@ internal sealed class StreamableHttpHandler(
|
|||
var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
|
||||
context.Features.Set(server);
|
||||
|
||||
var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User);
|
||||
var userIdClaim = GetUserIdClaim(context.User);
|
||||
var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager);
|
||||
|
||||
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
|
||||
|
|
@ -273,7 +259,6 @@ internal sealed class StreamableHttpHandler(
|
|||
RandomNumberGenerator.Fill(buffer);
|
||||
return WebEncoders.Base64UrlEncode(buffer);
|
||||
}
|
||||
|
||||
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
|
||||
{
|
||||
// Implementation for reading a JSON-RPC message from the request body
|
||||
|
|
@ -290,22 +275,6 @@ internal sealed class StreamableHttpHandler(
|
|||
return message;
|
||||
}
|
||||
|
||||
private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport)
|
||||
{
|
||||
transport.OnInitRequestReceived = initRequestParams =>
|
||||
{
|
||||
var statelessId = new StatelessSessionId
|
||||
{
|
||||
ClientInfo = initRequestParams?.ClientInfo,
|
||||
UserIdClaim = GetUserIdClaim(context.User),
|
||||
};
|
||||
|
||||
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
|
||||
transport.SessionId = Protector.Protect(sessionJson);
|
||||
context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
|
||||
=> session.RunAsync(requestAborted);
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ public abstract partial class McpServer : McpSession, IMcpServer
|
|||
{
|
||||
if (ClientCapabilities?.Sampling is null)
|
||||
{
|
||||
if (ServerOptions.KnownClientInfo is not null)
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
|
||||
}
|
||||
|
|
@ -429,7 +429,7 @@ public abstract partial class McpServer : McpSession, IMcpServer
|
|||
{
|
||||
if (ClientCapabilities?.Roots is null)
|
||||
{
|
||||
if (ServerOptions.KnownClientInfo is not null)
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Roots are not supported in stateless mode.");
|
||||
}
|
||||
|
|
@ -442,7 +442,7 @@ public abstract partial class McpServer : McpSession, IMcpServer
|
|||
{
|
||||
if (ClientCapabilities?.Elicitation is null)
|
||||
{
|
||||
if (ServerOptions.KnownClientInfo is not null)
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public static class McpServerExtensions
|
|||
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
|
||||
/// <remarks>
|
||||
/// This method requires the client to support sampling capabilities.
|
||||
/// It allows detailed control over sampling parameters including messages, system prompt, temperature,
|
||||
/// It allows detailed control over sampling parameters including messages, system prompt, temperature,
|
||||
/// and token limits.
|
||||
/// </remarks>
|
||||
[Obsolete($"Use {nameof(McpServer)}.{nameof(McpServer.SampleAsync)} instead. This member will be removed in a subsequent release.")] // See: https://github.com/modelcontextprotocol/csharp-sdk/issues/774
|
||||
|
|
|
|||
|
|
@ -54,8 +54,10 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
|
|||
Assert.NotNull(client.ServerInfo);
|
||||
Assert.NotNull(client.NegotiatedProtocolVersion);
|
||||
|
||||
if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse"))
|
||||
if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse") ||
|
||||
ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/stateless"))
|
||||
{
|
||||
// In SSE and in Streamable HTTP's stateless mode, no protocol-defined session IDs are used.:w
|
||||
Assert.Null(client.SessionId);
|
||||
}
|
||||
else
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
|||
{
|
||||
return async context =>
|
||||
{
|
||||
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-session-id"]))
|
||||
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-protocol-version"]))
|
||||
{
|
||||
protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]);
|
||||
}
|
||||
|
|
@ -179,8 +179,11 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
|||
Assert.Equal("2025-03-26", mcpClient.NegotiatedProtocolVersion);
|
||||
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
await mcpClient.DisposeAsync();
|
||||
|
||||
// The header should be included in the GET request, the initialized notification, the tools/list call, and the delete request.
|
||||
Assert.NotEmpty(protocolVersionHeaderValues);
|
||||
// The DELETE request won't be sent for Stateless mode due to the lack of an Mcp-Session-Id.
|
||||
Assert.Equal(Stateless ? 3 : 4, protocolVersionHeaderValues.Count);
|
||||
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
|||
[Fact]
|
||||
public async Task Messages_FromNewUser_AreRejected()
|
||||
{
|
||||
Assert.SkipWhen(Stateless, "User validation across requests is not applicable in stateless mode.");
|
||||
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
|
||||
|
||||
// Add an authentication scheme that will send a 403 Forbidden response.
|
||||
|
|
|
|||
Loading…
Reference in a new issue