Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}