Add stateless doc, prefer stateless mode and disable legacy SSE by default (#1468)

This commit is contained in:
Stephen Halter 2026-03-27 13:56:16 -07:00 • committed by GitHub
commit 8eb4023c20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -7,6 +7,7 @@ using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Collections.Concurrent;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
@ -96,6 +97,42 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Assert.Equal("AutoDetectTestServer", mcpClient.ServerInfo.Name);
}
[Fact]
public async Task SseEndpoints_AreDisabledByDefault_InStatefulMode()
{
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
// Stateful mode, but SSE not explicitly enabled.
options.Stateless = false;
});
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
using var sseResponse = await HttpClient.GetAsync("/sse", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, sseResponse.StatusCode);
using var messageResponse = await HttpClient.PostAsync("/message", new StringContent(""), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, messageResponse.StatusCode);
}
[Fact]
public async Task SseEndpoints_ThrowOnMapMcp_InStatelessMode_WithEnableLegacySse()
{
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.Stateless = true;
options.EnableLegacySse = true;
});
await using var app = Builder.Build();
var ex = Assert.Throws<InvalidOperationException>(() => app.MapMcp());
Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("EnableLegacySse", ex.Message);
}
[Fact]
public async Task AutoDetectMode_Works_WithSseEndpoint()
{
@ -108,7 +145,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Name = "AutoDetectSseTestServer",
Version = "1.0.0",
};
}).WithHttpTransport(ConfigureStateless);
}).WithHttpTransport(options => { ConfigureStateless(options); options.EnableLegacySse = true; });
await using var app = Builder.Build();
app.MapMcp();
@ -136,7 +173,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
Name = "SseTestServer",
Version = "1.0.0",
};
}).WithHttpTransport(ConfigureStateless);
}).WithHttpTransport(options => { ConfigureStateless(options); options.EnableLegacySse = true; });
await using var app = Builder.Build();
app.MapMcp();
@ -551,4 +588,152 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
// Dispose should still not hang
await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
}
[Fact]
public async Task Client_CanReconnect_AfterSessionExpiry()
{
Assert.SkipWhen(Stateless, "Sessions don't exist in stateless mode.");
string? expiredSessionId = null;
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<ClaimsPrincipalTools>();
await using var app = Builder.Build();
// Middleware that returns 404 for the expired session, simulating server-side session expiry.
app.Use(next =>
{
return async context =>
{
if (expiredSessionId is not null &&
context.Request.Headers["Mcp-Session-Id"].ToString() == expiredSessionId)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next(context);
};
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
// Connect the first client and verify it works.
var client1 = await ConnectAsync();
var originalSessionId = client1.SessionId;
Assert.NotNull(originalSessionId);
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotEmpty(tools);
// Simulate session expiry by having the middleware reject the original session.
expiredSessionId = originalSessionId;
// The next request should fail.
await Assert.ThrowsAnyAsync<Exception>(async () =>
await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
// Completion should resolve with a 404 status code.
var details = await client1.Completion.WaitAsync(TestContext.Current.CancellationToken);
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
Assert.Equal(HttpStatusCode.NotFound, httpDetails.HttpStatusCode);
await client1.DisposeAsync();
// Reconnect with a brand-new session.
await using var client2 = await ConnectAsync();
Assert.NotNull(client2.SessionId);
Assert.NotEqual(originalSessionId, client2.SessionId);
// The new session works normally.
tools = await client2.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotEmpty(tools);
}
[Fact]
public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler()
{
var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>();
var capturedActivityTags = new ConcurrentBag<(string? TagValue, bool HadActivity, string Method)>();
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
await using var app = Builder.Build();
// This is the pattern documented in sessions.md — verify it actually works.
// Tag before next() so child spans inherit the value.
app.MapMcp().AddEndpointFilter(async (context, next) =>
{
var httpContext = context.HttpContext;
// Read from request headers — available on all non-initialize requests in stateful mode.
string? beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"];
// Tag before next() so child activities created during the handler inherit it.
var activity = System.Diagnostics.Activity.Current;
if (beforeSessionId != null)
{
activity?.AddTag("mcp.transport.session.id", beforeSessionId);
}
var tagValue = activity?.GetTagItem("mcp.transport.session.id")?.ToString();
var result = await next(context);
// After the handler, check response headers too (for test validation only).
string? afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"];
capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method));
capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method));
return result;
});
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectAsync();
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// The filter must have observed at least one MCP request. Don't assert an exact
// minimum — the initialized notification or GET stream may not have completed yet.
Assert.NotEmpty(capturedSessionIds);
if (Stateless)
{
// Stateless mode: no session IDs anywhere.
Assert.All(capturedSessionIds, c =>
{
Assert.Null(c.BeforeNext);
Assert.Null(c.AfterNext);
});
// Activity should exist but no transport session tag in stateless mode.
Assert.All(capturedActivityTags, c => Assert.Null(c.TagValue));
}
else
{
// Stateful mode: response header is set on every POST and GET response.
var postCaptures = capturedSessionIds.Where(c => c.Method is "POST").ToList();
Assert.NotEmpty(postCaptures);
Assert.All(postCaptures, c =>
{
Assert.Equal(client.SessionId, c.AfterNext);
});
// At least one POST should have the session ID in the request header too
// (the initialized notification or list_tools — but not the initial initialize request).
Assert.Contains(postCaptures, c => c.BeforeNext == client.SessionId);
// Verify Activity.Current was available and the AddTag pattern works before next().
// The tag is only set on non-initialize requests (where the request header has the session ID).
var taggedPosts = capturedActivityTags.Where(c => c.Method is "POST" && c.TagValue is not null).ToList();
Assert.NotEmpty(taggedPosts);
Assert.All(taggedPosts, c =>
{
Assert.True(c.HadActivity, "Activity.Current should be non-null in the endpoint filter");
Assert.Equal(client.SessionId, c.TagValue);
});
}
}
}