The Stateless MCP · Part 3
The same MCP server in C#, and the three places it differs
The same booking server on .NET 10, where one Stateless boolean decides whether you speak 2026-07-28 at all, requestState arrives unchecked, and the second leg is code you write yourself.
Marcin
Java since 2007, .NET since 2010 · Azure · lately deep in LLMs
The server that works and speaks the wrong protocol
You port the meeting room server to C#. It compiles on the first try. You run it, a client connects, it lists two tools, it calls them, and the answers are right. Every test you would think to write passes.
Then you print client.NegotiatedProtocolVersion and it says 2025-11-25. It has said that
the whole time.
Nothing failed, so nothing told you. One boolean in the ASP.NET setup decided this, and somebody set it deliberately, to keep older clients working. In C# the transport configuration decides which protocol revision you are allowed to speak.
Before you read on, guess which line you would go looking at first. What makes it hard is that the line you want didn't fail, and whoever wrote it was not being careless.
The smallest server that works
Python was one package. C# is five, and three of them matter here:
ModelContextProtocol.Core, the client and the low-level server, with the fewest dependencies.ModelContextProtocol, which adds hosting, dependency injection and attribute discovery.ModelContextProtocol.AspNetCore, which adds the HTTP transport and references the other two.
Install the last one and you have all three:
dotnet add package ModelContextProtocol.AspNetCore --version 2.0.0
Everything here was checked against ModelContextProtocol.AspNetCore 2.0.0 on net10.0,
with the .NET SDK pinned to 10.0.302 by a global.json, on 2026-07-29. The pin is not
decoration: this machine has four SDKs installed and a bare dotnet picked an 11.0 preview,
which is not what you want under a server you are about to reason about.
One thing about the first person in what follows. I built this server for this article and ran every line of it that day, so when I say something surprised me, it surprised me here, at my desk, and not in a system booking anyone's real meetings.
The finished server is on GitHub at mapell-dev/mcp-2026-07-28-room-desk, next to the Python version from Part 2. Read the repository's warnings before you borrow anything from it, because some of what follows is broken on purpose.
The setup fits in a short Program.cs:
using RoomDesk;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<RoomTools>();
var app = builder.Build();
app.MapMcp();
app.Run("http://127.0.0.1:3002");
And the tool is a method with attributes where Python had decorators and a docstring:
using System.ComponentModel;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace RoomDesk;
[McpServerToolType]
public sealed class RoomTools
{
private static readonly Dictionary<string, (int Seats, int Floor)> Rooms = new()
{
["aurora"] = (4, 2),
["basalt"] = (8, 2),
["cinder"] = (8, 5),
};
private static readonly HashSet<(string Room, string Date)> Booked = new()
{
("basalt", "2026-08-03"),
};
private static List<string> FreeRooms(string date, int attendees) =>
Rooms.Where(r => r.Value.Seats >= attendees && !Booked.Contains((r.Key, date)))
.Select(r => r.Key)
.Order()
.ToList();
[McpServerTool, Description("List the meeting rooms free on a date that seat this many people.")]
public static string CheckAvailability(
[Description("The date, as YYYY-MM-DD")] string date,
[Description("How many people need a seat")] int attendees)
{
var free = FreeRooms(date, attendees);
return free.Count == 0
? $"No room on {date} seats {attendees}."
: $"Free on {date} for {attendees}: {string.Join(", ", free)}.";
}
}
Three things surprised me here.
WithTools<T> refuses a static class. You get error CS0718: 'RoomTools': static types cannot be used as type arguments, which is accurate and says nothing about how to fix it.
Make the class non-static. The methods can stay static.
McpServer and RequestContext<> parameters are dropped from the advertised schema rather
than rejected, so a tool can take them and the model never sees them. Convenient, and worth
knowing before you wonder where a parameter went.
And the third one is a prediction. Before you run this, what is your tool called on the wire? Write it down.
Look at what it's called
book_room | Book a meeting room, asking which one when more than one fits.
check_availability | List the meeting rooms free on a date that seat this many people.
{"type": "object",
"properties": {"date": {"description": "The date, as YYYY-MM-DD", "type": "string"},
"attendees": {"description": "How many people need a seat", "type": "integer"}},
"required": ["date", "attendees"]}
CheckAvailability is check_availability. The SDK renames every tool to snake_case, and
if you call it by the name you wrote in C# you get this:
{"code": -32602, "message": "Unknown tool: 'CheckAvailability'"}
That listing has book_room in it because the finished file does, and we get to it two
sections down. Note that it comes back first, which is neither source order nor registration
order, so do not build anything that depends on tool ordering. The schema shown is
check_availability's, and the [Description] attributes became its descriptions, which is
the same trade Python made with docstrings and type hints.
The one boolean
Back to the hook. The line is this one:
.WithHttpTransport(options => options.Stateless = true)
The name suggests a scaling preference, something you turn on when you want to run more than
one replica. On HTTP it is the precondition for speaking 2026-07-28 at all.
The new revision removed Mcp-Session-Id and the initialize handshake, so Streamable HTTP
can only carry it through the stateless path.
Set it to false and pin a client to the new revision, and the server tells you exactly
what it thinks:
{"code": -32022,
"message": "Bad Request: Starting with protocol version '2026-07-28', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). Use the initialize handshake with a protocol version that still supports sessions instead.",
"data": {"supported": ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"],
"requested": "2026-07-28"}}
That message names the property. One line fixes it.
Here's the part that answers the hook, and it's worse than an error. Look at
data.supported. It runs from 2024-11-05 to 2025-11-25 and 2026-07-28 is not in it. A
stateful server does not advertise the new revision at all.
So a client doing ordinary version negotiation takes the newest thing on offer, settles on
2025-11-25, and reports nothing, because from its point of view nothing went wrong. You
only ever see that -32022 if a client pins the new revision, and pinning is not the
default: the Python client negotiates unless you tell it not to.
That's why the guess is hard. You go looking for the line that broke, and there isn't one.
Python has no equivalent switch, and its stateless_http flag controls something else
entirely. If you are moving between the two languages, this is the setting that does not
translate, and it is the first of three places where the two SDKs genuinely part company.
The boundary: stateful mode is not broken and it is not deprecated. It is how you serve older clients that need sessions over HTTP, and down-level callers, further down, are where that stops being a nuisance and starts being the reason you'd choose it. The mistake is not choosing it. The mistake is choosing it without knowing what it excludes.
The tool that has to ask
Same booking as Part 2. Six people need a room on the 4th, and two rooms fit: basalt sits on the second floor and cinder on the fifth. The team is hosting a visitor who is already up on the fifth floor, so cinder is the room they want. Nothing in the arguments says that, so a person has to choose, and the protocol gives the server no channel to ask on.
In Python you declared a resolver and the SDK built the exchange. In C# you throw. The next three listings are excerpts from a single method. The whole thing appears at the end of this section.
Before you read the code, decide one thing: what should this tool do when the caller is an old client with no way to answer a question mid-call? There are two obvious answers and only one of them leaves your tool usable.
if (!server.IsMrtrSupported)
{
return $"More than one room fits ({string.Join(", ", free)}). "
+ "Resend with the room you want, or connect with a client that negotiates 2026-07-28.";
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["room"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = $"Rooms free on {date}: {string.Join(", ", free)}. Which one?",
RequestedSchema = new()
{
Properties =
{
["room"] = new ElicitRequestParams.StringSchema
{
Title = "Room",
Description = "Which room to book",
},
},
},
}),
},
requestState: $"{date}:{attendees}");
Throwing an exception to produce a successful result reads wrong the first time you see it.
It is control flow, not failure: the SDK catches it and turns it into an
InputRequiredResult with resultType of "input_required". I think this is the weaker of
the two ergonomics, because an exception tells every reader of your code that something went
wrong and here nothing did. I also think it is defensible, because it is the only way to
abandon a half-finished method in C# without threading a result type through everything
above it.
Notice the guard above the throw. server.IsMrtrSupported tells you whether the caller can
handle a multi-round-trip at all, and a tool that throws without checking simply fails for
everyone who can't.
You cannot make an old client able to answer, so you give it something it can act on
instead. That instruction only works because book_room advertises a third argument, which exists
for no other reason:
[Description("Which room to book. Only needed by clients that cannot answer a question mid-call.")]
string? room = null
Part 2 argued against exactly this. Add a room parameter, it said, and the model will fill
it in on the turns where the user never said, and a wrong guess books a real room. That
argument was right, and this parameter weakens it. room is in the advertised schema now,
and nothing stops a model from supplying it.
I don't have a clean way out of that. Serving clients that cannot answer a question mid-call means handing back the guess the new protocol let you take away. That is a trade, and I would rather name it than pretend the parameter is harmless.
The boundary: that fallback is not politeness. Without the guard, the tool works with whatever client you happen to test against and fails for every caller that cannot do a multi-round-trip.
That exception is the second.
The second call, by hand
The first leg comes back as an event stream, which is the first place C# and Python differ
visibly at the transport level: Python answered curl with a plain JSON body, C# answers
text/event-stream. Strip the event: message and data: framing and this is the payload underneath, in full:
{"result": {
"inputRequests": {"room": {"method": "elicitation/create", "params": {
"mode": "form",
"message": "Rooms free on 2026-08-12: basalt, cinder. Which one?",
"requestedSchema": {"type": "object", "properties": {
"room": {"type": "string", "title": "Room", "description": "Which room to book"}}}}}},
"requestState": "2026-08-12:6",
"resultType": "input_required",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "RoomDesk", "version": "1.0.0.0"}}},
"id": 2, "jsonrpc": "2.0"}
Two differences from Part 2's capture are visible right there. The key is "room" because
you chose it, where Python derived __main__:pick_room from a resolver you never named. And
requestedSchema has no enum, because the tool declared a plain string. Hold on to that
second one.
Part 2's words for the two legs carry over here unchanged, the second call and the reissue. The second call is where C# stops writing code for you. This is the third excerpt from the same method:
if (context.Params?.InputResponses?.TryGetValue("room", out var response) is true)
{
var elicited = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
if (elicited?.IsAccepted is not true)
{
return "Nothing booked.";
}
var picked = elicited.Content?.TryGetValue("room", out var value) is true
? value.GetString()
: null;
picked ??= free[0];
Booked.Add((picked, date));
return $"Booked {picked} on {date} for {attendees}.";
}
The same method runs twice. On the first call InputResponses is absent and you throw. On
the second call it's present and you read the answer out of it. Both legs of the exchange
are one method with a branch at the top, and you own the branch.
That IsAccepted check is doing more than it looks like. A user who changes their mind
sends decline, and a client that gives up sends cancel, and both arrive here as an
accepted-flag that isn't set. Your tool body runs in either case and gets to decide what
happens, which is a real difference from Python, where the plain annotation style never
reaches the body at all. If your booking needs to release a hold or write an audit line
when somebody says no, this is where that happens.
Here is the whole method, with the three excerpts back in place and the paths they left out. This is the code behind every booking and every multi-round-trip result shown above:
[McpServerTool, Description("Book a meeting room, asking which one when more than one fits.")]
public static string BookRoom(
McpServer server,
RequestContext<CallToolRequestParams> context,
[Description("The date, as YYYY-MM-DD")] string date,
[Description("How many people need a seat")] int attendees,
[Description("Which room to book. Only needed by clients that cannot answer a question mid-call.")]
string? room = null)
{
var free = FreeRooms(date, attendees);
if (free.Count == 0)
{
return $"No room on {date} seats {attendees}.";
}
// The non-interactive path: a caller that cannot do a multi-round-trip names the room
// up front. Deliberately trusted without validation, exactly like the elicited answer
// below, because the tutorial's point is that neither one is checked for you.
if (room is not null)
{
Booked.Add((room, date));
return $"Booked {room} on {date} for {attendees}.";
}
// The second call: the client answered the question we asked below.
if (context.Params?.InputResponses?.TryGetValue("room", out var response) is true)
{
var elicited = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
if (elicited?.IsAccepted is not true)
{
return "Nothing booked.";
}
var picked = elicited.Content?.TryGetValue("room", out var value) is true
? value.GetString()
: null;
picked ??= free[0];
Booked.Add((picked, date));
return $"Booked {picked} on {date} for {attendees}.";
}
// Only one room fits, so there is nothing to ask about.
if (free.Count == 1)
{
Booked.Add((free[0], date));
return $"Booked {free[0]} on {date} for {attendees}.";
}
if (!server.IsMrtrSupported)
{
return $"More than one room fits ({string.Join(", ", free)}). "
+ "Resend with the room you want, or connect with a client that negotiates 2026-07-28.";
}
// The first leg: return the question instead of calling back to the client.
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["room"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = $"Rooms free on {date}: {string.Join(", ", free)}. Which one?",
RequestedSchema = new()
{
Properties =
{
["room"] = new ElicitRequestParams.StringSchema
{
Title = "Room",
Description = "Which room to book",
},
},
},
}),
},
requestState: $"{date}:{attendees}");
}
Two paths in there have not come up yet, and both are ordinary: no room fits, and exactly one does. Neither needs a question.
Before you read on, look at what that second call hands your code. All of it arrived from
the client: the accepted flag, the room value, and the requestState sitting beside them.
The next two sections are about what each one costs, and about the one this handler gets
away with ignoring.
requestState is a string you wrote and a stranger hands back
Look at what went out:
"requestState": "2026-08-12:6"
That is the literal string the handler wrote, the date and the party size, in the clear. Here is the same field from the Python server in Part 2:
"requestState": "v1.zwWV0vXkfXq-nH58sBsfHCgankq5cAx2ugbkXZluiZ2zsGteWjnUb7er..."
Python sealed it into an encrypted, authenticated token bound to a time window, to the originating request, and to the authenticated principal. C# sent what you gave it. The C# SDK's own documentation is clear that this is deliberate, and clear about whose problem it is: servers may encode request state in any format, and if it contains sensitive data, servers "should encrypt it to ensure confidentiality and integrity."
There is no built-in way to do that. The word "should" is carrying your security model.
Everything that comes back on the second call is input from a client you don't control.
The spec requires servers to integrity-protect this state wherever it can influence authorization, resource access, or business logic. Python discharges that for you. In C# it is a to-do item you have not written down yet.
I want to be precise about the reference server, because overstating this would be easy.
BookRoom writes requestState and never reads it back, so this example is not exploitable
through that field. It got lucky. The field exists to carry state across the two legs, and
the moment a handler uses it for what it is for, it is trusting a string that a client could
have edited on the way through.
How much does the SDK check? Nothing at all. I sent a second call with
"requestState": "total-garbage-not-even-close" and it booked the room. Then I sent one
with no requestState field at all, and it booked the room again.
Python rejects a token whose arguments changed, with -32602 "Invalid or expired requestState". C# has nothing to reject with.
The second result is the one that should worry you. A second call with no requestState means
a client can skip the first leg entirely and answer a question the server never asked. The
multi-round-trip is not a state machine the server enforces. It is a shape the client is
trusted to follow.
The boundary: this is not the C# SDK being careless, and I would not accept that reading. It is a deliberate choice to let servers put anything in that field, documented as such, and the flexibility is real. The cost of the flexibility is that the safe path is not the default, and defaults are what most code ships with.
This is the third, and the one most likely to hurt you, because the other two announce themselves and this one does not.
The answer is client input too
The requestState is the part this handler ignores. Everything else on that second call
goes straight into the booking, so here's the bill, in the order you're likely to meet it.
A malformed acceptance books a room nobody chose. Send an accept with no content, or
with the key spelled roomName instead of room, and picked comes back null. Then
picked ??= free[0] quietly substitutes the first free room, books it, and reports success.
No attacker is involved and no connection dropped. A client sent a slightly wrong shape and
somebody has a meeting in the wrong room.
A room that does not exist gets booked. Answer atlantis and C# books atlantis. The
same answer against the Python server is rejected before the tool body runs:
Resolver '__main__:pick_room' received an accepted elicitation whose content does not match
the requested schema
The cause is not the SDK, and this is where it would be easy to be unfair. Python's
parameter was typed Literal["aurora", "basalt", "cinder"], which became an enum in the
requested schema, and the SDK validated the answer against it. The C# tool declared a plain
StringSchema with no allowed values, so there was nothing to validate against. Declare the
values in C# and you get the same protection. The difference is what each server said it
would accept.
The consequence goes further than one bad booking. After booking atlantis, check_availability still
reports basalt and cinder free for that date, because atlantis is not in Rooms and the
availability logic cannot see it. The booking went somewhere no other part of the server can
observe.
And the one that catches both languages. Answer aurora, a real room with four seats,
for a party of six. Both servers book it. Python's enum let it through because
aurora is a valid room name; the rule it broke was capacity, and capacity was never in the
schema. Neither handler re-checked.
The honest version is simple. Schema validation covers exactly what you put in the schema and nothing else, and Part 2 got one layer of it free from a type hint and still shipped this bug.
The reissue is the same lesson with time added. Part 2 sent its second call again, the way a dropped connection would, and the Python server booked basalt, the room the user had rejected, because its resolver re-runs on every leg and saw that cinder was gone. I sent the same duplicate at the C# server. The second call booked cinder, correctly. Each reissue after it reported another successful cinder booking, because the handler honors the stored answer and never re-checks whether the room is still free.
Different bugs from the same protocol change. Python's ergonomics push you toward discarding the user's answer. C#'s push you toward trusting it too long. Only an idempotency key that the caller supplies fixes either one.
Down-level callers
IsMrtrSupported is true in two cases, and only one of them is the new protocol. It is true
when 2026-07-28 was negotiated, and it is also true when the session is stateful under
2025-11-25, where the SDK bridges your InputRequiredException to a legacy
elicitation/create, waits for the answer, and calls your handler again. That bridge is
capped at ten rounds.
So the boolean from earlier decides which of those two worlds your server lives in, and the same tool body works in both. That part C# and Python agree on completely.
What C# has that Python doesn't
ModelContextProtocol.Extensions.Tasks ships today. Python 2.0.0 removed its experimental
Tasks API and says plainly that it does not implement the official extension yet.
That matters more than a feature checkbox, because it changes what "supports 2026-07-28" means. Both SDKs support the revision. Only one of them ships the extension for work that outlives a single request, and if your tool takes ten minutes to run then the protocol version is not the first question you should be asking.
So the two Tier 1 SDKs are not mirrors of each other, and the differences do not all run in the same direction. C# hands you more responsibility on the second call and more capability on long work.
The two SDKs, side by side
| Python 2.0.0 | C# 2.0.0 | |
|---|---|---|
| How a tool asks | Annotated[T, Resolve(fn)] returning Elicit(...) | throw new InputRequiredException(...) |
| Who writes the server side of the second leg | the SDK | you |
requestState | sealed, bound to request and principal | whatever you wrote, unchecked |
Key in inputRequests | derived, __main__:pick_room | yours, "room" |
| Schema richness | Literal becomes an enum | what you declare, nothing more |
| Stateless setting | unrelated to protocol version | decides whether you speak 2026-07-28 |
| Tasks extension | not implemented | ships |
| On a reissue | books the room the user rejected | books the chosen room again |
What to do on Monday
- Log
NegotiatedProtocolVersionon every request today. If you run a C# MCP server on HTTP, that log tells you which revision your traffic negotiated, and it may not be the one you expect. - Set
Stateless = true, then run your existing integration tests before you touch anything else. - Protect
requestStatebefore it carries anything worth changing. Sign it, or put the real state server-side behind an opaque id. - Validate what arrives in
inputResponseswith the same rules you would apply to a direct argument, because that is exactly what it is. - Give every tool that can ask a question an idempotency key.
One worked example ran in two languages, and both servers finished with a bug I left in on purpose. That is the shape of this revision. The protocol handed back responsibilities it used to carry, and it handed them back in both languages.