Skip to content

Commit 7392032

Browse files
committed
Address PR feedback
- Don't store client capabilities in stateless session ID since sampling and roots cannot be supported in stateless mode - Immediate throw for unsupported operations in stateless mode - Improve HttpTransportOptions doc comments
1 parent 63a60e5 commit 7392032

20 files changed

Lines changed: 330 additions & 139 deletions

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,36 @@ public class HttpServerTransportOptions
2424

2525
/// <summary>
2626
/// Gets or sets whether the server should run in a stateless mode that does not require all requests for a given session
27-
/// to arrive to the same ASP.NET Core application process. If true, the /sse endpoint will be disabled, and
28-
/// client capabilities will be round-tripped as part of the mcp-session-id header instead of stored in memory. Defaults to false.
27+
/// to arrive to the same ASP.NET Core application process.
2928
/// </summary>
29+
/// <remarks>
30+
/// 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
32+
/// requests are also unsupported, because any responses may arrive at another ASP.NET Core application process.
33+
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
34+
/// Defaults to <see langword="false"/>.
35+
/// </remarks>
3036
public bool Stateless { get; set; }
3137

3238
/// <summary>
33-
/// Represents the duration of time the server will wait between any active requests before timing out an
34-
/// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will
35-
/// receive a 404 status code and should restart their session. A client can keep their session open by
36-
/// keeping a GET request open. The default value is set to 2 hours.
39+
/// Gets or sets the duration of time the server will wait between any active requests before timing out an MCP session.
3740
/// </summary>
41+
/// <remarks>
42+
/// This is checked in background every 5 seconds. A client trying to resume a session will receive a 404 status code
43+
/// and should restart their session. A client can keep their session open by keeping a GET request open.
44+
/// Defaults to 2 hours.
45+
/// </remarks>
3846
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
3947

4048
/// <summary>
41-
/// The maximum number of idle sessions to track. This is used to limit the number of sessions that can be idle at once.
49+
/// Gets or sets maximum number of idle sessions to track in memory. This is used to limit the number of sessions that can be idle at once.
50+
/// </summary>
51+
/// <remarks>
4252
/// Past this limit, the server will log a critical error and terminate the oldest idle sessions even if they have not reached
4353
/// their <see cref="IdleTimeout"/> until the idle session count is below this limit. Clients that keep their session open by
44-
/// keeping a GET request open will not count towards this limit. The default value is set to 100,000 sessions.
45-
/// </summary>
54+
/// keeping a GET request open will not count towards this limit.
55+
/// Defaults to 100,000 sessions.
56+
/// </remarks>
4657
public int MaxIdleSessionCount { get; set; } = 100_000;
4758

4859
/// <summary>

src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ internal sealed partial class IdleTrackingBackgroundService(
1212
ILogger<IdleTrackingBackgroundService> logger) : BackgroundService
1313
{
1414
// The compiler will complain about the parameter being unused otherwise despite the source generator.
15-
private ILogger _logger = logger;
15+
private readonly ILogger _logger = logger;
1616

1717
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
1818
{

src/ModelContextProtocol.AspNetCore/StatelessSessionId.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ namespace ModelContextProtocol.AspNetCore;
55

66
internal sealed class StatelessSessionId
77
{
8-
[JsonPropertyName("capabilities")]
9-
public ClientCapabilities? Capabilities { get; init; }
10-
118
[JsonPropertyName("clientInfo")]
129
public Implementation? ClientInfo { get; init; }
1310

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Microsoft.Net.Http.Headers;
88
using ModelContextProtocol.Protocol.Messages;
99
using ModelContextProtocol.Protocol.Transport;
10+
using ModelContextProtocol.Protocol.Types;
1011
using ModelContextProtocol.Server;
1112
using ModelContextProtocol.Utils.Json;
1213
using System.Collections.Concurrent;
@@ -27,8 +28,6 @@ internal sealed class StreamableHttpHandler(
2728
ILoggerFactory loggerFactory,
2829
IServiceProvider applicationServices)
2930
{
30-
private const string StatelessSessionIdPurpose = "Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId";
31-
3231
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
3332

3433
private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json");
@@ -38,7 +37,7 @@ internal sealed class StreamableHttpHandler(
3837

3938
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
4039

41-
private IDataProtector Protector { get; } = dataProtection.CreateProtector(StatelessSessionIdPurpose);
40+
private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId");
4241

4342
public async Task HandlePostRequestAsync(HttpContext context)
4443
{
@@ -139,7 +138,10 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
139138
{
140139
var sessionJson = Protector.Unprotect(sessionId);
141140
var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId);
142-
var transport = new StreamableHttpServerTransport();
141+
var transport = new StreamableHttpServerTransport
142+
{
143+
Stateless = true,
144+
};
143145
session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId);
144146
}
145147
else if (!Sessions.TryGetValue(sessionId, out session))
@@ -148,7 +150,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
148150
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
149151
// JSON-RPC library from Microsoft called StreamJsonRpc where it's called JsonRpcErrorCode.NoMarshaledObjectFound
150152
// https://learn.microsoft.com/dotnet/api/streamjsonrpc.protocol.jsonrpcerrorcode?view=streamjsonrpc-2.9#fields
151-
await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, 32001);
153+
await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001);
152154
return null;
153155
}
154156

@@ -182,18 +184,23 @@ await WriteJsonRpcErrorAsync(context,
182184
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewSessionAsync(HttpContext context)
183185
{
184186
string sessionId;
185-
var transport = new StreamableHttpServerTransport();
187+
StreamableHttpServerTransport transport;
186188

187189
if (!HttpServerTransportOptions.Stateless)
188190
{
189191
sessionId = MakeNewSessionId();
192+
transport = new();
190193
context.Response.Headers["mcp-session-id"] = sessionId;
191194
}
192195
else
193196
{
194197
// "(uninitialized stateless id)" is not written anywhere. We delay writing the mcp-session-id
195198
// until after we receive the initialize request with the client info we need to serialize.
196199
sessionId = "(uninitialized stateless id)";
200+
transport = new()
201+
{
202+
Stateless = true,
203+
};
197204
ScheduleStatelessSessionIdWrite(context, transport);
198205
}
199206

@@ -217,15 +224,18 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSes
217224
string sessionId,
218225
StatelessSessionId? statelessId = null)
219226
{
227+
var mcpServerServices = applicationServices;
220228
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
221229
if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null)
222230
{
223231
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
224232

225233
if (statelessId is not null)
226234
{
227-
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo ?? mcpServerOptions.KnownClientInfo;
228-
mcpServerOptions.KnownClientCapabilities = statelessId.Capabilities ?? mcpServerOptions.KnownClientCapabilities;
235+
// The session does not outlive the request in stateless mode.
236+
mcpServerServices = context.RequestServices;
237+
mcpServerOptions.ScopeRequests = false;
238+
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo;
229239
}
230240

231241
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
@@ -234,8 +244,7 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSes
234244
}
235245
}
236246

237-
// Use application instead of request services, because the session will likely outlive the first initialization request.
238-
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, applicationServices);
247+
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
239248
context.Features.Set(server);
240249

241250
var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User);
@@ -286,8 +295,7 @@ private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttp
286295
{
287296
var statelessId = new StatelessSessionId
288297
{
289-
ClientInfo = transport.ClientInfo,
290-
Capabilities = transport.ClientCapabilities,
298+
ClientInfo = transport?.InitializeRequest?.ClientInfo,
291299
UserIdClaim = GetUserIdClaim(context.User),
292300
};
293301

src/ModelContextProtocol/Protocol/Transport/StreamableHttpPostTransport.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ public async ValueTask<bool> RunAsync(CancellationToken cancellationToken)
4242

4343
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
4444
{
45+
if (parentTransport.Stateless && message is JsonRpcRequest)
46+
{
47+
throw new InvalidOperationException("Server to client requests are not supported in stateless mode.");
48+
}
49+
4550
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
4651
}
4752

@@ -76,11 +81,9 @@ private async ValueTask OnMessageReceivedAsync(JsonRpcMessage? message, Cancella
7681
_pendingRequest = request.Id;
7782

7883
// Store client capabilities so they can be serialized by "stateless" callers for use in later requests.
79-
if (request.Method == RequestMethods.Initialize)
84+
if (parentTransport.Stateless && request.Method == RequestMethods.Initialize)
8085
{
81-
var initializeRequestParams = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.JsonContext.Default.InitializeRequestParams);
82-
parentTransport.ClientCapabilities = initializeRequestParams?.Capabilities;
83-
parentTransport.ClientInfo = initializeRequestParams?.ClientInfo;
86+
parentTransport.InitializeRequest = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.JsonContext.Default.InitializeRequestParams);
8487
}
8588
}
8689

src/ModelContextProtocol/Protocol/Transport/StreamableHttpServerTransport.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,18 @@ public sealed class StreamableHttpServerTransport : ITransport
3838
private int _getRequestStarted;
3939

4040
/// <summary>
41-
/// Gets the capabilities supported by the client if it was received by <see cref="HandlePostRequest(IDuplexPipe, CancellationToken)"/>.
41+
/// Configures whether the transport should be in stateless mode that does not require all requests for a given session
42+
/// to arrive to the same ASP.NET Core application process. Unsolicited server-to-client messages are not supported in this mode,
43+
/// so calling <see cref="HandleGetRequest(Stream, CancellationToken)"/> results in an <see cref="InvalidOperationException"/>.
44+
/// Server-to-client requests are also unsupported, because the responses may arrive at another ASP.NET Core application process.
45+
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
4246
/// </summary>
43-
public ClientCapabilities? ClientCapabilities { get; internal set; }
47+
public bool Stateless { get; init; }
4448

4549
/// <summary>
46-
/// Gets the version and implementation information of the connected client if it was received by <see cref="HandlePostRequest(IDuplexPipe, CancellationToken)"/>.
50+
/// Gets the initialize request if it was received by <see cref="HandlePostRequest(IDuplexPipe, CancellationToken)"/> and <see cref="Stateless"/> is set to <see langword="true"/>.
4751
/// </summary>
48-
public Implementation? ClientInfo { get; internal set; }
52+
public InitializeRequestParams? InitializeRequest { get; internal set; }
4953

5054
/// <inheritdoc/>
5155
public ChannelReader<JsonRpcMessage> MessageReader => _incomingChannel.Reader;
@@ -62,6 +66,11 @@ public sealed class StreamableHttpServerTransport : ITransport
6266
/// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns>
6367
public async Task HandleGetRequest(Stream sseResponseStream, CancellationToken cancellationToken)
6468
{
69+
if (Stateless)
70+
{
71+
throw new InvalidOperationException("GET requests are not supported in stateless mode.");
72+
}
73+
6574
if (Interlocked.Exchange(ref _getRequestStarted, 1) == 1)
6675
{
6776
throw new InvalidOperationException("Session resumption is not yet supported. Please start a new session.");
@@ -93,6 +102,11 @@ public async Task<bool> HandlePostRequest(IDuplexPipe httpBodies, CancellationTo
93102
/// <inheritdoc/>
94103
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
95104
{
105+
if (Stateless)
106+
{
107+
throw new InvalidOperationException("Unsolicited server to client messages are not supported in stateless mode.");
108+
}
109+
96110
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
97111
}
98112

src/ModelContextProtocol/Server/McpServer.cs

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory?
5858
_serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})";
5959
_servicesScopePerRequest = options.ScopeRequests;
6060

61-
ClientCapabilities = options.KnownClientCapabilities;
6261
ClientInfo = options.KnownClientInfo;
6362
UpdateEndpointNameWithClientInfo();
6463

@@ -80,26 +79,29 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory?
8079
}
8180

8281
// Now that everything has been configured, subscribe to any necessary notifications.
83-
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
82+
if (transport is not StreamableHttpServerTransport streamableHttpTransport || streamableHttpTransport.Stateless is false)
8483
{
85-
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification);
86-
tools.Changed += changed;
87-
_disposables.Add(() => tools.Changed -= changed);
88-
}
84+
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
85+
{
86+
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification);
87+
tools.Changed += changed;
88+
_disposables.Add(() => tools.Changed -= changed);
89+
}
8990

90-
if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts)
91-
{
92-
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
93-
prompts.Changed += changed;
94-
_disposables.Add(() => prompts.Changed -= changed);
95-
}
91+
if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts)
92+
{
93+
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
94+
prompts.Changed += changed;
95+
_disposables.Add(() => prompts.Changed -= changed);
96+
}
9697

97-
var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection;
98-
if (resources is not null)
99-
{
100-
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
101-
resources.Changed += changed;
102-
_disposables.Add(() => resources.Changed -= changed);
98+
var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection;
99+
if (resources is not null)
100+
{
101+
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
102+
resources.Changed += changed;
103+
_disposables.Add(() => resources.Changed -= changed);
104+
}
103105
}
104106

105107
// And initialize the session.

src/ModelContextProtocol/Server/McpServerExtensions.cs

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,10 @@ public static class McpServerExtensions
3030
/// and token limits.
3131
/// </remarks>
3232
public static ValueTask<CreateMessageResult> RequestSamplingAsync(
33-
this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken)
33+
this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken = default)
3434
{
3535
Throw.IfNull(server);
36-
37-
if (server.ClientCapabilities?.Sampling is null)
38-
{
39-
throw new InvalidOperationException("Client does not support sampling.");
40-
}
36+
ThrowIfSamplingUnsupported(server);
4137

4238
return server.SendRequestAsync(
4339
RequestMethods.SamplingCreateMessage,
@@ -163,11 +159,7 @@ public static async Task<ChatResponse> RequestSamplingAsync(
163159
public static IChatClient AsSamplingChatClient(this IMcpServer server)
164160
{
165161
Throw.IfNull(server);
166-
167-
if (server.ClientCapabilities?.Sampling is null)
168-
{
169-
throw new InvalidOperationException("Client does not support sampling.");
170-
}
162+
ThrowIfSamplingUnsupported(server);
171163

172164
return new SamplingChatClient(server);
173165
}
@@ -198,14 +190,10 @@ public static ILoggerProvider AsClientLoggerProvider(this IMcpServer server)
198190
/// or other structured data sources that the client makes available through the protocol.
199191
/// </remarks>
200192
public static ValueTask<ListRootsResult> RequestRootsAsync(
201-
this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken)
193+
this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken = default)
202194
{
203195
Throw.IfNull(server);
204-
205-
if (server.ClientCapabilities?.Roots is null)
206-
{
207-
throw new InvalidOperationException("Client does not support roots.");
208-
}
196+
ThrowIfRootsUnsupported(server);
209197

210198
return server.SendRequestAsync(
211199
RequestMethods.RootsList,
@@ -215,6 +203,32 @@ public static ValueTask<ListRootsResult> RequestRootsAsync(
215203
cancellationToken: cancellationToken);
216204
}
217205

206+
private static void ThrowIfSamplingUnsupported(IMcpServer server)
207+
{
208+
if (server.ClientCapabilities?.Sampling is null)
209+
{
210+
if (server.ServerOptions.KnownClientInfo is not null)
211+
{
212+
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
213+
}
214+
215+
throw new InvalidOperationException("Client does not support sampling.");
216+
}
217+
}
218+
219+
private static void ThrowIfRootsUnsupported(IMcpServer server)
220+
{
221+
if (server.ClientCapabilities?.Roots is null)
222+
{
223+
if (server.ServerOptions.KnownClientInfo is not null)
224+
{
225+
throw new InvalidOperationException("Roots are not supported in stateless mode.");
226+
}
227+
228+
throw new InvalidOperationException("Client does not support roots.");
229+
}
230+
}
231+
218232
/// <summary>Provides an <see cref="IChatClient"/> implementation that's implemented via client sampling.</summary>
219233
private sealed class SamplingChatClient(IMcpServer server) : IChatClient
220234
{

0 commit comments

Comments
 (0)