Custom Transports
A transport is the layer that moves JSON-RPC 2.0 messages between an MCP client and server. The protocol logic — tools, resources, prompts, capability negotiation — is identical regardless of how bytes travel. That separation is deliberate: the same server can talk to Claude Desktop over stdio, to a browser agent over HTTP, or to a real-time dashboard over WebSocket, just by swapping the transport you pass to server.connect(). Understanding transports is what lets you take a local prototype and expose it to remote, authenticated, multi-client deployments without rewriting your tool handlers.
- Explain the MCP Transport interface and how it decouples protocol from wire format
- Wire up stdio transport for local clients like Claude Desktop and Cursor
- Compare the legacy SSE and modern Streamable HTTP transports for remote servers
- Implement a custom WebSocket transport from the Transport interface
- Handle reconnection, heartbeats, and session lifecycle safely
The Transport Interface
Every transport in the TypeScript SDK implements a single small interface from @modelcontextprotocol/sdk/shared/transport.js. Once you understand these five members, building your own transport is mostly plumbing.
typescriptimport type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js" export interface Transport { start(): Promise<void> // begin listening / connecting send(message: JSONRPCMessage): Promise<void> // client <-> server close(): Promise<void> // tear down cleanly onmessage?: (message: JSONRPCMessage) => void // inbound messages onerror?: (error: Error) => void onclose?: () => void sessionId?: string // set for stateful remote sessions }
The SDK calls start() when you invoke server.connect(transport), calls send() to emit responses and notifications, and expects you to invoke onmessage for every inbound JSON-RPC frame you decode. That contract is all a custom transport must honor.
Pro Tip
transport.start() yourself. server.connect(transport) (or client.connect(transport)) starts it for you — calling it twice throws "already started".stdio Transport
stdio is the default for locally-spawned servers. The host launches your process and exchanges newline-delimited JSON-RPC messages over stdin/stdout. It is zero-config, has no network surface, and is what Claude Desktop and Cursor use.
typescriptimport { Server } from "@modelcontextprotocol/sdk/server/index.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" const server = new Server( { name: "my-server", version: "1.0.0" }, { capabilities: { tools: {} } } ) const transport = new StdioServerTransport() await server.connect(transport) // starts reading stdin, writing stdout
The host registers the server by command in its config file:
json{ "mcpServers": { "my-server": { "command": "node", "args": ["/absolute/path/to/build/server.js"] } } }
Warning
stdout is reserved exclusively for protocol messages. A stray console.log writes garbage into the JSON-RPC stream and the client will disconnect with a parse error. Send all diagnostics to stderr via console.error(...), which the host surfaces in its logs.Remote Transports: SSE vs Streamable HTTP
For servers that outlive a single spawned process — shared internal tools, hosted APIs, browser agents — you need an HTTP-based transport. The spec offers two, and it matters which you choose for a new build.
Introduced in protocol version 2025-03-26 and the recommended transport for all new remote servers. A single /mcp endpoint handles POST (client to server) and optionally upgrades to an SSE stream for server-initiated messages. Sessions are tracked with the Mcp-Session-Id header, so it survives reconnects and load balancers.
The modern Streamable HTTP server, mounted on Express, generates a session id per connection:
typescriptimport express from "express" import { randomUUID } from "node:crypto" import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" const app = express() app.use(express.json()) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableDnsRebindingProtection: true, allowedHosts: ["127.0.0.1", "api.example.com"], }) await server.connect(transport) app.post("/mcp", async (req, res) => { await transport.handleRequest(req, res, req.body) }) app.listen(3000)
The legacy SSE transport pairs one event stream per client with a message-post endpoint, keyed by sessionId:
typescriptimport { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js" const transports: Record<string, SSEServerTransport> = {} app.get("/sse", async (req, res) => { const transport = new SSEServerTransport("/messages", res) transports[transport.sessionId] = transport res.on("close", () => { delete transports[transport.sessionId] }) await server.connect(transport) }) app.post("/messages", async (req, res) => { const transport = transports[req.query.sessionId as string] if (!transport) return res.status(400).send("Unknown sessionId") await transport.handlePostMessage(req, res) })
Note
Origin header, bind to 127.0.0.1 for local-only servers, and enable DNS-rebinding protection so a malicious web page cannot reach a server running on the user's machine.Building a Custom WebSocket Transport
The SDK ships a WebSocket client transport but no server one, which makes it the perfect exercise for the Transport interface. WebSocket gives you full-duplex, low-latency messaging — ideal for streaming agents and live dashboards. You implement start, send, and close, and forward parsed frames to onmessage.
typescriptimport type { WebSocket } from "ws" import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js" import { type JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js" export class WebSocketServerTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void onerror?: (error: Error) => void onclose?: () => void constructor(private ws: WebSocket) {} async start(): Promise<void> { this.ws.on("message", (data) => { try { // Validate against the schema before handing off to the protocol layer const message = JSONRPCMessageSchema.parse(JSON.parse(data.toString())) this.onmessage?.(message) } catch (error) { this.onerror?.(error as Error) } }) this.ws.on("close", () => this.onclose?.()) this.ws.on("error", (err) => this.onerror?.(err)) } async send(message: JSONRPCMessage): Promise<void> { this.ws.send(JSON.stringify(message)) } async close(): Promise<void> { this.ws.close() } }
Wiring it into a ws server is then a two-liner per connection:
typescriptimport { WebSocketServer } from "ws" const wss = new WebSocketServer({ port: 8080 }) wss.on("connection", async (ws) => { const transport = new WebSocketServerTransport(ws) await server.connect(transport) // calls start() for you })
Reconnection & Heartbeats
Long-lived transports must detect dead connections. TCP alone will happily keep a half-open socket for minutes, so you need application-level liveness checks and a client that knows how to come back.
- WebSocket heartbeats use the protocol's built-in ping/pong frames. Ping on an interval and terminate any socket that fails to pong.
- SSE reconnection is handled for you client-side: the SDK's
SSEClientTransportfollows the EventSource reconnect semantics and resumes automatically after a drop. - Streamable HTTP resumption relies on the
Mcp-Session-Idheader — the client replays it so the server can rebind to existing session state.
typescriptconst HEARTBEAT_MS = 30_000 wss.on("connection", (ws) => { let alive = true ws.on("pong", () => { alive = true }) const timer = setInterval(() => { if (!alive) return ws.terminate() // no pong since last tick -> dead alive = false ws.ping() }, HEARTBEAT_MS) ws.on("close", () => clearInterval(timer)) })
Warning
clearInterval (or clear timeouts) inside the close handler. A heartbeat timer that outlives its socket is a classic memory leak that slowly eats a long-running server.Common Pitfalls & Best Practices
Pro Tip
Next Steps
Next Lesson
Production Deployment