Technical articles on AI agents, Azure, .NET, architecture, and EV charging systems from Sydney.

Tag: Streamable HTTP

How I Design an Enterprise MCP Gateway in .NET

Once you have more than one MCP server, the question stops being “how do I expose a tool?” and becomes “how do I expose many tools without turning every AI client into a spaghetti of credentials, transports, and half-documented endpoints?” That is the moment people start talking about an MCP gateway.

I have already written about what MCP servers are and how I choose between stdio, Streamable HTTP, and SSE. This post sits one layer above those pieces. It is the architecture I reach for when local demos are fine, but a real team needs a single place to attach Claude Code, Cursor, or an internal agent host — with auth, allowlists, and logs that an enterprise security review will not immediately reject.

I am not selling a product here. This is the pattern I would sketch on a whiteboard for a .NET shop that already runs APIs on Azure and does not want every agent process spawning its own copy of every tool server.

What an MCP Gateway Actually Is

In the wild, “MCP gateway” gets used for three slightly different things:

  1. A reverse proxy for JSON-RPC — one public endpoint, many backend MCP servers.
  2. A transport adapter — wrap stdio servers so remote clients can reach them over Streamable HTTP.
  3. A policy plane — authentication, per-tool allowlists, rate limits, audit trails, and catalog aggregation.

A useful enterprise gateway is all three. If you only reverse-proxy without policy, you have moved the mess behind a nicer hostname. If you only do policy without transport bridging, half your existing stdio servers stay stuck on laptops.

The mental model I use is deliberately boring:

  • Northbound: one MCP endpoint that clients understand (preferably Streamable HTTP).
  • Southbound: many servers — some remote Streamable HTTP, some legacy SSE, some local stdio wrapped by the gateway process or a sidecar.
  • Control plane: registry of servers/tools, authn/authz, observability.

That is closer to an API gateway plus a service registry than it is to “another chat plugin.”

Why Teams Outgrow Point-to-Point MCP

Point-to-point works for a single developer. It falls over in familiar ways once more people join:

  • Every client config file lists five servers, five URLs, and five secret storage stories.
  • stdio servers cannot be shared cleanly across a team or a CI runner.
  • Tool names collide (search, query, run) with no namespace strategy.
  • Security asks who can call execute_sql and you have no answer beyond “the laptop that has the config.”
  • You cannot rotate a backend without editing every client.

None of that means MCP is wrong. It means the integration boundary needs a facade, the same way we stopped giving every browser a direct connection to every microservice.

Reference Architecture I Would Use in .NET

Here is the shape I would implement first, then harden.

Layer Responsibility .NET-friendly choice
Edge TLS, WAF, private networking Azure Front Door / App Gateway, or internal only via VNet
Gateway host MCP session, routing, policy ASP.NET Core + official MCP C# SDK transports
Identity Who is calling Entra ID (OAuth 2.1 / OIDC), workload identity for services
Registry Server catalog, tool metadata, versions Config + SQL/Cosmos; eventually a small admin API
Backends Actual tools Existing MCP servers, internal APIs wrapped as tools
Telemetry Traces, tool audit, failures OpenTelemetry → App Insights

Two design choices I treat as non-negotiable for anything beyond a lab:

  1. Northbound transport is Streamable HTTP (stateful only if you truly need sessions; stateless when you can get away with it). Clients that only speak stdio can use a thin local bridge, but the shared service is HTTP.
  2. The gateway never becomes the place business logic lives. It routes, authorizes, and observes. Domain rules stay in the backend servers or existing APIs.

Core Responsibilities, in Practice

1. Catalog aggregation

Clients should see a merged tool list. Behind the scenes you will:

  • Connect to each registered backend
  • Pull tools/list (and prompts/resources if you expose them)
  • Prefix or namespace tool names to avoid collisions — for example billing.refund vs bare refund
  • Refresh on a schedule or on admin signal when a backend deploys

I prefer explicit namespaces over hoping two teams never pick the same verb.

2. Request routing

When a client calls a tool, the gateway:

  1. Authenticates the caller
  2. Checks allowlist / role policy for that tool
  3. Maps the public tool name to a backend + original tool name
  4. Forwards the JSON-RPC call with a correlation id
  5. Returns the result (or a sanitized error)

Keep the public schema stable even if a backend renames an internal tool. That stability is half the point of the facade.

3. Transport bridging

stdio is still the fastest way to build a tool server on a workstation. In production I wrap those processes deliberately — supervisor restarts them, the gateway owns their lifetime, and they are not reachable except through the gateway. Remote backends should already speak Streamable HTTP. Legacy SSE gets a compatibility adapter with a sunset date, not a forever home. I covered the trade-offs in the transport comparison.

4. Auth and tool policy

MCP’s authorization story has matured around HTTP transports and OAuth-style flows. In an enterprise .NET environment I still design policy the way I would for any sensitive API:

  • Service-to-service: managed identity / client credentials where appropriate
  • User-delegated agents: Entra ID tokens with clear audience and scopes
  • Tool-level authorization: not just “is logged in,” but “may call payments.capture
  • Optional approval workflows for high-risk tools (human in the loop) outside the protocol if needed

If everything is allowed once you pass the front door, you built a reverse proxy with better marketing.

A Minimal .NET Sketch

The following is intentionally incomplete. It shows the seams I care about — registry, policy, and forward — rather than a full production host. Use the official MCP C# SDK types for the real wire protocol; treat this as architecture-shaped pseudocode that compiles in spirit.

public sealed record McpBackend(
    string Id,
    string PublicNamespace,
    Uri? HttpEndpoint,
    BackendKind Kind);

public enum BackendKind { StreamableHttp, StdioSupervised }

public interface IToolCatalog
{
    Task RefreshAsync(CancellationToken ct);
    bool TryResolve(string publicToolName, out ResolvedTool tool);
}

public sealed record ResolvedTool(
    string BackendId,
    string UpstreamToolName,
    IReadOnlyList<string> RequiredRoles);

public sealed class GatewayToolDispatcher
{
    private readonly IToolCatalog _catalog;
    private readonly IAuthorizationService _authz;
    private readonly IBackendClientFactory _clients;
    private readonly ILogger<GatewayToolDispatcher> _log;

    public GatewayToolDispatcher(
        IToolCatalog catalog,
        IAuthorizationService authz,
        IBackendClientFactory clients,
        ILogger<GatewayToolDispatcher> log)
    {
        _catalog = catalog;
        _authz = authz;
        _clients = clients;
        _log = log;
    }

    public async Task<ToolResult> InvokeAsync(
        ClaimsPrincipal caller,
        string publicToolName,
        BinaryData arguments,
        string correlationId,
        CancellationToken ct)
    {
        if (!_catalog.TryResolve(publicToolName, out var tool))
            return ToolResult.Error("tool_not_found", $"Unknown tool '{publicToolName}'.");

        var decision = await _authz.AuthorizeAsync(caller, tool, ct);
        if (!decision.Succeeded)
        {
            _log.LogWarning(
                "Denied {Tool} for {Subject} corr={CorrelationId}",
                publicToolName, caller.Identity?.Name, correlationId);
            return ToolResult.Error("forbidden", "Not allowed to invoke this tool.");
        }

        var client = _clients.Get(tool.BackendId);
        using var scope = _log.BeginScope(new Dictionary<string, object>
        {
            ["tool"] = publicToolName,
            ["backend"] = tool.BackendId,
            ["correlationId"] = correlationId
        });

        // Forward as upstream tools/call with mapped name.
        return await client.CallToolAsync(tool.UpstreamToolName, arguments, correlationId, ct);
    }
}

A few implementation notes from real systems work:

  • Correlation ids belong on every hop. When an agent misbehaves at 2am, you will want one id that ties client request → gateway → backend logs.
  • Argument schemas should be validated at the edge. Do not assume backends are hostile-input-proof.
  • Timeouts and cancellation matter more than people expect; tool calls are often long and LLM clients retry enthusiastically.
  • Idempotency is not free. If a client retries create_invoice, decide whether the gateway or the backend owns deduplication keys.

Configuration Beats Cleverness

Early on I keep the registry in configuration plus a small database table, not a distributed discovery fantasy.

{
  "McpGateway": {
    "Backends": [
      {
        "Id": "billing",
        "PublicNamespace": "billing",
        "Kind": "StreamableHttp",
        "HttpEndpoint": "https://billing-mcp.internal/"
      },
      {
        "Id": "ops-scripts",
        "PublicNamespace": "ops",
        "Kind": "StdioSupervised",
        "Command": "dotnet",
        "Args": ["OpsMcp.dll"]
      }
    ],
    "DefaultDeny": true
  }
}

DefaultDeny is deliberate. New tools stay dark until someone maps roles. That is mildly annoying in week one and deeply comforting in month six.

Security Review Talking Points

If you present this design inside a bank, telco, or large government supplier, expect these questions. Having answers ready is part of the architecture.

Question Answer shape I use
Where do secrets live? Backend credentials in Key Vault / managed identity; never in client MCP configs.
Can the model see admin tools? Only if the caller’s token maps to a role that includes them; catalog is filtered per principal.
What is logged? Tool name, subject, latency, success/failure, correlation id — not raw secrets or full PII payloads.
Blast radius if the gateway is compromised? Network isolation to backends, short-lived credentials, per-backend least privilege.
How do we revoke access? IdP group removal + gateway policy cache TTL measured in minutes, not days.

I also keep a hard list of tools that must never be exposed to a general-purpose coding agent — production data deletion, unrestricted shell, wire-transfer equivalents. Those either stay off the gateway or require a separate break-glass path.

Where Gateways Go Wrong

Patterns I have seen (and occasionally caused):

  • God gateway. Business rules, prompt templates, and five product teams’ special cases all land in one repo. Split backends early.
  • Transparent proxy with no schema discipline. Tool names thrash every sprint; clients break; people blame “AI.”
  • stdio farm without supervision. Orphan processes, leaked file handles, mysterious port conflicts.
  • Logging full tool arguments “just for debug.” That is how payroll numbers end up in a log analytics workspace with the wrong retention policy.
  • Ignoring multi-tenancy. If two business units share a gateway, tenant isolation is a first-class design input, not a later flag.

How This Fits the Rest of an AI Platform

An MCP gateway is not a model router and not an agent framework. Keep those boundaries clean:

  • Model gateway / LLM proxy — tokens, model selection, spend caps.
  • Agent runtime — planning, memory, multi-step loops.
  • MCP gateway — tools as capabilities with enterprise controls.

When those three collapse into one binary, upgrades get political. When they stay separate, you can swap an agent host without re-litigating tool security.

If your world also includes operational systems — and mine often does, including EV charging platforms speaking OCPP to a CSMS — the same facade thinking applies. Agents should not get a raw socket to production infrastructure. They get mediated tools with audit trails. The technology changes; the integration discipline does not.

Frequently Asked Questions

What is an MCP gateway?

An MCP gateway is a mediating service between AI clients and one or more MCP servers. It aggregates tool catalogs, enforces authentication and authorization, bridges transports such as stdio and Streamable HTTP, and provides a single stable endpoint for clients.

Do I need an MCP gateway for local development?

Usually no. stdio servers wired directly into Claude Desktop or an IDE are fine for personal workflows. Introduce a gateway when multiple users, multiple servers, or shared credentials enter the picture.

Should the gateway use stdio or Streamable HTTP northbound?

For anything shared, prefer Streamable HTTP northbound. Keep stdio for local clients or as a southbound implementation detail behind the gateway.

Is an MCP gateway the same as an API gateway?

Related idea, different protocol concerns. You still want TLS termination, identity, and routing, but you must handle MCP’s JSON-RPC methods, tool catalogs, and sometimes long-lived sessions — not only REST paths.

How do I prevent tool name collisions?

Namespace by backend or domain (billing.refund, ops.restart_worker) and treat public names as a stable contract owned by the gateway team.

Can I put every internal API behind MCP?

You can, but you should not. Expose small, intention-revealing tools with clear side effects. A 200-endpoint OpenAPI surface dumped into tool form overwhelms models and frightens security reviewers.

Closing

The useful question is not “do we need an MCP gateway?” It is “where do we want the control point for agent tools to live?” If the answer is “in twelve desktop config files,” you already know the next outage’s shape.

Start with one HTTP endpoint, one backend, real identity, and default-deny policy. Add namespaces, telemetry, and stdio supervision when the first design proves itself. The protocol will keep evolving; a clear facade still ages better than a pile of special cases.

If you want the foundations under this post, read What Are MCP Servers? and MCP Transport Types Explained next — or again, with the gateway lens in mind.

MCP Transport Types Explained: stdio vs Streamable HTTP vs SSE in C#

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.

Powered by WordPress & Theme by Anders Norén