WRITING June 16, 2026 8 min read

MCP Server Transports: Stdio vs SSE vs Streamable HTTP in Practice

The first real architecture decision when building an MCP server is which transport to use. I've shipped servers on all three, and here's what actually matters when choosing between stdio, SSE, and Streamable HTTP.

“Which transport should I use?” is the first real architecture question every MCP server builder faces. Most tutorials pick stdio and move on. But the choice has real implications for lifecycle management, deployment topology, multi-tenancy, and performance. After building four MCP servers (scry, tome, lore, and flume), I’ve used all three transports in different contexts and developed strong opinions about when each one is the right call.

This post is part of my MCP series. If you’re new to the protocol, start there. If you’re building in Go specifically, the Go implementation guide covers the SDK in depth.

Stdio: the local workhorse

Stdio is the simplest transport. The client launches your server as a child process. Communication happens over stdin (client to server) and stdout (server to client) using newline-delimited JSON-RPC. No ports, no HTTP, no network stack at all.

The lifecycle model is what makes stdio distinctive. Your server lives and dies with the client process. When Claude Code starts, it spawns your server. When Claude Code exits, your server gets a SIGTERM. There is no independent lifecycle to manage, no orphaned processes, no stale connections.

Here’s a complete stdio server in Go using the official SDK:

package main

import (
	"context"
	"log"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
	server := mcp.NewServer(
		&mcp.Implementation{Name: "my-tool", Version: "v1.0.0"},
		nil,
	)

	mcp.AddTool(server, &mcp.Tool{
		Name:        "lookup",
		Description: "Look up a symbol definition",
	}, handleLookup)

	// Run blocks until the client disconnects or the context is cancelled.
	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		log.Fatal(err)
	}
}

Nothing more is required. StdioTransport binds to os.Stdin and os.Stdout. The Run method blocks until the client disconnects. Your server doesn’t need to know anything about ports, TLS, or HTTP routing.

When to use stdio:

  • Local developer tools (code intelligence, git helpers, database introspectors)
  • Anything launched by Claude Code, Cursor, or similar editors
  • Single-client scenarios where you want zero configuration
  • Tools where client lifecycle coupling is a feature, not a bug

Limitations:

  • One client per server instance. No sharing.
  • No remote access. The client must be able to spawn the process.
  • Stdout is reserved for protocol messages, so you cannot log to stdout. Use stderr or a file.
  • Debugging is harder because you can’t easily intercept the wire traffic.

All four of my servers (scry, tome, lore, flume) use stdio. They’re all local tools designed for a single developer’s workflow. The simplicity is worth the tradeoffs.

SSE: the original HTTP transport

SSE (Server-Sent Events) was the first HTTP-based transport in the MCP spec. It uses a long-lived GET connection for server-to-client messages (the SSE stream) and separate POST requests for client-to-server messages. The server responds to the initial GET with an SSE stream that includes a session endpoint URL, and the client POSTs subsequent messages to that endpoint.

package main

import (
	"log"
	"net/http"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
	server := mcp.NewServer(
		&mcp.Implementation{Name: "my-remote-tool", Version: "v1.0.0"},
		nil,
	)

	mcp.AddTool(server, &mcp.Tool{
		Name:        "query",
		Description: "Run a query",
	}, handleQuery)

	handler := mcp.NewSSEHTTPHandler(func(r *http.Request) *mcp.Server {
		return server
	})

	http.Handle("/mcp/sse", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

The NewSSEHTTPHandler takes a factory function that returns a server for each request. You can return the same server instance for all connections (shared state) or create a new one per session (isolated state). That factory function is where multi-tenancy decisions live.

When SSE works:

  • Remote servers that need to push notifications to clients
  • Environments where you already have SSE infrastructure
  • Clients that only support the 2024-11-05 version of the spec

The problems with SSE:

  • Requires a long-lived HTTP connection, which doesn’t play well with load balancers that have idle timeouts
  • Some corporate proxies and firewalls terminate SSE connections
  • The two-endpoint model (GET for the stream, POST for messages) adds complexity
  • Session state is tied to the SSE connection, so if it drops, you lose the session

SSE works, but the spec has moved on. If you’re building something new today, look at Streamable HTTP first.

Streamable HTTP: the future

Streamable HTTP is the newest transport and the one the MCP specification is converging on. It replaces the split GET/POST model with a unified HTTP POST endpoint. The client sends JSON-RPC requests as POST bodies. The server can respond with either a single JSON response (for simple request/response patterns) or upgrade to an SSE stream for the response body (for streaming results or server-initiated messages).

This is the most firewall-friendly transport. It’s standard HTTP POST. Every proxy, load balancer, CDN, and WAF in existence knows how to handle it. No long-lived connections are required for basic operation.

package main

import (
	"log"
	"net/http"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
	server := mcp.NewServer(
		&mcp.Implementation{Name: "my-remote-tool", Version: "v1.0.0"},
		nil,
	)

	mcp.AddTool(server, &mcp.Tool{
		Name:        "search",
		Description: "Search across documents",
	}, handleSearch)

	handler := mcp.NewStreamableHTTPHandler(
		func(r *http.Request) *mcp.Server {
			return server
		},
		nil, // default options: SSE streaming enabled
	)

	http.Handle("/mcp", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

The StreamableHTTPOptions struct lets you control behavior. Setting JSONResponse: true forces synchronous JSON responses instead of SSE streaming, which is useful for testing or environments where SSE is problematic. The default (nil options) enables SSE streaming for responses.

Why Streamable HTTP wins for remote servers:

  • Works through any HTTP infrastructure without special configuration
  • Supports session resumption: clients can reconnect after drops
  • Single endpoint simplifies routing, authentication, and rate limiting
  • Optional streaming means you get the benefits of SSE when you need them without requiring long-lived connections for every interaction
  • The spec is converging here, so new clients will implement this first

Decision matrix

Here’s how I think about the choice:

FactorStdioSSEStreamable HTTP
DeploymentLocal processRemote serviceRemote service
ClientsSingleMultipleMultiple
LifecycleCoupled to clientIndependentIndependent
Network requirementsNoneLong-lived HTTPStandard HTTP POST
Firewall friendlinessN/A (local)ModerateExcellent
Multi-tenancyNoYes (with work)Yes (built-in)
Session resumptionN/ANoYes
Spec statusStable, permanentLegacy (2024-11-05)Current (2025-06-18)
Setup complexityMinimalModerateModerate

The short version: stdio for local tools, Streamable HTTP for everything else. SSE is the answer only if you’re supporting older clients that haven’t adopted the current spec.

What I actually use

All four of my servers (scry, tome, lore, flume) run over stdio. Here’s why:

They’re developer tools. They run on my machine, launched by Claude Code or Cursor. They don’t need to be shared, they don’t need to survive client disconnects, and they benefit enormously from the zero-configuration lifecycle. When I open a Claude Code session, my MCP servers are available. When I close it, they’re gone. No orphaned processes, no port conflicts, no “is the server still running?” debugging.

Scry indexes my codebase and provides sub-millisecond symbol lookups. It needs access to local files. Running it remotely would add latency and require syncing code to a server. Stdio means it reads from the same filesystem the agent is working with. Same logic applies to lore (local git repo), tome (usually local database), and flume (local HTTP traffic).

If I were building a shared MCP server (say, a team-wide tool that gives agents access to production metrics or a shared knowledge base), I’d use Streamable HTTP without hesitation. Independent lifecycle, multi-tenancy via the request factory, session resumption for reliability, and standard HTTP deployment. Put it behind an nginx reverse proxy, add auth middleware, deploy it like any other service.

Hybrid patterns: one server, multiple transports

The Go SDK makes it straightforward to serve the same logic over multiple transports. Your tool handlers, resources, and prompts are registered on the mcp.Server struct. The transport is just the delivery mechanism. This means a single binary can support both local and remote access:

package main

import (
	"context"
	"log"
	"net/http"
	"os"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func buildServer() *mcp.Server {
	server := mcp.NewServer(
		&mcp.Implementation{Name: "hybrid-tool", Version: "v1.0.0"},
		nil,
	)

	mcp.AddTool(server, &mcp.Tool{
		Name:        "search",
		Description: "Search the index",
	}, handleSearch)

	return server
}

func main() {
	server := buildServer()

	switch os.Args[1] {
	case "stdio":
		if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
			log.Fatal(err)
		}
	case "http":
		handler := mcp.NewStreamableHTTPHandler(
			func(r *http.Request) *mcp.Server {
				return server
			},
			nil,
		)
		http.Handle("/mcp", handler)
		log.Fatal(http.ListenAndServe(":8080", nil))
	}
}

I use this pattern conceptually across my servers. They all ship as single binaries. Today they only implement the stdio path because that’s all I need. But the architecture means adding an HTTP transport is a flag flip, not a rewrite. The server logic doesn’t change at all.

This is one of the strongest design decisions in the Go SDK: the clean separation between “what does this server do” (tools, resources, prompts) and “how does it communicate” (transport). Build your server once. Serve it however you need.

The transport you pick isn’t permanent

If you’re starting a new MCP server today, pick stdio if it’s a local tool and Streamable HTTP if it’s remote. Don’t overthink it. The SDK makes switching transports a mechanical change, so you’re not committing to an architecture you can’t undo.

What matters more than the transport choice is getting your tools right. The transport is plumbing. The tools are the product. Pick the transport that gets you shipping fastest and revisit it when your deployment requirements actually change, not before.

Frequently Asked Questions

What is the difference between MCP stdio and HTTP transports?

Stdio runs the MCP server as a child process of the client, communicating over stdin/stdout. HTTP transports (SSE and Streamable HTTP) run the server as a standalone service that clients connect to over the network. Stdio is simpler and dies with the client. HTTP transports support remote access, multi-tenancy, and independent lifecycle management.

Which MCP transport should I use?

Use stdio for local developer tools, CLI companions, and anything launched by a single client like Claude Code or Cursor. Use Streamable HTTP for remote servers, multi-tenant deployments, or anything that needs to survive client disconnects. SSE works but is being superseded by Streamable HTTP in the spec.

What is MCP Streamable HTTP transport?

Streamable HTTP is the newest MCP transport and the one the spec is converging on. It uses standard HTTP POST for requests and optionally upgrades to SSE for streaming responses. It works through firewalls, load balancers, and proxies without special configuration, making it the best choice for remote MCP servers.

Can an MCP server support multiple transports simultaneously?

Yes. The official Go SDK separates server logic from transport. You define your tools and handlers once on an mcp.Server, then serve that same server over stdio, SSE, or Streamable HTTP depending on how the binary is invoked. This is a common pattern for servers that need to work both locally and remotely.

Why does Claude Code use stdio for MCP servers?

Claude Code launches MCP servers as child processes over stdio because it gives the client full lifecycle control. The server starts when the client needs it and dies when the client exits. There is no port management, no authentication, and no network configuration. For local developer tools, this simplicity is the right tradeoff.

Back to all writing