Add HttpServerSessionMode for hybrid stateful/stateless HTTP serving (#1796)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
This commit is contained in:
pardhu 2026-08-12 23:36:36 -07:00 • committed by GitHub
commit c6a97d94d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 764 additions and 150 deletions

View file

@ -0,0 +1,68 @@
namespace ModelContextProtocol.AspNetCore.Tests;
public class HttpServerTransportOptionsTests
{
[Fact]
public void SessionMode_DefaultsToStateless()
{
var options = new HttpServerTransportOptions();
Assert.Equal(HttpServerSessionMode.Stateless, options.SessionMode);
Assert.True(options.Stateless);
}
[Theory]
[InlineData(true, HttpServerSessionMode.Stateless)]
[InlineData(false, HttpServerSessionMode.Stateful)]
public void SettingStateless_SelectsEquivalentSessionMode(bool stateless, HttpServerSessionMode expected)
{
var options = new HttpServerTransportOptions { Stateless = stateless };
Assert.Equal(stateless, options.Stateless);
Assert.Equal(expected, options.SessionMode);
}
[Theory]
[InlineData(HttpServerSessionMode.Stateless, true)]
[InlineData(HttpServerSessionMode.Stateful, false)]
public void ReadingStateless_ReflectsSessionMode(HttpServerSessionMode sessionMode, bool expected)
{
var options = new HttpServerTransportOptions { SessionMode = sessionMode };
Assert.Equal(expected, options.Stateless);
}
[Fact]
public void ReadingStateless_ReturnsFalseForHybridMode()
{
var options = new HttpServerTransportOptions
{
SessionMode = HttpServerSessionMode.StatefulForInitializeClients,
};
Assert.False(options.Stateless);
Assert.Equal(HttpServerSessionMode.StatefulForInitializeClients, options.SessionMode);
}
[Fact]
public void AssigningBothProperties_DoesNotThrow_AndLastAssignmentWins()
{
var options = new HttpServerTransportOptions();
options.Stateless = false;
Assert.False(options.Stateless);
Assert.Equal(HttpServerSessionMode.Stateful, options.SessionMode);
options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients;
Assert.False(options.Stateless);
Assert.Equal(HttpServerSessionMode.StatefulForInitializeClients, options.SessionMode);
options.Stateless = true;
Assert.True(options.Stateless);
Assert.Equal(HttpServerSessionMode.Stateless, options.SessionMode);
options.SessionMode = HttpServerSessionMode.Stateful;
Assert.False(options.Stateless);
Assert.Equal(HttpServerSessionMode.Stateful, options.SessionMode);
}
}

View file

@ -24,10 +24,10 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHttpHandlerTests), Version = "1" };
}).WithHttpTransport(options =>
{
// Stateless = false maps the GET/DELETE endpoints and opts the author into sessions. Starting with
// SessionMode = HttpServerSessionMode.Stateful maps the GET/DELETE endpoints and opts the author into sessions. Starting with
// the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so such a request is
// refused on a session-enabled server. Stateless = true (the default) serves them natively.
options.Stateless = stateless;
// refused on a session-enabled server. SessionMode = HttpServerSessionMode.Stateless (the default) serves them natively.
options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful;
});
_app = Builder.Build();
@ -69,7 +69,7 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError()
{
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567),
// so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that
// so the server cannot honor it when configured with sessions (SessionMode = HttpServerSessionMode.Stateful). The server refuses that
// version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back
// to the initialize handshake.
await StartAsync(stateless: false);

View file

@ -0,0 +1,344 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// End-to-end coverage for <see cref="HttpServerSessionMode.StatefulForInitializeClients"/>: a single endpoint
/// that serves <c>initialize</c>-handshake clients with full stateful sessions while serving <c>2026-07-28</c>
/// and later clients statelessly, without forcing them to downgrade
/// (<see href="https://github.com/modelcontextprotocol/csharp-sdk/issues/1777"/>).
/// </summary>
[McpServerToolType]
public class July2026ProtocolHybridSessionModeTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private WebApplication? _app;
private int _configureSessionOptionsCount;
private int _runSessionHandlerCount;
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
[McpServerTool(Name = "greet")]
public static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!";
[McpServerTool(Name = "greet_via_elicit")]
public static async Task<string> GreetViaElicit(McpServer server, CancellationToken cancellationToken)
{
// Server to client requests only work over a stateful session, so this proves the initialize-handshake
// half of a hybrid endpoint keeps its session even though the endpoint also serves stateless requests.
var elicitResult = await server.ElicitAsync(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new(),
}, cancellationToken);
var name = elicitResult.Content?.TryGetValue("answer", out var answer) == true
? answer.GetString()
: "stranger";
return $"Hello, {name}!";
}
[McpServerTool(Name = "scope_state")]
public static string ScopeState(ScopedService scopedService) => scopedService.State ?? "<not the http request scope>";
private async Task StartHybridServerAsync(bool trackRunSessionHandler = false)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHybridSessionModeTests), Version = "1" };
})
.WithHttpTransport(options =>
{
options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients;
options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) =>
{
Interlocked.Increment(ref _configureSessionOptionsCount);
return Task.CompletedTask;
};
if (trackRunSessionHandler)
{
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental.
options.RunSessionHandler = async (httpContext, server, cancellationToken) =>
{
Interlocked.Increment(ref _runSessionHandlerCount);
await server.RunAsync(cancellationToken);
};
#pragma warning restore MCPEXP002
}
})
.WithTools<July2026ProtocolHybridSessionModeTests>();
Builder.Services.AddScoped<ScopedService>();
_app = Builder.Build();
_app.Use(next => context =>
{
context.RequestServices.GetRequiredService<ScopedService>().State = "From request middleware!";
return next(context);
});
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
}
private Task<McpClient> ConnectClientAsync(string? protocolVersion = null, Action<McpClientOptions>? configureClient = null)
{
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri("http://localhost:5000/"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
// A null ProtocolVersion prefers 2026-07-28 and probes with server/discover before considering a
// fallback to the initialize handshake. Pinning an older version forces the initialize handshake.
var clientOptions = new McpClientOptions { ProtocolVersion = protocolVersion };
configureClient?.Invoke(clientOptions);
return McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
}
[Fact]
public async Task ModernAndLegacyClients_ShareOneEndpoint_AndModernDoesNotDowngrade()
{
await StartHybridServerAsync();
await using var modernClient = await ConnectClientAsync();
await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion);
// The whole point of the hybrid mode: the default client keeps 2026-07-28 instead of downgrading.
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, modernClient.NegotiatedProtocolVersion);
Assert.Null(modernClient.SessionId);
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, legacyClient.NegotiatedProtocolVersion);
Assert.False(string.IsNullOrEmpty(legacyClient.SessionId));
// Both halves of the endpoint remain usable while the other is connected.
var modernResult = await modernClient.CallToolAsync("greet",
new Dictionary<string, object?> { ["name"] = "Modern" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("Hello, Modern!", Assert.IsType<TextContentBlock>(Assert.Single(modernResult.Content)).Text);
var legacyResult = await legacyClient.CallToolAsync("greet",
new Dictionary<string, object?> { ["name"] = "Legacy" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("Hello, Legacy!", Assert.IsType<TextContentBlock>(Assert.Single(legacyResult.Content)).Text);
}
[Fact]
public async Task LegacyClient_OnHybridServer_StillSupportsServerToClientElicitation()
{
await StartHybridServerAsync();
await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion, options =>
{
options.Handlers.ElicitationHandler = (request, ct) => new ValueTask<ElicitResult>(new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(),
},
});
});
var result = await legacyClient.CallToolAsync("greet_via_elicit",
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(result.IsError is not true);
Assert.Equal("Hello, Bob!", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
}
[Fact]
public async Task ModernRequests_UseRequestScopedServices_WhileLegacySessionsUseApplicationServices()
{
await StartHybridServerAsync();
await using var modernClient = await ConnectClientAsync();
await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion);
// Stateless requests resolve services from HttpContext.RequestServices, so the tool observes the state
// that the ASP.NET Core middleware set on the request-scoped service.
var modernResult = await modernClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("From request middleware!", Assert.IsType<TextContentBlock>(Assert.Single(modernResult.Content)).Text);
// Stateful sessions outlive the HTTP request, so they scope requests off the application services
// instead and never see the middleware's request-scoped state.
var legacyResult = await legacyClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("<not the http request scope>", Assert.IsType<TextContentBlock>(Assert.Single(legacyResult.Content)).Text);
}
[Fact]
public async Task ConfigureSessionOptions_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients()
{
await StartHybridServerAsync();
var beforeLegacyConnect = Volatile.Read(ref _configureSessionOptionsCount);
await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion);
// The initialize request creates the session; notifications/initialized reuses it.
Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyConnect);
var beforeLegacyCalls = Volatile.Read(ref _configureSessionOptionsCount);
await legacyClient.CallToolAsync("greet", new Dictionary<string, object?> { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken);
await legacyClient.CallToolAsync("greet", new Dictionary<string, object?> { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken);
// Subsequent requests reuse the session, so the callback does not run again.
Assert.Equal(0, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyCalls);
await using var modernClient = await ConnectClientAsync();
var beforeModernCall = Volatile.Read(ref _configureSessionOptionsCount);
await modernClient.CallToolAsync("greet", new Dictionary<string, object?> { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken);
// Each 2026-07-28 POST creates a fresh per-request server, so the callback runs again.
Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeModernCall);
}
[Fact]
public async Task RunSessionHandler_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients()
{
await StartHybridServerAsync(trackRunSessionHandler: true);
var beforeLegacyConnect = Volatile.Read(ref _runSessionHandlerCount);
await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion);
Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyConnect);
var beforeLegacyCalls = Volatile.Read(ref _runSessionHandlerCount);
await legacyClient.CallToolAsync("greet", new Dictionary<string, object?> { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken);
await legacyClient.CallToolAsync("greet", new Dictionary<string, object?> { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(0, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyCalls);
var beforeModernConnect = Volatile.Read(ref _runSessionHandlerCount);
await using var modernClient = await ConnectClientAsync();
Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernConnect);
var beforeModernCall = Volatile.Read(ref _runSessionHandlerCount);
await modernClient.CallToolAsync("greet", new Dictionary<string, object?> { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernCall);
}
[Fact]
public async Task ModernPost_DoesNotMintSessionId_WhileLegacyInitializeDoes()
{
await StartHybridServerAsync();
using var modernResponse = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest, mcpMethod: "server/discover");
Assert.Equal(HttpStatusCode.OK, modernResponse.StatusCode);
Assert.False(modernResponse.Headers.Contains("Mcp-Session-Id"), "2026-07-28 responses must not include Mcp-Session-Id.");
using var legacyResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest);
Assert.Equal(HttpStatusCode.OK, legacyResponse.StatusCode);
Assert.False(string.IsNullOrEmpty(Assert.Single(legacyResponse.Headers.GetValues("Mcp-Session-Id"))));
}
[Fact]
public async Task ModernPost_IgnoresMcpSessionIdHeader()
{
await StartHybridServerAsync();
// SEP-2567 removed sessions from the 2026-07-28 revision, so a stray session ID must neither be honored
// nor looked up against the stateful session manager the hybrid endpoint keeps for legacy clients.
using var response = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest,
mcpMethod: "server/discover", sessionId: "non-existent-session-id");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Mcp-Session-Id"));
}
[Fact]
public async Task LegacyGetAndDelete_RemainAvailable_WhileModernGetAndDeleteReturn405()
{
await StartHybridServerAsync();
using var initializeResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest);
var sessionId = Assert.Single(initializeResponse.Headers.GetValues("Mcp-Session-Id"));
// The GET and DELETE endpoints are still mapped, so legacy clients keep the unsolicited-message stream
// and explicit session termination.
using var legacyGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId);
Assert.Equal(HttpStatusCode.OK, legacyGet.StatusCode);
using var modernGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.July2026ProtocolVersion, content: null);
Assert.Equal(HttpStatusCode.MethodNotAllowed, modernGet.StatusCode);
Assert.Equal(["POST"], modernGet.Content.Headers.Allow);
using var modernDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.July2026ProtocolVersion, content: null);
Assert.Equal(HttpStatusCode.MethodNotAllowed, modernDelete.StatusCode);
Assert.Equal(["POST"], modernDelete.Content.Headers.Allow);
using var legacyDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId);
Assert.Equal(HttpStatusCode.OK, legacyDelete.StatusCode);
// The session is gone, which proves the legacy DELETE was honored rather than short-circuited.
using var afterDelete = await SendAsync(HttpMethod.Post, McpProtocolVersions.November2025ProtocolVersion, ListToolsRequest, sessionId: sessionId);
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
}
private Task<HttpResponseMessage> SendAsync(
HttpMethod method,
string? protocolVersion,
string? content = null,
string? mcpMethod = null,
string? sessionId = null)
{
var request = new HttpRequestMessage(method, "");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (protocolVersion is not null)
{
request.Headers.Add("MCP-Protocol-Version", protocolVersion);
}
if (mcpMethod is not null)
{
request.Headers.Add("Mcp-Method", mcpMethod);
}
if (sessionId is not null)
{
request.Headers.Add("Mcp-Session-Id", sessionId);
}
if (content is not null)
{
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
}
return HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
}
private static string DiscoverRequest => """
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"HybridTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
""";
private static string InitializeRequest => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"HybridTestClient","version":"1.0"}}}
""";
private static string ListToolsRequest => """
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
""";
public class ScopedService
{
public string? State { get; set; }
}
}

View file

@ -11,12 +11,13 @@ namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// End-to-end coverage for a default (2026-07-28-first) client connecting to a real C# Streamable HTTP
/// server that deliberately opted into sessions (<see cref="HttpServerTransportOptions.Stateless"/>
/// is <c>false</c>). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports
/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with <c>-32022 UnsupportedProtocolVersion</c>.
/// The client must then auto-downgrade to the <c>initialize</c> handshake, obtain the stateful session
/// the server author opted into, and continue to work, including a server→client elicitation round-trip
/// resolved over the stateful session via the initialize-handshake backcompat resolver.
/// server that deliberately opted into sessions using either <see cref="HttpServerTransportOptions.Stateless"/>
/// or <see cref="HttpServerTransportOptions.SessionMode"/>. Starting with the 2026-07-28 protocol revision,
/// Streamable HTTP no longer supports sessions (SEP-2567 / SEP-2575), so the server refuses the probe with
/// <c>-32022 UnsupportedProtocolVersion</c>. The client must then auto-downgrade to the <c>initialize</c>
/// handshake, obtain the stateful session the server author opted into, and continue to work, including a
/// server-to-client elicitation round-trip resolved over the stateful session via the initialize-handshake
/// backcompat resolver.
/// </summary>
public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
@ -37,7 +38,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
[McpServerTool(Name = "greet_via_elicit")]
private static async Task<string> GreetViaElicit(McpServer server, CancellationToken cancellationToken)
{
// Server→client round-trip: only works when the session is stateful, which is exactly what
// Server-to-client round-trip: only works when the session is stateful, which is exactly what
// the initialize fallback re-establishes for the 2026-07-28-first client.
var elicitResult = await server.ElicitAsync(new ElicitRequestParams
{
@ -52,15 +53,19 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
return $"Hello, {name}!";
}
private async Task StartStatefulServerAsync()
private Task StartStatefulServerAsync() =>
StartStatefulServerAsync(options => options.Stateless = false);
private Task StartStatefulSessionModeServerAsync() =>
StartStatefulServerAsync(options => options.SessionMode = HttpServerSessionMode.Stateful);
private async Task StartStatefulServerAsync(Action<HttpServerTransportOptions> configureTransport)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolStatefulFallbackTests), Version = "1" };
})
// Stateless = false is a deliberate opt-in to sessions. Starting with the 2026-07-28 protocol revision,
// Streamable HTTP can never be served statefully, so the server refuses the probe and the client downgrades.
.WithHttpTransport(options => options.Stateless = false)
.WithHttpTransport(configureTransport)
.WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]);
_app = Builder.Build();
@ -88,6 +93,19 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
{
await StartStatefulServerAsync();
await AssertDefaultClientDowngradesAndToolsWorkAsync();
}
[Fact]
public async Task DefaultClient_AgainstStatefulSessionMode_DowngradesToInitialize_AndToolsWork()
{
await StartStatefulSessionModeServerAsync();
await AssertDefaultClientDowngradesAndToolsWorkAsync();
}
private async Task AssertDefaultClientDowngradesAndToolsWorkAsync()
{
await using var client = await ConnectDefaultClientAsync();
// The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize.

View file

@ -11,7 +11,7 @@ namespace ModelContextProtocol.AspNetCore.Tests;
public abstract partial class MapMcpTests
{
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567):
// the handler refuses a request when the server opted into sessions (Stateless = false), so a client pinned
// the handler refuses a request when the server opted into sessions (SessionMode = HttpServerSessionMode.Stateful), so a client pinned
// to that revision downgrades to legacy instead of negotiating 2026-07-28. These MRTR tests therefore can't
// run on the stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures.
private const string July2026StatefulStreamableHttpSkipReason =

View file

@ -293,7 +293,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
Name = "backcompat-roots-tool",
Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list",
}),
]).WithHttpTransport(options => options.Stateless = false);
]).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful);
_app = Builder.Build();
_app.MapMcp();

View file

@ -441,7 +441,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
request.Headers.Accept.Add(new("text/event-stream"));
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
// Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE
// SessionMode = HttpServerSessionMode.Stateless doesn't map the GET endpoint - per SEP-2567 the standalone SSE
// stream is replaced by subscriptions/listen POST requests. Existing routing in
// McpEndpointRouteBuilderExtensions only maps GET when Stateless == false.
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);

View file

@ -35,10 +35,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
{
options.ServerInfo = new Implementation { Name = nameof(RequestAbortCancellationTests), Version = "1" };
})
.WithHttpTransport(options =>
{
options.Stateless = stateless;
})
.WithHttpTransport(options => options.Stateless = stateless)
.WithTools([McpServerTool.Create(
async (CancellationToken cancellationToken) =>
{
@ -86,8 +83,8 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
public async Task July2026Request_AbortFlowsCancellationToToolHandler()
{
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is
// served natively only on a stateless server; a Stateless=false server refuses these requests so dual-era
// clients fall back to initialize.
// served natively only on a stateless server; a stateful server refuses these requests to allow
// a client to fall back to `initialize` if it supports it.
await StartAsync(stateless: true);
using var request = CreateBlockingToolRequest(july2026Protocol: true);

View file

@ -490,8 +490,8 @@ public abstract class ResumabilityIntegrationTestsBase(ITestOutputHelper testOut
var serverBuilder = Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
// Resumability is a stateful concern; pin Stateless = false now that the new default is true.
options.Stateless = false;
// Resumability is a stateful concern; pin SessionMode = HttpServerSessionMode.Stateful since the session is required.
options.SessionMode = HttpServerSessionMode.Stateful;
options.EventStreamStore = eventStreamStore;
configureTransport?.Invoke(options);
})

View file

@ -222,7 +222,7 @@ public class SessionMigrationTests(ITestOutputHelper outputHelper) : KestrelInMe
Name = "SessionMigrationTestServer",
Version = "1.0.0",
};
}).WithTools(Tools).WithHttpTransport(options => options.Stateless = false);
}).WithTools(Tools).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful);
if (migrationHandler is not null)
{

View file

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Connections;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Serilog;
@ -378,7 +379,7 @@ public class Program
serviceCollection.AddSingleton(app.ApplicationServices.GetRequiredService<DiagnosticListener>());
serviceCollection.AddRoutingCore();
serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.Stateless = true);
serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless);
var appBuilder = new ApplicationBuilder(serviceCollection.BuildServiceProvider());
appBuilder.UseRouting();
@ -428,8 +429,8 @@ public class Program
.WithHttpTransport(options =>
{
// The test fixture exercises legacy stateful behaviors (SSE + session-id flows).
// Set Stateless = false explicitly now that the 2026-07-28 protocol (SEP-2567) defaults to true.
options.Stateless = false;
// Set SessionMode = HttpServerSessionMode.Stateful explicitly since sessions are required.
options.SessionMode = HttpServerSessionMode.Stateful;
options.EnableLegacySse = true;
});