In the previous post, I mentioned in one line that the HTTP transport is stateless by default since SDK 2.0. That line deserves a whole post, because it changes how you should think about any MCP server that does more than one thing per call.
What changed
Before the 2026-07-28 revision of the MCP spec, a client talking to an HTTP server first did a handshake and got a session ID. Every later request carried that ID, and it was tied to the server instance that created it. Run three instances behind a load balancer, and you needed sticky routing so each client kept hitting the same one.
The new revision removed both the handshake and the session ID. Each request now carries everything the server needs to handle it. The C# SDK followed: since 2.0, WithHttpTransport() runs stateless unless you say otherwise.
In practice this means any instance can answer any request. Put your server behind a plain round-robin load balancer, scale it to five instances, restart one in the middle of the day. Nothing breaks, because nothing was kept in a session.
Stateless protocol, not stateless app
Here's the misunderstanding I see most: "stateless" sounds like your tools can't remember anything between calls. They can. They just can't hide it in the connection.
Take a support desk. A good flow for replying to a customer is: the agent drafts a reply, the user reads it, then it gets sent. That's two tool calls with state in between. In a stateless server, you make the state explicit. The first tool returns an ID, and the model passes that ID to the second:
[McpServerTool(Name = "draft_reply")]
[Description("""
Drafts a reply to a ticket without sending it. Returns a draftId.
Show the draft to the user. Call send_reply with the draftId only after they approve it.
""")]
public Task<ReplyDraft> DraftReply(
[Description("The ticket number in the format SD-1234.")] string ticketNumber,
[Description("The full reply text to send to the customer.")] string message)
=> drafts.CreateAsync(ticketNumber, message);
[McpServerTool(Name = "send_reply")]
[Description("Sends a reply that was created with draft_reply and approved by the user.")]
public async Task<string> SendReply(
[Description("The draftId returned by draft_reply.")] string draftId)
{
var draft = await drafts.GetAsync(draftId)
?? throw new McpException(
$"Draft {draftId} doesn't exist or has expired. Create a new one with draft_reply.");
await tickets.AddReplyAsync(draft.TicketNumber, draft.Message);
await drafts.DeleteAsync(draftId);
return $"Reply sent on {draft.TicketNumber}.";
}
public sealed record ReplyDraft(
string DraftId, string TicketNumber, string Message, DateTimeOffset ExpiresAt);The model is good at this. It carries IDs from one call to the next all the time, and because the ID is a normal parameter, it's visible in the conversation and in your logs. Hidden session state was never visible anywhere.
A few rules make handles safe:
- Store them where every instance can see them. A database table or Redis, not memory. More on that below.
- Make them unguessable. A GUID, not an incrementing number.
- Give them an expiry. Drafts nobody sends shouldn't live forever.
- Tie them to the caller once you have authentication. One user shouldn't be able to send another user's draft by guessing an ID.
The static field trap
This one has bitten people who were sure their server was stateless:
// Works on your machine. Breaks in production.
private static readonly Dictionary<string, ReplyDraft> Drafts = new();On your laptop there's one process, so it works. In production with two instances, draft_reply lands on instance A, send_reply lands on instance B, and B has never heard of that draft. The user sees "draft doesn't exist" at random. Worse, a static dictionary is shared across every user of that instance.
With a stateless transport, assume the next request goes to a different machine. Anything that must survive between calls goes into shared storage.
When you still want sessions
Stateless is the default, not a rule. You can switch sessions back on:
builder.Services
.AddMcpServer()
.WithHttpTransport(options => options.Stateless = false)
.WithTools<TicketReadTools>();There are real reasons to do it. Your server may need to push messages to the client without being asked. Or you may have to support older clients that rely on session-based features. But know what you're paying: sticky routing, sessions lost when an instance restarts, and harder scaling. The SDK also marks several stateful-only options as obsolete now, so expect warnings telling you you're on the old path.
What about asking the user mid-call?
The obvious worry: confirmations. In the old model, a tool could pause, ask the user "are you sure?" over the session and continue. Without a session, how does that work?
The new spec answers with Multi Round-Trip Requests. Instead of reaching back over a session, the tool returns a result saying "I need input first", and the client calls the tool again with the answer. No session needed. It deserves its own post, and we'll use it later in this series when we build write tools that ask before they act. For now, the important part: going stateless doesn't mean giving up confirmations.
One caveat from the SDK 2.0 announcement worth knowing: older clients that don't speak the new protocol and aren't on a session can't do this round trip. That's why the draft/send pattern above is still useful. It works with every client, because it's just two ordinary tool calls.
The short version
Since SDK 2.0, every HTTP request to your MCP server should be treated as if it might land on a different machine than the last one. Keep state in explicit IDs and shared storage, not in sessions or static fields. Turn sessions on only when you have a specific reason, and when a tool can't find its state, say so clearly, as we did in the ninth post.
Want the full build? Build MCP Servers in C#: Connect AI to Your .NET Apps shows you how to give AI access to your own .NET apps with an MCP server. Or get the weekly tips by email: /newsletter.



