Day 8 · Week 2 · Spark Phase

Transport Deep Dive

Under the hood of MCP's wire layer — master stdio, HTTP with Server-Sent Events, WebSockets, and production security for each transport type.

📅 Day 8 of 90 🔌 Transport Layer 🔒 Security Patterns
What Is a Transport?
The MCP spec deliberately separates what is communicated (JSON-RPC messages) from how it's communicated (the transport). This means one server codebase can run over stdio in Claude Desktop, over HTTP for a web app, or over WebSockets for a real-time dashboard — with zero changes to your tool/resource/prompt logic.
graph LR A["MCP Server Logic\n(Tools / Resources / Prompts)"] --> B["Transport Interface\n.send() / .onMessage()"] B --> C["stdio\n(local processes)"] B --> D["HTTP + SSE\n(web / cloud)"] B --> E["WebSocket\n(real-time)"] B --> F["Custom\n(your own)"] style A fill:#0d2a28,stroke:#0d9488,color:#5eead4 style B fill:#1a1a24,stroke:#444,color:#8888aa style C fill:#1a2000,stroke:#84cc16,color:#bef264 style D fill:#0d1f2a,stroke:#0ea5e9,color:#7dd3fc style E fill:#1e1030,stroke:#8b5cf6,color:#c4b5fd style F fill:#2a1a1a,stroke:#f59e0b,color:#fde68a
🧩
The transport abstraction: Every transport in the TypeScript SDK implements the same Transport interface — start(), send(message), onmessage, onerror, onclose, and close(). Swapping transports is a one-line change in your entry point.
The Three Official Transports
MCP ships three transport implementations out of the box. Each has a distinct sweet spot — understanding the trade-offs is the first step to choosing correctly.
🖥️
stdio
Standard in / standard out — the workhorse of local MCP
  • Latencysub-millisecond
  • Setup complexityzero
  • Network requiredno
  • Multi-clientno (1:1)
  • Streamingline-by-line
  • Best forClaude Desktop
🌐
HTTP + SSE
Stateless requests + a persistent event stream
  • Latency~10–100 ms
  • Setup complexitymoderate
  • Network requiredyes
  • Multi-clientyes
  • StreamingSSE push
  • Best forweb / cloud APIs
WebSocket
Full-duplex persistent connection over a single TCP socket
  • Latency~2–20 ms
  • Setup complexitymoderate
  • Network requiredyes
  • Multi-clientyes
  • Streamingfull-duplex
  • Best fordashboards / IDEs
CapabilitystdioHTTP + SSEWebSocket
Server → Client pushpipe onlySSEnative
Works behind load balancernoyessticky sessions
Firewall-friendlyyesport 80/443port 80/443
Reconnect on dropprocess exitEventSource automanual
TLS / encryptionOS-level onlyHTTPSWSS
Auth tokensenv varsheaders / cookiesheaders / subprotocol
stdio — The Local Powerhouse
stdio is the default transport for Claude Desktop and the MCP Inspector. The host spawns your server as a child process and pipes JSON-RPC messages over stdin/stdout. It's deceptively simple but surprisingly robust.
How stdio transport works internally
Message framing
Each JSON-RPC message is a single newline-delimited JSON line. The SDK reads line-by-line; incomplete lines are buffered until \n arrives.
Process lifecycle
Claude Desktop spawns a new child process per connection. If the process exits, the connection closes. There is no reconnection — the host re-spawns on next use.
Backpressure
Node.js pipe backpressure applies. If the host reads slowly (rare) the write buffer can fill. The SDK handles this automatically via the stream API.
Logging rule
NEVER write to stdout (console.log) — it corrupts the JSON-RPC stream. Use console.error (stderr) or the MCP logging primitive.
stderr handling
Claude Desktop captures stderr and shows it in its log viewer. Use it freely for debug info.
// Complete stdio server bootstrap — exactly what you built in Day 7
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new McpServer({ name: 'my-server', version: '1.0.0' });

// ... register tools, resources, prompts ...

const transport = new StdioServerTransport();
await server.connect(transport);
// Server is now listening — process stays alive until stdin closesstdio bootstrap
⚠️
One connection, one process: stdio is a 1:1 transport. If you need multiple simultaneous clients (e.g., two Claude Desktop windows connecting to the same server) you must use HTTP+SSE or WebSocket, or accept that each window spawns its own process.
HTTP + Server-Sent Events (SSE)
The HTTP+SSE transport is a clever hybrid: client → server messages travel over regular HTTP POST requests, while server → client messages flow over a persistent SSE connection. This means you get server push without WebSocket's complexity, and you remain 100% firewall-friendly.
sequenceDiagram participant C as MCP Client participant S as MCP Server (HTTP) C->>S: GET /sse (EventSource open) S-->>C: event: endpoint\ndata: {"uri":"/message?sessionId=abc"} Note over C,S: SSE stream stays open ↕ C->>S: POST /message?sessionId=abc\n{"jsonrpc":"2.0","method":"initialize",...} S-->>C: event: message\ndata: {"jsonrpc":"2.0","result":{...}} C->>S: POST /message?sessionId=abc\n{"method":"tools/call",...} S-->>C: event: message\ndata: {"jsonrpc":"2.0","result":{...}} C->>S: Connection closes → SSE ends
The SSE event stream wire format — what actually flows over the network:
S→Cevent: endpoint
S→Cdata: {"uri":"/message?sessionId=abc123"}
blank(empty line — SSE event delimiter)

C→SPOST /message?sessionId=abc123
C→S{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}

S→Cevent: message
S→Cdata: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
blank(event delimiter)
// src/http-server.ts — Full HTTP + SSE server with Express
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';

const app = express();
app.use(express.json());

// Session store: sessionId → transport instance
const sessions = new Map<string, SSEServerTransport>();

// ── SSE endpoint — client opens this first ────────────────────────────────
app.get('/sse', async (req, res) => {
  // Set SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering

  const transport = new SSEServerTransport('/message', res);
  const sessionId = transport.sessionId;
  sessions.set(sessionId, transport);

  const server = new McpServer({ name: 'my-http-server', version: '1.0.0' });
  // register tools, resources, prompts on `server`...
  await server.connect(transport);

  req.on('close', () => {
    sessions.delete(sessionId);
    console.error(`[SSE] Session ${sessionId} disconnected`);
  });
});

// ── POST endpoint — all client → server messages ──────────────────────────
app.post('/message', async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = sessions.get(sessionId);

  if (!transport) {
    res.status(404).json({ error: 'Session not found' });
    return;
  }
  await transport.handlePostMessage(req, res, req.body);
});

// ── Health check ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', sessions: sessions.size });
});

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => console.error(`[HTTP MCP] listening on :${PORT}`));src/http-server.ts
📦
Add Express to your project: npm install express && npm install -D @types/express. The SSEServerTransport is included in the core SDK — no extra package needed.
WebSocket Transport
WebSocket gives you a single persistent, full-duplex TCP connection. Unlike HTTP+SSE — where requests and responses travel on different channels — WebSocket frames flow freely in both directions on one socket. This makes it ideal for high-frequency interactions like IDE integrations and real-time dashboards.
WebSocket vs HTTP+SSE — the key difference
Connection count
WebSocket: 1 connection per client. HTTP+SSE: 2 channels (1 SSE stream + N POST requests).
Message ordering
WebSocket guarantees strict ordering at the frame level. HTTP+SSE ordering depends on POST responses arriving before the SSE event (usually fine, occasionally subtle).
Load balancer support
WebSocket requires sticky sessions (same backend for the lifetime of a connection). HTTP+SSE only requires SSE stickiness — POST can go anywhere if you share session state.
Reconnection
SSE's EventSource reconnects automatically with backoff. WebSocket reconnection requires your own logic (or a library like reconnecting-websocket).
// src/ws-server.ts — WebSocket MCP server with the 'ws' library
import { WebSocketServer, WebSocket } from 'ws';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { WebSocketServerTransport } from '@modelcontextprotocol/sdk/server/websocket.js';

const wss = new WebSocketServer({ port: 3001 });

wss.on('connection', async (ws: WebSocket, req) => {
  console.error(`[WS] New connection from ${req.socket.remoteAddress}`);

  // Each connection gets its own McpServer instance
  const server = new McpServer({ name: 'my-ws-server', version: '1.0.0' });
  // register tools, resources, prompts...

  const transport = new WebSocketServerTransport(ws);
  await server.connect(transport);

  ws.on('close', () => console.error('[WS] Client disconnected'));
  ws.on('error', (err) => console.error('[WS] Error:', err));
});

console.error('[WS MCP] WebSocket server on ws://localhost:3001');src/ws-server.ts
💡
Per-connection server instances: Notice that a new McpServer is created for each WebSocket connection. This is the idiomatic pattern — it gives each client isolated state (auth context, user-specific caching) without global mutable state race conditions.
Building a Custom Transport
If none of the three built-in transports fits your needs (say you want to use NATS, Redis Pub/Sub, or an in-process channel for testing), you can implement the Transport interface directly. It has exactly five members.
// The complete Transport interface from @modelcontextprotocol/sdk
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';

// ── In-process transport pair — perfect for unit testing ─────────────────
// Creates two linked transports: messages sent on A arrive on B, and vice versa.
export function createInProcessTransportPair(): [Transport, Transport] {
  let aHandler: ((msg: JSONRPCMessage) => void) | undefined;
  let bHandler: ((msg: JSONRPCMessage) => void) | undefined;

  const a: Transport = {
    start: async () => {},
    close: async () => { a.onclose?.(); b.onclose?.(); },
    send: async (msg) => { bHandler?.(msg); },           // A sends → B receives
    set onmessage(fn) { aHandler = fn; },
    get onmessage() { return aHandler; },
    onerror: undefined,
    onclose: undefined,
  };

  const b: Transport = {
    start: async () => {},
    close: async () => { a.onclose?.(); b.onclose?.(); },
    send: async (msg) => { aHandler?.(msg); },           // B sends → A receives
    set onmessage(fn) { bHandler = fn; },
    get onmessage() { return bHandler; },
    onerror: undefined,
    onclose: undefined,
  };

  return [a, b];
}

// Usage in tests:
// const [clientTransport, serverTransport] = createInProcessTransportPair();
// await mcpServer.connect(serverTransport);
// await mcpClient.connect(clientTransport);
// Now test client.callTool(...) against a real server with no networkin-process transport
🧪
The in-process transport unlocks integration testing. Spin up a real McpServer with real tool handlers, connect an McpClient over the in-process pair, and exercise the full JSON-RPC handshake — no HTTP server, no ports, no flaky network in CI.
Authentication Patterns per Transport
Authentication looks different for each transport because the channel shapes are different. Here's the battle-tested pattern for each.
// ── stdio: env-var secrets ────────────────────────────────────────────────
// Secrets are injected via the claude_desktop_config.json "env" block
// and read with process.env inside the server. Never hardcode.
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) throw new Error('GITHUB_TOKEN env var is required');

// ── HTTP+SSE: Bearer token middleware ─────────────────────────────────────
import type { Request, Response, NextFunction } from 'express';

function requireAuth(req: Request, res: Response, next: NextFunction) {
  const auth = req.headers.authorization ?? '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;

  if (!token || token !== process.env.MCP_SECRET_TOKEN) {
    res.status(401).json({ error: 'Unauthorized' });
    return;
  }
  next();
}

// Apply to both endpoints
app.get('/sse',     requireAuth, sseHandler);
app.post('/message', requireAuth, postHandler);

// ── WebSocket: token in the upgrade request ───────────────────────────────
wss.on('connection', (ws, req) => {
  // Option A: token in query string (simpler, less secure)
  const url = new URL(req.url ?? '/', 'ws://localhost');
  const token = url.searchParams.get('token');

  // Option B: token in Sec-WebSocket-Protocol header (more standard)
  const protocols = req.headers['sec-websocket-protocol'] ?? '';
  const bearerToken = protocols.split(',').find(p => p.trim().startsWith('token-'))?.slice(6);

  if (!token && !bearerToken) {
    ws.close(1008, 'Unauthorized'); // 1008 = Policy Violation
    return;
  }
  // ... proceed with authenticated connection
});auth patterns
🚨
Never use query-string tokens in production WebSocket over HTTP. They appear in server access logs and browser history. Use the Sec-WebSocket-Protocol subprotocol header or a short-lived ticket from a REST endpoint instead.
Production Security Checklist
Transport security isn't optional once your MCP server handles real data. These six areas cover the attack surface specific to MCP servers.
🔐
TLS Everywhere
Always run HTTP+SSE behind HTTPS and WebSocket over WSS in production. Use a reverse proxy (nginx, Caddy) that handles TLS termination so your Node process only handles plain HTTP internally.
🔑
Secret Rotation
API keys and bearer tokens must be rotatable without downtime. Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault). Never hardcode secrets in source or Docker images.
🚦
Rate Limiting
Add per-session and per-IP rate limits on the HTTP POST endpoint. A malicious client can hammer tools/call with expensive operations. Use express-rate-limit or an API gateway layer.
🌍
CORS Policy
For browser-facing HTTP+SSE servers, lock down Access-Control-Allow-Origin to your specific domain. Wildcard * allows any website to connect to your MCP server and invoke tools on behalf of a logged-in user.
📏
Payload Size Limits
Large JSON-RPC messages can exhaust memory. Set express.json({ limit: '1mb' }) and validate that tool arguments conform to your Zod schemas before any expensive processing begins.
🔍
Origin Validation
For local stdio servers, validate that the spawning process is the expected host (e.g., Claude Desktop). For HTTP servers, validate the Origin header on SSE connections to prevent cross-origin event-stream hijacking.
// Production nginx config — TLS termination + proxy to Node MCP server
server {
    listen 443 ssl http2;
    server_name mcp.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem;

    # SSE requires disabled buffering + increased timeouts
    location /sse {
        proxy_pass         http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection '';          # must be empty for SSE
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_buffering    off;                    # critical for SSE
        proxy_cache        off;
        proxy_read_timeout 86400s;                 # 24h — keep SSE alive
        chunked_transfer_encoding on;
    }

    # POST messages — normal proxy
    location /message {
        proxy_pass       http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        client_max_body_size 1m;
    }
}nginx.conf
⚠️
proxy_buffering off is critical for SSE. Without it, nginx buffers the entire response before sending it to the client — which means SSE events arrive in batches (or not at all until the connection closes). Always disable buffering on SSE endpoints.
Reconnection & Resilience
Network connections drop. A production MCP setup handles this gracefully on both sides — the server cleans up orphaned sessions and the client reconnects without losing context.
1
SSE auto-reconnect (client side)
The browser's EventSource automatically reconnects when the SSE stream drops. It sends the Last-Event-ID header so the server can replay missed events — no code needed on your part.
2
Server-side session cleanup
Listen to req.on('close') on the SSE endpoint. Delete the session from your Map immediately so stale handlers don't accumulate and leak memory.
3
WebSocket heartbeat (ping/pong)
TCP connections can go "half-open" silently — both sides think the connection is alive but packets are dropped. Implement a ping every 30 seconds; if pong doesn't arrive within 10 seconds, terminate and let the client reconnect.
4
Graceful shutdown
Handle SIGTERM and SIGINT. Send a JSON-RPC shutdown notification to connected clients, drain in-flight requests, then close the transport. This prevents clients from seeing abrupt disconnections during deploys.
// WebSocket heartbeat + graceful shutdown pattern
const PING_INTERVAL = 30_000;
const PONG_TIMEOUT  = 10_000;

wss.on('connection', (ws) => {
  let isAlive = true;
  ws.on('pong', () => { isAlive = true; });

  const pingTimer = setInterval(() => {
    if (!isAlive) {
      console.error('[WS] Heartbeat timeout — terminating');
      ws.terminate();
      return;
    }
    isAlive = false;
    ws.ping();
  }, PING_INTERVAL);

  ws.on('close', () => clearInterval(pingTimer));
});

// Graceful shutdown on SIGTERM (e.g., Kubernetes rolling deploy)
process.on('SIGTERM', async () => {
  console.error('[shutdown] SIGTERM received — draining connections');
  wss.clients.forEach(ws => ws.close(1001, 'Server shutting down'));
  await new Promise(r => wss.close(r));
  process.exit(0);
});resilience patterns
Choosing the Right Transport
The decision is almost always straightforward once you know your deployment target. Use this guide to settle debates with your team in seconds.
Use stdio when…
  • Integrating with Claude Desktop
  • Local developer tooling
  • Single-user scripts or CLIs
  • No network required
  • You want zero infrastructure overhead
  • MCP Inspector testing
Use HTTP+SSE when…
  • Multi-user / multi-tenant API
  • Deployed to cloud (AWS, GCP, Azure)
  • Behind a load balancer
  • Browser-based MCP clients
  • SSE reconnect is important
  • Firewall-restricted environments
Use WebSocket when…
  • IDE plugin with live feedback
  • Real-time dashboard or monitoring
  • High-frequency tool calls (>10/sec)
  • Bidirectional streaming needed
  • Latency is critical (<20 ms)
  • Long-lived interactive sessions
🔀
You can offer multiple transports from one server. Start an Express app that listens for SSE on port 3000 and a WebSocket server on port 3001. Both connect to the same registered tools/resources/prompts. This is how enterprise deployments support both browser and IDE clients simultaneously.
Real-World Deployment Patterns
What does a production HTTP+SSE MCP deployment actually look like? Here's the reference architecture used by teams shipping MCP servers to thousands of users.
graph TD A["🌐 Internet"] --> B["nginx / Caddy\nTLS termination + rate limit"] B --> C["Load Balancer\nsticky sessions by sessionId"] C --> D["Node MCP Server\nInstance 1"] C --> E["Node MCP Server\nInstance 2"] C --> F["Node MCP Server\nInstance N"] D --> G["Redis\nSession state store"] E --> G F --> G D --> H["GitHub API / DB\n/ downstream services"] E --> H F --> H style A fill:#111118,stroke:#444,color:#666 style B fill:#0d1f14,stroke:#10b981,color:#6ee7b7 style C fill:#0d1a2a,stroke:#0ea5e9,color:#7dd3fc style D fill:#0d2a28,stroke:#0d9488,color:#5eead4 style E fill:#0d2a28,stroke:#0d9488,color:#5eead4 style F fill:#0d2a28,stroke:#0d9488,color:#5eead4 style G fill:#1e1a14,stroke:#f59e0b,color:#fde68a style H fill:#1e1030,stroke:#8b5cf6,color:#c4b5fd
// Scaling tip: share session state in Redis so any server can handle
// any POST /message — you don't need hard sticky sessions for POST,
// only for the SSE connection itself.

import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

// On SSE connect — store session metadata
await redis.setEx(`session:${sessionId}`, 86400, JSON.stringify({
  connectedAt: Date.now(),
  serverId: process.env.SERVER_ID,
}));

// On POST /message — look up session to confirm it exists
const meta = await redis.get(`session:${sessionId}`);
if (!meta) { res.status(404).json({ error: 'Session expired' }); return; }

// On SSE disconnect — clean up
await redis.del(`session:${sessionId}`);redis session sharing
Transport Quick Reference
Everything you need to remember on one screen — bookmark this before Day 9.
TopicstdioHTTP+SSEWebSocket
SDK classStdioServerTransportSSEServerTransportWebSocketServerTransport
Import path…/server/stdio.js…/server/sse.js…/server/websocket.js
Server pushpipeSSE eventsWS frames
Authenv varsBearer headerWS protocol header
Sessions1 per processMap<id, transport>per connection
Nginx confignot neededproxy_buffering offUpgrade header
Reconnecthost re-spawnsEventSource automanual / library
Loggingstderr ONLYstdout safestdout safe
Transport Knowledge Check
5 questions covering all three transports, security, and deployment patterns.
Q1In HTTP+SSE transport, client-to-server messages (like tools/call) travel over which channel?
AThe persistent SSE stream
BHTTP POST requests to the /message endpoint
CA WebSocket upgrade on the same connection
DHTTP GET requests with query parameters
Q2You write console.log('debug info') inside a stdio MCP server handler. What happens?
AThe message appears in Claude Desktop's log viewer
BNothing — Node.js suppresses log output in stdio mode
CThe text is written to stdout, corrupting the JSON-RPC stream and breaking the connection
DThe SDK intercepts it and reroutes to stderr automatically
Q3Your nginx config proxies an SSE endpoint but clients report events arriving in large delayed batches instead of real-time. What's the most likely cause?
AThe server is using HTTP/1.0 instead of HTTP/1.1
Bproxy_buffering is enabled — nginx buffers the response before forwarding
CThe Content-Type header is missing text/event-stream
DThe client is not sending an Accept header
Q4You want to run integration tests against a real McpServer with all tools registered, without starting an HTTP server or binding a port. Which approach is best?
AUse StdioServerTransport and pipe stdin/stdout in the test
BStart a real HTTP server on a random port and tear it down after each test
CUse an in-process transport pair — connect client and server directly in memory
DMock the entire McpServer class with Jest
Q5For a WebSocket MCP server behind a load balancer, why do you typically need sticky sessions?
AWebSocket doesn't support TLS, so the load balancer must terminate the connection
BThe persistent WebSocket connection and its associated in-memory state live on one backend instance — subsequent frames must reach the same instance
CLoad balancers don't understand the WebSocket protocol and drop frames
DWebSocket requires a dedicated port that load balancers can't share
← Previous Day
Day 7: Week 1 Capstone
Build a complete MCP server from scratch
Next Day →
Day 9: MCP Client SDK
Build clients that talk to any MCP server