Skip to content

Commit cb23930

Browse files
halter73Copilot
andcommitted
Flip Stateless default to true and obsolete stateful Streamable HTTP options (MCP9005)
Default `HttpServerTransportOptions.Stateless` to true so new code on the 2026-07-28 draft revision (SEP-2567) is sessionless from the start. Mark the surface that only makes sense in the legacy stateful HTTP mode as obsolete behind the new MCP9005 diagnostic so callers see a deprecation hint but can still pin Stateless = false to keep using session-based behaviors during back-compat: * `HttpServerTransportOptions.EventStreamStore` (resumability) * `HttpServerTransportOptions.SessionMigrationHandler` (multi-node migration) * `HttpServerTransportOptions.PerSessionExecutionContext` * `HttpServerTransportOptions.IdleTimeout` * `HttpServerTransportOptions.MaxIdleSessionCount` Internal infrastructure that legitimately reads those options for the back-compat stateful path now suppresses MCP9005 at the use site. Test projects suppress it globally via NoWarn because the suite intentionally exercises both modes. Update tests/samples that previously relied on the implicit `Stateless = false` default to set it explicitly: * TestSseServer.Program — SSE always needs stateful state shared across GET/POST. * ConformanceServer.Program — resumability + OAuth conformance scenarios are stateful. * ResumabilityIntegrationTestsBase — resumability is a stateful concern. * SseIntegrationTests / MapMcpSseTests — SSE requires stateful. * OAuthTestBase — OAuth flow uses the GET /sse session-based endpoint. * MrtrProtocolTests / SessionMigrationTests / StreamableHttpServerConformanceTests — these tests intentionally drive the legacy stateful session machinery. * DraftHttpHandlerTests — tests draft rejection of GET/DELETE endpoints, which are only mapped when Stateless = false. Rework HTTP header conformance helpers (HttpHeaderConformanceTests + StreamableHttpServerConformanceTests) to stop asserting an mcp-session-id response header from draft/non-draft initialize, because the sessionless default means none is returned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bd5c624 commit cb23930

20 files changed

Lines changed: 80 additions & 29 deletions

src/Common/Obsoletions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,8 @@ internal static class Obsoletions
3333
public const string EnableLegacySse_DiagnosticId = "MCP9004";
3434
public const string EnableLegacySse_Message = "Legacy SSE transport has no built-in request backpressure and should only be used with completely trusted clients in isolated processes. Use Streamable HTTP instead.";
3535
public const string EnableLegacySse_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis";
36+
37+
public const string LegacyStatefulHttp_DiagnosticId = "MCP9005";
38+
public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567.";
39+
public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis";
3640
}

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,23 @@ public class HttpServerTransportOptions
5050
/// allowing for load balancing without session affinity.
5151
/// </summary>
5252
/// <value>
53-
/// <see langword="true"/> if the server runs in a stateless mode; <see langword="false"/> if the server tracks state between requests. The default is <see langword="false"/>.
53+
/// <see langword="true"/> if the server runs in a stateless mode; <see langword="false"/> if the server tracks state between requests.
54+
/// The default is <see langword="true"/> as of the <c>2026-07-28</c> draft protocol revision (SEP-2567);
55+
/// set to <see langword="false"/> only when you need to support legacy clients that rely on session affinity.
5456
/// </value>
5557
/// <remarks>
5658
/// If <see langword="true"/>, <see cref="McpSession.SessionId"/> will be null, and the "MCP-Session-Id" header will not be used,
5759
/// the <see cref="RunSessionHandler"/> will be called once for each request, and the "/sse" endpoint will be disabled.
5860
/// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses
5961
/// might arrive at another ASP.NET Core application process.
6062
/// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
63+
/// <para>
64+
/// Requests that declare the <c>2026-07-28</c> draft protocol revision via the <c>MCP-Protocol-Version</c> header
65+
/// are always routed through the stateless path regardless of this property's value, because that revision
66+
/// removes <c>Mcp-Session-Id</c> entirely (SEP-2567).
67+
/// </para>
6168
/// </remarks>
62-
public bool Stateless { get; set; }
69+
public bool Stateless { get; set; } = true;
6370

6471
/// <summary>
6572
/// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (<c>/sse</c> and <c>/message</c>)
@@ -112,6 +119,7 @@ public class HttpServerTransportOptions
112119
/// If this property is not set, the server will attempt to resolve an <see cref="ISseEventStreamStore"/> from DI.
113120
/// </para>
114121
/// </remarks>
122+
[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)]
115123
public ISseEventStreamStore? EventStreamStore { get; set; }
116124

117125
/// <summary>
@@ -128,6 +136,7 @@ public class HttpServerTransportOptions
128136
/// If this property is not set, the server will attempt to resolve an <see cref="ISessionMigrationHandler"/> from DI.
129137
/// </para>
130138
/// </remarks>
139+
[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)]
131140
public ISessionMigrationHandler? SessionMigrationHandler { get; set; }
132141

133142
/// <summary>
@@ -144,6 +153,7 @@ public class HttpServerTransportOptions
144153
/// Enabling a per-session <see cref="ExecutionContext"/> can be useful for setting <see cref="AsyncLocal{T}"/> variables
145154
/// that persist for the entire session, but it prevents you from using IHttpContextAccessor in handlers.
146155
/// </remarks>
156+
[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)]
147157
public bool PerSessionExecutionContext { get; set; }
148158

149159
/// <summary>
@@ -162,6 +172,7 @@ public class HttpServerTransportOptions
162172
/// tied to the open GET <c>/sse</c> request, and they are removed immediately when the client disconnects.
163173
/// </para>
164174
/// </remarks>
175+
[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)]
165176
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
166177

167178
/// <summary>
@@ -182,6 +193,7 @@ public class HttpServerTransportOptions
182193
/// exactly as long as the SSE connection is open.
183194
/// </para>
184195
/// </remarks>
196+
[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)]
185197
public int MaxIdleSessionCount { get; set; } = 10_000;
186198

187199
/// <summary>

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ internal sealed class HttpServerTransportOptionsSetup(IServiceProvider servicePr
1212
{
1313
public void Configure(HttpServerTransportOptions options)
1414
{
15+
#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally.
1516
options.EventStreamStore ??= serviceProvider.GetService<ISseEventStreamStore>();
1617
options.SessionMigrationHandler ??= serviceProvider.GetService<ISessionMigrationHandler>();
18+
#pragma warning restore MCP9005
1719
}
1820
}

src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ public IdleTrackingBackgroundService(
1818
ILogger<IdleTrackingBackgroundService> logger)
1919
{
2020
// Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown.
21+
#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally.
2122
if (options.Value.IdleTimeout != Timeout.InfiniteTimeSpan)
2223
{
2324
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero);
2425
}
2526

2627
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0);
28+
#pragma warning restore MCP9005
2729

2830
_sessions = sessions;
2931
_options = options;

src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ internal sealed partial class StatefulSessionManager(
1717
private readonly ConcurrentDictionary<string, StreamableHttpSession> _sessions = new(StringComparer.Ordinal);
1818

1919
private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider;
20+
#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally.
2021
private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout;
2122
private readonly long _idleTimeoutTicks = GetIdleTimeoutInTimestampTicks(httpServerTransportOptions.Value.IdleTimeout, httpServerTransportOptions.Value.TimeProvider);
2223
private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount;
24+
#pragma warning restore MCP9005
2325

2426
private readonly object _idlePruningLock = new();
2527
private readonly List<long> _idleTimestamps = [];

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,12 @@ await WriteJsonRpcErrorAsync(context,
307307

308308
private async ValueTask<StreamableHttpSession?> TryMigrateSessionAsync(HttpContext context, string sessionId)
309309
{
310+
#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally.
310311
if (HttpServerTransportOptions.SessionMigrationHandler is not { } handler)
311312
{
312313
return null;
313314
}
315+
#pragma warning restore MCP9005
314316

315317
var migrationLock = _migrationLocks.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1));
316318
await migrationLock.WaitAsync(context.RequestAborted);
@@ -414,6 +416,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
414416
if (!isStateless)
415417
{
416418
sessionId = MakeNewSessionId();
419+
#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally.
417420
transport = new(loggerFactory)
418421
{
419422
SessionId = sessionId,
@@ -423,6 +426,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
423426
? (initParams, ct) => handler.OnSessionInitializedAsync(context, sessionId, initParams, ct)
424427
: null,
425428
};
429+
#pragma warning restore MCP9005
426430

427431
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
428432
}
@@ -493,8 +497,10 @@ private async ValueTask<StreamableHttpSession> MigrateSessionAsync(
493497
var transport = new StreamableHttpServerTransport(loggerFactory)
494498
{
495499
SessionId = sessionId,
500+
#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally.
496501
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
497502
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
503+
#pragma warning restore MCP9005
498504
};
499505

500506
// Initialize the transport with the migrated session's init params.
@@ -511,7 +517,9 @@ private async ValueTask<StreamableHttpSession> MigrateSessionAsync(
511517

512518
private async ValueTask<ISseEventStreamReader?> GetEventStreamReaderAsync(HttpContext context, string lastEventId)
513519
{
520+
#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally.
514521
if (HttpServerTransportOptions.EventStreamStore is not { } eventStreamStore)
522+
#pragma warning restore MCP9005
515523
{
516524
await WriteJsonRpcErrorAsync(context,
517525
"Bad Request: This server does not support resuming streams.",

tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ private async Task StartAsync()
2424
Builder.Services.AddMcpServer(options =>
2525
{
2626
options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" };
27-
}).WithHttpTransport();
27+
}).WithHttpTransport(options =>
28+
{
29+
// Map the GET/DELETE endpoints so we can exercise the draft-mode rejection paths
30+
// (these endpoints are not registered in stateless mode, which is the new default).
31+
options.Stateless = false;
32+
});
2833

2934
_app = Builder.Build();
3035
_app.MapMcp();

tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -437,9 +437,9 @@ private async Task InitializeWithDraftVersionAsync()
437437
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
438438
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
439439

440-
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
441-
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
442-
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
440+
// Draft protocol revision (SEP-2567) is sessionless: the server does not return a
441+
// mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28
442+
// to route through the sessionless path.
443443
}
444444

445445
private async Task InitializeWithNonDraftVersionAsync()
@@ -449,9 +449,8 @@ private async Task InitializeWithNonDraftVersionAsync()
449449
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
450450
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
451451

452-
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
453-
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
454-
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
452+
// Server is stateless by default (SEP-2567), so initializing with the non-draft protocol does not return
453+
// a mcp-session-id header. Subsequent requests are independent, just like the draft path.
455454
}
456455

457456
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using Microsoft.AspNetCore.Builder;
1+
using Microsoft.AspNetCore.Builder;
22
using Microsoft.Extensions.DependencyInjection;
33
using ModelContextProtocol.Protocol;
44
using ModelContextProtocol.Server;
@@ -21,7 +21,7 @@ protected override void ConfigureStateless(HttpServerTransportOptions options)
2121
[InlineData("/mcp/secondary")]
2222
public async Task Allows_Customizing_Route(string pattern)
2323
{
24-
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true);
24+
Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; });
2525
await using var app = Builder.Build();
2626

2727
app.MapMcp(pattern);
@@ -53,7 +53,7 @@ public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePat
5353
Name = "TestCustomRouteServer",
5454
Version = "1.0.0",
5555
};
56-
}).WithHttpTransport(options => options.EnableLegacySse = true);
56+
}).WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; });
5757
await using var app = Builder.Build();
5858

5959
app.MapMcp(routePattern);
@@ -83,7 +83,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_InSseMode()
8383
return "Complete";
8484
}, options: new() { Name = "polling_tool" });
8585

86-
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true).WithTools([pollingTool]);
86+
Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }).WithTools([pollingTool]);
8787

8888
await using var app = Builder.Build();
8989
app.MapMcp();

tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
<IsPackable>false</IsPackable>
88
<IsTestProject>true</IsTestProject>
99
<RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace>
10+
<!-- The test suite intentionally exercises the obsoleted stateful Streamable HTTP surface. -->
11+
<NoWarn>$(NoWarn);MCP9005</NoWarn>
1012
</PropertyGroup>
1113

1214
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">

0 commit comments

Comments
 (0)