diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index bb4e155f2..9b7991f5b 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -186,7 +186,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `SessionMode = HttpServerSessionMode.Stateful` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `SessionMode = HttpServerSessionMode.Stateful` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation; sessions live in memory, so stateful mode also requires session affinity (sticky sessions) once you run more than one instance behind a load balancer. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). #### Host name validation diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index f26e9ed5c..b030cf108 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -379,7 +379,18 @@ await WriteJsonRpcErrorAsync(context, // One of the few other usages I found was from some Ethereum JSON-RPC documentation and this // JSON-RPC library from Microsoft called StreamJsonRpc where it's called JsonRpcErrorCode.NoMarshaledObjectFound // https://learn.microsoft.com/dotnet/api/streamjsonrpc.protocol.jsonrpcerrorcode?view=streamjsonrpc-2.9#fields - await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001, requestId); + // + // Sessions live in memory, so this is also what a request looks like when it reaches the wrong + // instance of a multi-instance deployment. Say so: the bare "Session not found" gave operators + // nothing to act on and made a load-balancing misconfiguration look like a client bug. + // See https://github.com/modelcontextprotocol/csharp-sdk/issues/1861. + await WriteJsonRpcErrorAsync(context, + "Session not found: The server has no session for this Mcp-Session-Id. Sessions are held in memory by the instance " + + "that created them, so a request for an existing session must reach that same instance; configure session affinity " + + "(sticky sessions) when running more than one instance behind a load balancer. If your server doesn't need sessions, " + + "enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless. " + + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", + StatusCodes.Status404NotFound, -32001, requestId); return null; } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 0a0a6bbed..7a3b3a9f4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -982,4 +982,49 @@ public async Task DeleteRequest_FromDifferentUser_IsRejected_AndSessionSurvives( using var aliceDeleteResponse = await HttpClient.SendAsync(aliceDelete, TestContext.Current.CancellationToken); Assert.True(aliceDeleteResponse.IsSuccessStatusCode); } + + // A stateful server keeps sessions in memory, so a request for a session this instance does not have + // fails with 404. That is the normal outcome of running several instances without session affinity, or + // of losing sessions to a restart, and the error has to say so: "Session not found" on its own is + // indistinguishable from a client-side bug. + // See https://github.com/modelcontextprotocol/csharp-sdk/issues/1861. + [Fact] + public async Task Stateful_UnknownSessionId_Returns404WithSessionAffinityGuidance() + { + Assert.SkipWhen(Stateless, "Sessions don't exist in stateless mode."); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // The session was created by a different instance, which is what a load balancer without sticky + // sessions produces: the client still holds a valid-looking Mcp-Session-Id, this instance does not. + const string listToolsRequest = """ + {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}} + """; + using var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/") + { + Content = new StringContent(listToolsRequest, System.Text.Encoding.UTF8, "application/json"), + }; + request.Headers.Add("Mcp-Session-Id", "session-created-by-another-instance"); + request.Headers.Add("MCP-Protocol-Version", "2025-11-25"); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + using var document = System.Text.Json.JsonDocument.Parse(body); + var error = document.RootElement.GetProperty("error"); + Assert.Equal(-32001, error.GetProperty("code").GetInt32()); + + var message = error.GetProperty("message").GetString(); + Assert.NotNull(message); + Assert.StartsWith("Session not found", message); + Assert.Contains("session affinity", message); + Assert.Contains("HttpServerSessionMode.Stateless", message); + } }