Model Context Protocol (MCP) over HTTP has traditionally involved session state. A client could establish a session with a server and subsequent requests would remain associated with that session. That model works, but it introduces an operational constraint when an MCP server is deployed across multiple instances: the load balancer needs session affinity, or the application needs a shared mechanism for maintaining session state.
MCP's 2026-07-28 protocol revision changes that architecture. The MCP C# SDK 2.0 makes HTTP transport stateless by default. Requests no longer depend on an Mcp-Session-Id, which allows ordinary HTTP infrastructure to distribute requests across server instances without protocol-level session affinity. Microsoft specifically highlights horizontal scaling, serverless deployments, and multi-instance environments as benefits of this model.
That raises an important engineering question:
How much does stateless MCP actually change the behavior of a .NET application under load?
Rather than assuming that statelessness is automatically faster, this article presents a reproducible benchmarking approach for measuring throughput, latency, resource consumption, and scaling behavior behind a load balancer.
What Are We Actually Benchmarking?
The goal is not simply to compare two HTTP endpoints.
A useful benchmark should measure the behavior of an MCP server under conditions that resemble its production deployment.
The experiment should compare at least these dimensions:
| Dimension | Stateless MCP | Stateful MCP |
|---|
| Transport session | None | Maintained |
| Session affinity | Not required | Usually required |
| Horizontal scaling | Any instance can handle requests | Requests may need the same instance |
| Shared session store | Not required at protocol layer | May be required |
| Request routing | Ordinary HTTP load balancing | Session-aware routing |
| Server memory | No transport session per client | Session state consumes resources |
| Failure handling | Request can reach another instance | Session may need recovery |
| Deployment complexity | Lower for stateless workloads | Higher |
| Server-to-client unsolicited messages | Not supported in stateless mode | Supported |
| Best fit | Scalable remote MCP services | Features requiring transport sessions |
The SDK documentation explicitly describes stateless Streamable HTTP as suitable for production deployments and horizontal scaling without session affinity. Stateful mode remains appropriate when the application requires session-specific transport behavior.
Build a Minimal Stateless MCP Server
Start with a small ASP.NET Core application.
Install the HTTP integration:
dotnet add package ModelContextProtocol
dotnet add package ModelContextProtocol.AspNetCore
Configure the MCP server:
using ModelContextProtocol.Server;
using System.ComponentModel;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = true;
})
.WithToolsFromAssembly();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run();
In SDK 2.0, Stateless is already true by default, so the explicit setting above makes the benchmark's intent obvious. A stateless server does not create transport sessions or assign Mcp-Session-Id.
Now add a deliberately simple tool:
[McpServerToolType]
public static class BenchmarkTools
{
[McpServerTool]
[Description("Returns a simple response for load testing.")]
public static string Echo(string message)
{
return $"Processed: {message}";
}
}
This intentionally contains almost no business logic.
That is important because the first benchmark should measure MCP and HTTP infrastructure rather than database latency, external API latency, or CPU-heavy application logic.
Add an Identifiable Server Instance
When testing a load-balanced deployment, you need to determine whether requests are actually reaching different instances.
One simple approach is to expose an instance identifier.
[McpServerToolType]
public static class DiagnosticsTools
{
private static readonly string InstanceId =
$"{Environment.MachineName}-{Guid.NewGuid():N}";
[McpServerTool]
[Description("Returns the server instance handling the request.")]
public static string GetInstance()
{
return InstanceId;
}
}
This is useful during testing because repeated calls can show whether the load balancer is distributing requests across multiple instances.
For production observability, a stable deployment or container identifier is preferable to a randomly generated process identifier.
Deploy Multiple MCP Instances
A realistic benchmark should contain more than one application instance.
For example:
Load Generator
|
v
+-------------+
| Load Balancer|
+-------------+
/ | \
/ | \
v v v
MCP-01 MCP-02 MCP-03
\ | /
\ | /
+----+----+
|
Backend APIs
The important characteristic is that the load balancer should not use MCP session affinity for the stateless test.
A request can therefore follow this path:
Request 1 -> MCP-01
Request 2 -> MCP-03
Request 3 -> MCP-02
Request 4 -> MCP-01
That is the behavior the benchmark should verify.
Use a Real Load Balancer
For a local reproducible experiment, NGINX is sufficient.
A simplified configuration might look like:
events {}
http {
upstream mcp_backend {
server mcp01:8080;
server mcp02:8080;
server mcp03:8080;
}
server {
listen 80;
location /mcp {
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
There is deliberately no sticky-session configuration.
The objective is to test whether ordinary HTTP routing is sufficient for the stateless MCP deployment.
Design the Benchmark Workload
A benchmark is only useful if the workload is defined before measurements are collected.
At minimum, define:
Number of MCP server instances.
CPU and memory limits.
.NET runtime version.
MCP SDK version.
Load generator location.
Request rate or concurrency.
Request payload size.
Tool execution time.
Duration of the test.
Warm-up period.
Metrics collected.
Failure threshold.
For example:
Scenario: Stateless MCP
Instances: 1, 2, 3, 5
Concurrency: 10, 50, 100, 250
Duration: 5 minutes per scenario
Warm-up: 60 seconds
Operation: tools/call
Payload: small JSON request
Backend dependency: none
These values are examples of a test plan, not measured performance results.
The same workload should be executed against every configuration.
Measure the Right Metrics
Latency alone is not enough.
Throughput
Measure completed requests per second.
A stateless architecture should be evaluated for how effectively additional instances increase total capacity.
Latency
Capture:
Average latency
Median latency
p95 latency
p99 latency
Maximum latency
The median tells you what a typical request experiences. The p95 and p99 values are often more useful for production capacity planning because they expose tail latency.
Error Rate
Record:
HTTP errors
MCP protocol errors
Timeouts
Connection failures
Tool execution failures
Load-balancer failures
A configuration that produces higher throughput but significantly higher error rates is not an improvement.
CPU Utilization
Measure CPU usage independently for:
Load balancer
MCP instances
Backend dependencies
This helps determine whether the application or infrastructure is the bottleneck.
Memory
Record:
Working set
Managed heap
Allocation rate
GC activity
The important comparison is how memory behaves as concurrent clients increase.
Instance Distribution
Record how many requests each MCP instance receives.
For example:
MCP-01: 33.1%
MCP-02: 33.7%
MCP-03: 33.2%
Those numbers are only an example of how results might be reported. They should not be presented as actual benchmark results unless they came from a real test run.
Create a Load Test
A tool such as k6 can generate HTTP traffic against the load balancer.
A simplified test could look like this:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
constant_load: {
executor: 'constant-vus',
vus: 50,
duration: '5m'
}
}
};
export default function () {
const payload = JSON.stringify({
jsonrpc: "2.0",
id: Date.now(),
method: "tools/call",
params: {
name: "echo",
arguments: {
message: "benchmark"
}
}
});
const response = http.post(
'http://localhost/mcp',
payload,
{
headers: {
'Content-Type': 'application/json'
}
}
);
check(response, {
'request succeeded': r =>
r.status >= 200 && r.status < 300
});
}
The exact request format should be aligned with the MCP protocol version and client behavior used in the test environment. The benchmark should ideally use an actual MCP client for protocol-level validation and use a raw HTTP load generator as a complementary stress test.
Benchmark One Instance First
Do not immediately benchmark five instances.
Start with one server:
Load Generator
|
v
MCP-01
This establishes the baseline.
Record:
Throughput
p50
p95
p99
CPU
Memory
Errors
Then repeat with two instances:
+-- MCP-01
Load Balancer|
+-- MCP-02
Continue with three and five instances.
The important question is not:
"How fast is MCP?"
Instead ask:
"How does capacity change when I add MCP instances?"
Calculate Scaling Efficiency
Suppose a one-instance test produces a measured throughput of T1.
With N instances, calculate:
Scaling Efficiency =
Measured Throughput / (T1 × N) × 100
For example, if the one-instance baseline is 1,000 requests/second and a three-instance deployment achieves 2,700 requests/second:
Scaling Efficiency =
2700 / (1000 × 3) × 100
= 90%
This example demonstrates the calculation only. It is not a benchmark result.
The gap between theoretical and measured scaling can come from:
Load-balancer overhead
CPU saturation
Network limits
Connection limits
Garbage collection
Logging
Backend dependencies
Load-generator capacity
Test Statelessness Across Instances
Performance is only one part of the experiment.
The benchmark should also prove that the server does not accidentally depend on transport session state.
The diagnostic tool introduced earlier can help:
var instance = await client.CallToolAsync(
"get_instance",
new Dictionary<string, object?>());
Console.WriteLine(instance);
Repeated calls through the load balancer should be capable of reaching different instances.
The important validation is that a request does not fail merely because the next request reaches another server.
This is the architectural property that differentiates the stateless deployment.
Benchmark Stateful Mode as a Control
A useful experiment also runs a stateful configuration.
builder.Services
.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = false;
})
.WithToolsFromAssembly();
This should not be treated as a simple "old versus new" speed contest.
The purpose is to measure the operational consequences of maintaining transport sessions.
Compare:
| Measurement | Stateless | Stateful |
|---|
| Throughput | Measure | Measure |
| p95 latency | Measure | Measure |
| p99 latency | Measure | Measure |
| Memory/client | Measure | Measure |
| CPU | Measure | Measure |
| Errors | Measure | Measure |
| Load distribution | Measure | Measure |
| Session affinity | No | Required where sessions are used |
| Cross-instance requests | Supported | Session-aware |
| Failure recovery | Request-oriented | Session-dependent |
The table should be populated with measurements from your environment rather than generic numbers.
Test Instance Failure
A production benchmark should include failure injection.
Start three MCP instances:
MCP-01
MCP-02
MCP-03
Generate continuous traffic and then stop one instance.
Measure:
Error rate during failure
Recovery time
p95 latency during recovery
Request distribution after recovery
Whether traffic returns to the failed instance after it becomes healthy
With a stateless architecture, the protocol does not require subsequent requests to return to a specific server instance. The MCP C# SDK documentation explicitly identifies this as an advantage for horizontal scaling.
However, this does not mean every application automatically becomes failure-proof.
If your tool stores application state only in process memory, losing that instance can still lose that application state.
Stateless Does Not Mean No State
This distinction is critical.
Consider a shopping workflow.
A stateful implementation might rely on:
MCP Session
|
+-- Current Cart
+-- User Context
+-- Workflow State
A stateless implementation can move that state into an explicit application-level identifier:
Tool Call
|
+-- cartId = "abc123"
The server can then retrieve the state from a shared database or distributed cache.
For example:
[McpServerTool]
public async Task<string> GetCart(
string cartId,
CancellationToken cancellationToken)
{
var cart = await cartRepository.GetAsync(
cartId,
cancellationToken);
return cart is null
? "Cart not found."
: cart.ToString();
}
The transport remains stateless while the application can still maintain durable state.
Microsoft specifically recommends this model when application state needs to survive across tool calls: use explicit handles such as a basketId or browserId rather than hiding application state inside transport sessions.
Common Benchmarking Mistakes
Measuring Only Average Latency
Average latency can hide severe tail latency.
Always capture p95 and p99.
Testing Without a Warm-Up
The first requests can include:
Application startup
JIT compilation
Connection establishment
Cache initialization
Use a warm-up phase before recording the actual benchmark.
Running the Load Generator on the Same Machine
This can make the load generator compete with the server for CPU, memory, and network resources.
For meaningful capacity testing, isolate the load generator when possible.
Benchmarking a Fake Workload
A zero-cost tool is useful for measuring protocol overhead, but it does not represent a real application.
After the baseline test, create additional scenarios involving realistic workloads such as database queries or downstream HTTP calls.
Ignoring the Load Balancer
Testing the application directly does not validate the architecture.
The actual production path should be tested:
Client
↓
Load Balancer
↓
MCP Instances
↓
Dependencies
Treating Stateless as Automatically Faster
Statelessness primarily changes the state and scaling model.
It does not guarantee lower CPU time or lower per-request latency for every workload.
The benchmark should establish the actual effect.
Recommended Benchmark Matrix
A practical experiment can use this matrix:
| Scenario | Instances | Concurrency | State | Failure Injection |
|---|
| Baseline | 1 | Low | Stateless | No |
| Scale-out | 2 | Medium | Stateless | No |
| Scale-out | 3 | Medium | Stateless | No |
| Scale-out | 5 | High | Stateless | No |
| Stateful control | 3 | Medium | Stateful | No |
| Failure test | 3 | Medium | Stateless | Stop 1 instance |
| Recovery test | 3 | Medium | Stateless | Restart instance |
| Real workload | 3 | Production-like | Stateless | Optional |
This produces much more useful engineering information than running one benchmark command and reporting a single requests-per-second number.
Production Best Practices
Keep MCP Instances Disposable
A stateless MCP server should ideally be safe to terminate and recreate.
Avoid storing important application state exclusively in process memory.
Put Durable State Behind the Service
Use an appropriate persistent or distributed system for application state when required.
Examples include:
Relational databases
Distributed caches
Object storage
External workflow stores
Monitor Tail Latency
Use p95 and p99 dashboards rather than relying exclusively on averages.
Correlate Requests
Stateless transport does not eliminate observability requirements.
Use distributed tracing and request correlation so that a tool invocation can be followed across:
Client
↓
Load Balancer
↓
MCP Server
↓
Database/API
Avoid Unnecessary Stateful Transport
The SDK supports stateful HTTP when an application needs session-specific behavior, but stateless mode is the default and is designed specifically for horizontally scalable HTTP deployments.
Test the Real Deployment Topology
If production uses Kubernetes, a cloud load balancer, an API gateway, or a WAF, benchmark through that infrastructure.
A localhost benchmark cannot reproduce all of those variables.
Troubleshooting
Requests Keep Going to One Instance
Check the load-balancer configuration.
Remove session affinity for the stateless experiment and verify that the upstream pool contains all instances.
Stateless Requests Fail After Scale-Out
Look for application state stored in:
static
or:
IMemoryCache
or another process-local store.
Those mechanisms can work for local caching, but they should not be treated as durable shared state across instances.
Memory Continues Increasing
Check whether the application is retaining:
Tool results
Request objects
Large payloads
Logging buffers
Application-level caches
Unbounded collections
Stateless transport does not prevent application-level memory leaks.
p99 Latency Increases While Average Latency Looks Fine
Investigate:
Tail latency is often where capacity problems become visible first.
Frequently Asked Questions
Does stateless MCP eliminate the need for a load balancer?
No. It makes load balancing simpler because protocol-level session affinity is no longer required for stateless HTTP. You can still use a load balancer for distribution, TLS termination, health checks, and other infrastructure responsibilities.
Does stateless MCP mean the application cannot maintain state?
No. Application state can still exist in databases, distributed caches, or other durable systems. The key difference is that the MCP transport itself does not maintain a session between requests.
Can every MCP server use stateless mode?
No. Servers that require session-scoped transport state or unsolicited server-to-client messages may need stateful mode. The C# SDK documentation explicitly identifies these as reasons to use stateful HTTP.
Is stateless mode automatically faster?
Not necessarily. Its primary benefit is architectural: simpler horizontal scaling and reduced dependence on transport sessions. Actual latency and throughput must be measured for the workload.
Should I benchmark the MCP server directly or through the load balancer?
For a production scalability study, do both.
A direct-server test helps isolate application performance. A load-balanced test measures the behavior users will actually experience.
Conclusion
Stateless MCP changes the scalability model of HTTP-based MCP servers.
With the MCP C# SDK 2.0, stateless HTTP is the default. The server does not need to maintain a transport session for each client, and requests can be distributed across instances using ordinary HTTP infrastructure. This removes a major source of complexity for horizontally scaled deployments.
But the architectural benefit should not be confused with a guaranteed performance improvement.
A meaningful benchmark should measure throughput, p95 and p99 latency, CPU, memory, error rate, instance distribution, scale-out efficiency, and failure recovery. It should also distinguish transport state from application state.
The strongest production experiment is therefore not simply:
Stateless vs Stateful
It is:
1 instance
↓
2 instances
↓
3 instances
↓
5 instances
↓
Instance failure
↓
Recovery
↓
Production-like workload
Run the same workload through the same infrastructure, collect the same metrics, and publish the measured results.
That approach turns MCP statelessness from an architectural claim into something an engineering team can actually validate before adopting it in production.