Nobody tells you this when you start building MCP servers, but picking the wrong MCP transport is the kind of mistake that doesn’t hurt immediately. It hurts three months later when you’re trying to deploy to Azure and realise your architecture assumes a single process on a single machine. I’ve been there. This post is what I wish I’d read before that happened.
The MCP C# SDK gives you four transport options: stdio, Streamable HTTP, SSE, and in-memory. They all carry the same JSON-RPC 2.0 messages between your AI client and your server — the difference is in where your server lives, how connections are managed, and what happens under production load. I’ll walk through each one honestly, with working C# code and the stuff the official docs gloss over.
If you haven’t read my post on what MCP servers actually are, start there first — this one assumes you already know the basics.
What Is an MCP Transport, Exactly?
The simplest way I can put it: the MCP transport is the pipe that carries messages between the AI model and your server. The protocol itself — tool calls, capability manifests, responses — is identical regardless of which transport you use. What changes is the physical channel those messages travel through.
Think of it like this. The conversation between you and a colleague is the same whether you’re in the same room, on a video call, or texting. The transport just determines who can initiate, how fast messages arrive, and what happens when the connection drops.
The Four Transports at a Glance
Before I go into detail on each one, here’s the map. I’ll refer back to this table throughout.
| Transport | Direction | Sessions | Scales horizontally? | Best for |
|---|---|---|---|---|
| stdio | Bidirectional | Implicit — one per process | N/A | Local tools, Claude Desktop, IDE integrations |
| Streamable HTTP (stateless) | Request-response | None | ✅ No constraints | Production APIs, Azure Container Apps |
| Streamable HTTP (stateful) | Bidirectional | Mcp-Session-Id header |
⚠️ Needs sticky sessions | Long-running agents, server push notifications |
| SSE (legacy) | Server→client stream + POST | Query string session ID | ⚠️ Needs sticky sessions | Legacy client compatibility only |
| In-memory | Bidirectional | Implicit — one per pipe | N/A | Unit tests, same-process embedding |
1. stdio — Start Here, Always
stdio is the one that surprised me the most when I first encountered MCP. The client literally launches your server as a child process and the two talk through stdin and stdout. No HTTP, no ports, no certificates. Your server is just a program reading from one pipe and writing to another.
This is how Claude Desktop connects to local MCP servers. You add your server to claude_desktop_config.json, Claude spawns the process, and from that point on the two are talking JSON-RPC over standard I/O. It’s almost offensively simple for something that enables quite sophisticated agent behaviour.
What I like about it
- It just works, everywhere. No networking, no firewall rules, no TLS ceremony. If you can run the binary, you have a working MCP server. I’ve got stdio servers running in environments where I’m not allowed to open any network ports.
- Bidirectional by default. The server can push notifications back to the client at any time — stdin/stdout flow control gives you natural backpressure with zero configuration.
- Clean isolation. Each client connection gets its own process. If the server crashes, the client knows immediately and only that session is affected. Nothing shared, nothing leaked.
- Easiest to debug. You can literally run the server in a terminal and type JSON at it. I’ve done this more times than I’d like to admit.
What to watch out for
- Local only. The server has to live on the same machine as the client. This rules it out for any cloud or API-style deployment.
- One client per process. Not a problem for dev tools, but if you’re imagining dozens of agents sharing one server instance — that’s not stdio’s job.
- Secret leakage is a real risk. This one caught me off guard. By default, your child process inherits every environment variable from the parent — which means
GITHUB_TOKEN,OPENAI_API_KEY,AWS_SECRET_ACCESS_KEY, all of it flows straight to your server (or a third-party server you’re connecting to). The fix is one option, but you have to know to set it.
Server — the code is three lines
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithTools<MyTools>();
// stdout belongs to the protocol — logs must go to stderr
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
await builder.Build().RunAsync();
Client
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Command = "dotnet",
Arguments = ["run", "--project", "MyMcpServer"],
ShutdownTimeout = TimeSpan.FromSeconds(10)
});
await using var client = await McpClient.CreateAsync(transport);
The environment variable thing — actually fix this
Don’t skip this, especially if you’re connecting to any third-party MCP server. The safe pattern is to start from the SDK’s curated allowlist and add only what your specific server needs:
// GetDefaultEnvironmentVariables() gives you PATH, HOME, and standard
// system dirs — nothing sensitive
var env = StdioClientTransportOptions.GetDefaultEnvironmentVariables();
env["MY_SERVER_API_KEY"] = apiKey; // add only what's needed
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Command = "my-mcp-server",
InheritEnvironmentVariables = false, // <-- this is the important bit
EnvironmentVariables = env,
});
If you’re writing the server yourself and you know exactly what it needs, this feels like overkill. If you’re connecting to someone else’s server — treat it like any other third-party code and don’t hand it your credentials by default.
2. Streamable HTTP — What You Want in Production
Once your MCP server needs to live somewhere other than the developer’s laptop, Streamable HTTP is the answer. It’s the transport I reach for on every Azure deployment, and the one the SDK team clearly considers the future of the protocol.
Here’s how it works: the client sends an HTTP POST. The server holds that POST response body open as an SSE stream and writes the JSON-RPC response through it — along with any intermediate messages like progress updates. So you get the reliability and auth ecosystem of HTTP, with real-time streaming baked in. Clever.
It comes in two flavours and the choice matters a lot for your deployment architecture.
Stateless mode — what I use by default
Every request is independent. No session tracking. No in-memory state between calls. This is gloriously simple to operate — deploy three instances behind Azure Front Door and every request can land on any instance. No sticky session configuration, no session replication, no “which node has this user’s state” debugging at 2am.
The trade-off: the server can’t send unsolicited messages to the client. If your tools are pure request-response — client asks, server answers — stateless is perfect and I’d argue it’s the right default for most tool-use scenarios.
Stateful mode — when you need server push
If your server needs to push notifications mid-conversation — progress updates on a long-running job, real-time alerts, streaming intermediate results — you need stateful mode. The server issues an Mcp-Session-Id header after the first request and tracks per-session state in memory. Clients can also open long-lived GET requests to receive unsolicited notifications.
The cost is operational: you need session affinity at your load balancer. On Azure Container Apps this means pinning the session to a specific replica. Not complicated, but it’s a constraint you need to plan for.
Server code
dotnet add package ModelContextProtocol.AspNetCore
Stateless (the default I recommend):
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = true;
})
.WithTools<MyTools>();
var app = builder.Build();
app.MapMcp(); // registers POST /mcp and GET /mcp
app.Run();
Stateful (when you need server push):
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = false;
})
.WithTools<MyTools>();
Client
// AutoDetect tries Streamable HTTP first, falls back to SSE if needed
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri("https://my-mcp-server.example.com/mcp")
// TransportMode defaults to AutoDetect
});
await using var client = await McpClient.CreateAsync(transport);
Session resumption — more useful than it sounds
In stateful mode, if the connection drops mid-conversation, you don’t have to start from scratch. Store the session ID and server info after connecting, then resume:
// Save these after first connection
string savedSessionId = client.SessionId;
McpServerCapabilities savedCaps = client.ServerCapabilities;
McpImplementation savedServerInfo = client.ServerInfo;
// On reconnect
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri("https://my-mcp-server.example.com/mcp"),
KnownSessionId = savedSessionId
});
await using var client = await McpClient.ResumeSessionAsync(transport, new ResumeClientSessionOptions
{
ServerCapabilities = savedCaps,
ServerInfo = savedServerInfo
});
For long-running agent workflows — the kind where the model is orchestrating a multi-step task over several minutes — this is the difference between a graceful reconnect and a broken session that kills the job.
If you have browser clients (CORS)
Most MCP clients aren’t browsers, but if yours is, you’ll need CORS. One thing worth emphasising: CORS is not a substitute for host name validation — you need both. The CORS config tells browsers which origins are allowed; host name validation protects against DNS rebinding attacks from non-browser clients.
var allowedOrigins = builder.Configuration
.GetSection("Mcp:AllowedOrigins")
.Get<string[]>() ?? ["http://localhost:5173"];
builder.Services.AddCors(options =>
{
options.AddPolicy("McpBrowserClient", policy =>
{
policy.WithOrigins(allowedOrigins)
.WithMethods("POST", "GET", "DELETE")
.WithHeaders("Content-Type", "Authorization",
"MCP-Protocol-Version", "Mcp-Session-Id")
.WithExposedHeaders("Mcp-Session-Id");
});
});
app.UseCors();
app.MapMcp("/mcp").RequireCors("McpBrowserClient");
3. SSE — Use It Only If You Have To
I’ll be straight with you: I wouldn’t choose SSE for anything new. The SDK marks EnableLegacySse as obsolete with diagnostic MCP9004, and that label exists for a reason.
SSE splits communication across two endpoints: your server streams to the client over a /sse connection, and the client sends messages back via HTTP POST to a /message endpoint. It was the original remote transport before Streamable HTTP arrived, and it has a structural flaw that’s hard to work around: the POST endpoint returns HTTP 202 before your handler even runs, which means there’s no backpressure. Under load, requests pile up and you can’t signal to the client that the server is overwhelmed.
The only reason to use SSE today is compatibility. Some older MCP clients — early Claude Desktop builds, some third-party tools — only speak SSE. If you need to support them alongside newer clients, run SSE alongside Streamable HTTP and let clients self-select.
Client
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri("https://my-mcp-server.example.com/sse"),
TransportMode = HttpTransportMode.Sse,
MaxReconnectionAttempts = 5,
DefaultReconnectionInterval = TimeSpan.FromSeconds(1)
});
Server — note the pragma
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = false; // SSE requires stateful mode
#pragma warning disable MCP9004
options.EnableLegacySse = true;
#pragma warning restore MCP9004
})
.WithTools<MyTools>();
The fact that you need a pragma suppression to enable it should tell you everything about the SDK team’s intentions here.
4. In-Memory — Your Testing Best Friend
This one doesn’t get talked about enough. The in-memory transport connects a client and server using System.IO.Pipelines inside the same process — no network, no serialisation overhead, no infrastructure. For unit and integration tests it’s genuinely great.
What I like about it: your tests exercise the real MCP protocol, not mocks. Tool registration, schema generation, capability negotiation — all of it runs for real, just without a network between the two sides. I caught a schema mismatch bug in a tool’s [Description] attribute using an in-memory test that I’d completely missed in manual testing.
using System.IO.Pipelines;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
Pipe clientToServer = new(), serverToClient = new();
await using var server = McpServer.Create(
new StreamServerTransport(
clientToServer.Reader.AsStream(),
serverToClient.Writer.AsStream()),
new McpServerOptions
{
ToolCollection =
[
McpServerTool.Create(
(string message) => $"Echo: {message}",
new() { Name = "echo" })
]
});
_ = server.RunAsync();
await using var client = await McpClient.CreateAsync(
new StreamClientTransport(
clientToServer.Writer.AsStream(),
serverToClient.Reader.AsStream()));
var tools = await client.ListToolsAsync();
var echo = tools.First(t => t.Name == "echo");
Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello, MCP!" }));
// Output: Echo: Hello, MCP!
One limitation worth knowing: in-memory tests won’t catch network-specific failure modes — timeouts, dropped connections, serialisation edge cases from real socket buffers. Use in-memory for protocol correctness tests, and add a handful of real HTTP integration tests for your production transport paths.
Which MCP Transport Should You Use?
Here’s the decision in plain terms. I’ve seen people overthink this — pick the one that fits your deployment and move on.
| Your situation | Use this |
|---|---|
| Connecting to Claude Desktop, VS Code, or any local AI client | stdio |
| Remote server on Azure / AWS, tools are pure request-response | Streamable HTTP — stateless |
| Remote server, needs server push or long-running job tracking | Streamable HTTP — stateful |
| Supporting a legacy client that only speaks SSE | SSE (but plan to migrate) |
| Writing unit or integration tests | In-memory |
| Not sure — want the client to handle it automatically | HttpTransportMode.AutoDetect on the client |
One More Thing: Enterprise SSO with the ID-JAG Flow
If you’re deploying an MCP server inside a corporate environment with Okta or Entra ID, you’ll likely hit the question of how agents authenticate without requiring users to log in every time. The SDK has a built-in solution for this: the Identity Assertion Grant flow.
In short: it exchanges an OIDC ID token from your identity provider for an MCP access token via a two-step RFC-standard token exchange. Your agent stays authenticated, the access token is cached until expiry, and you call InvalidateCache() if you get a 401 and need to refresh.
using ModelContextProtocol.Authentication;
var provider = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "mcp-client-id",
IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
IdpClientId = "idp-client-id",
// This callback retrieves the current user's ID token from your SSO client
IdTokenCallback = (context, cancellationToken) =>
mySsoClient.GetIdTokenAsync(cancellationToken)
},
new HttpClient());
var tokens = await provider.GetAccessTokenAsync(
resourceUrl: new Uri("https://mcp-server.example.com"),
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"),
cancellationToken: ct);
// On 401: provider.InvalidateCache() forces a fresh exchange next call
I’ll cover enterprise auth in more depth in a dedicated post — there’s enough there to warrant its own treatment, especially for Azure-hosted scenarios with Entra ID.
Frequently Asked Questions
What is the default MCP transport in the C# SDK?
There isn’t one — you choose explicitly when configuring the server. The most common starting points are WithStdioServerTransport() for local tools and WithHttpTransport() from ModelContextProtocol.AspNetCore for anything deployed remotely.
Can I switch transports without rewriting my tools?
Yes, completely. The transport is configured in Program.cs; your [McpServerTool] methods don’t know or care which transport is active. I’ve migrated projects from stdio to Streamable HTTP in under 10 minutes — it’s genuinely just a config change.
What is the difference between stateless and stateful Streamable HTTP?
Stateless means each HTTP POST is a standalone request — no session, no shared state, infinitely scalable. Stateful means the server issues an Mcp-Session-Id and tracks per-session state in memory, which enables server-to-client push notifications and session resumption but requires sticky sessions at your load balancer.
Is SSE still supported in the MCP C# SDK?
Yes, but it’s marked obsolete (diagnostic MCP9004). The SDK team recommends Streamable HTTP for all new work. SSE is there for backwards compatibility with older clients and will likely be removed in a future major version.
What transport does Claude Desktop use?
stdio. Claude Desktop launches your server as a child process using the command defined in claude_desktop_config.json and communicates over stdin/stdout. This is why local MCP servers for Claude are so simple to set up — no networking involved at all.
Can I run stdio and Streamable HTTP on the same server?
Not in a single AddMcpServer() setup, but the practical pattern is to put your tool logic in a shared class library and have two thin host projects — one with stdio for local dev tooling, one with Streamable HTTP for production. The tool code is identical; only the entry point differs.
Transport decisions look simple on paper and get complicated fast when you’re deploying to Azure with sticky session requirements and corporate SSO in the way. If you’re navigating that and want a second opinion, find me on LinkedIn — I’m in Sydney and happy to talk it through.
This is part of my MCP server series: What Are MCP Servers? · Real-World MCP Case Study · Production MCP Deployment on Azure.