Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
builder.Services.TryAddSingleton<StreamableHttpHandler>();
builder.Services.TryAddSingleton<SseHandler>();
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
builder.Services.AddDataProtection();

builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());

Expand Down

This file was deleted.

This file was deleted.

59 changes: 14 additions & 45 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,7 +17,6 @@ internal sealed class StreamableHttpHandler(
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
StatefulSessionManager sessionManager,
IDataProtectionProvider dataProtection,
ILoggerFactory loggerFactory,
IServiceProvider applicationServices)
{
Expand All @@ -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.
Expand Down Expand Up @@ -128,17 +122,6 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
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.
Expand Down Expand Up @@ -170,6 +153,13 @@ await WriteJsonRpcErrorAsync(context,
{
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);
Expand All @@ -193,14 +183,12 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
}
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);
Expand All @@ -209,21 +197,19 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
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)
Expand All @@ -235,7 +221,7 @@ private async ValueTask<StreamableHttpSession> CreateSessionAsync(
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;
Expand Down Expand Up @@ -273,7 +259,6 @@ internal static string MakeNewSessionId()
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
Expand All @@ -290,22 +275,6 @@ internal static string MakeNewSessionId()
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);
Expand Down
6 changes: 3 additions & 3 deletions src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ private void ThrowIfSamplingUnsupported()
{
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.");
}
Expand All @@ -429,7 +429,7 @@ private void ThrowIfRootsUnsupported()
{
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.");
}
Expand All @@ -442,7 +442,7 @@ private void ThrowIfElicitationUnsupported()
{
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.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ public async Task Connect_TestServer_ShouldProvideServerFields()
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia
{
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"]);
}
Expand All @@ -179,8 +179,11 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia
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));
}
}
2 changes: 2 additions & 0 deletions tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ public async Task Can_UseIHttpContextAccessor_InTool()
[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.
Expand Down
Loading