MCP Transport Options: stdio vs SSE vs WebSocket
Complete guide to MCP transport options including stdio for local processes, Server-Sent Events for HTTP streaming, and WebSocket for bidirectional communication, covering implementation patterns, performance characteristics, and production deployment considerations.
MCP Transport Options: stdio vs SSE vs WebSocket
Overview
MCP servers communicate with clients through transports — the wire protocol that carries JSON-RPC messages between server and client. The transport you choose determines how your server is deployed, how it scales, and what kinds of clients can connect to it. stdio is the simplest and works for local tools. SSE streams data over HTTP for web-compatible scenarios. WebSocket provides full bidirectional communication for real-time applications. I have built MCP servers with all three and each has clear use cases where it excels.
Prerequisites
- Node.js 16 or later
@modelcontextprotocol/sdkpackage installed- Understanding of MCP server basics
- Express.js for HTTP-based transports
- Familiarity with JSON-RPC message format
- Basic understanding of HTTP streaming and WebSocket protocols
Transport Comparison
| Feature | stdio | SSE | WebSocket |
|---------------------|----------------|----------------|----------------|
| Deployment | Local process | HTTP server | HTTP server |
| Direction | Bidirectional | Server→Client | Bidirectional |
| Client→Server | stdin | HTTP POST | WS messages |
| Concurrent clients | 1 | Many | Many |
| Firewall friendly | N/A (local) | Yes (HTTP) | Usually |
| Browser compatible | No | Yes | Yes |
| Connection overhead | Process spawn | HTTP request | WS handshake |
| Best for | CLI tools | Web clients | Real-time apps |
stdio Transport
stdio is the default MCP transport. The client spawns the server as a child process and communicates through stdin/stdout.
How It Works
Client Process Server Process
| |
|--- spawn server process ------->|
| |
|--- stdin: JSON-RPC request ---->|
| |
|<-- stdout: JSON-RPC response ---|
| |
|--- stdin: JSON-RPC request ---->|
|<-- stdout: JSON-RPC response ---|
| |
|--- close stdin ---------------->|
| X (process exits)
Implementation
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var StdioTransport = require("@modelcontextprotocol/sdk/server/stdio.js").StdioServerTransport;
var server = new Server(
{ name: "stdio-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", function() {
return {
tools: [{
name: "echo",
description: "Echo a message back",
inputSchema: {
type: "object",
properties: {
message: { type: "string" }
},
required: ["message"]
}
}]
};
});
server.setRequestHandler("tools/call", function(request) {
if (request.params.name === "echo") {
return {
content: [{ type: "text", text: "Echo: " + request.params.arguments.message }]
};
}
throw new Error("Unknown tool");
});
// IMPORTANT: Use stderr for logging, not stdout
// stdout is reserved for JSON-RPC messages
console.error("Starting stdio MCP server...");
var transport = new StdioTransport();
server.connect(transport).then(function() {
console.error("Server connected via stdio");
});
Client Configuration
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["./mcp-server.js"],
"env": {
"DATABASE_URL": "postgresql://..."
}
}
}
}
stdio Advantages and Limitations
// Advantages:
// 1. Zero network configuration — no ports, no firewalls
// 2. Process isolation — server runs in its own process
// 3. Simple deployment — just a script file
// 4. Secure by default — no network exposure
// Limitations:
// 1. Single client — one process, one connection
// 2. Local only — client and server must be on the same machine
// 3. No web browsers — cannot use from web applications
// 4. Process overhead — each connection spawns a new process
// Common pitfall: accidentally writing to stdout
// This breaks the JSON-RPC protocol
console.log("Hello"); // WRONG - this goes to stdout
console.error("Hello"); // RIGHT - this goes to stderr
Handling stdout Pollution
// Redirect any accidental stdout writes
var originalStdoutWrite = process.stdout.write;
var jsonRpcPattern = /^\{.*"jsonrpc"/;
process.stdout.write = function(chunk) {
var str = typeof chunk === "string" ? chunk : chunk.toString();
// Only allow JSON-RPC messages through stdout
if (jsonRpcPattern.test(str.trim())) {
return originalStdoutWrite.apply(process.stdout, arguments);
}
// Redirect everything else to stderr
return process.stderr.write(chunk);
};
SSE (Server-Sent Events) Transport
SSE uses HTTP for client-to-server messages and a persistent HTTP stream for server-to-client messages.
How It Works
Client Server (HTTP)
| |
|--- GET /sse (open stream) --------->|
|<--- event: endpoint |
| data: /messages?sessionId=abc |
| |
|--- POST /messages (JSON-RPC) ------>|
|<--- SSE event: JSON-RPC response ---|
| |
|--- POST /messages (JSON-RPC) ------>|
|<--- SSE event: JSON-RPC response ---|
| |
|<--- SSE event: notification --------|
| |
|--- close connection ---------------->|
Implementation
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var SSETransport = require("@modelcontextprotocol/sdk/server/sse.js").SSEServerTransport;
var express = require("express");
var app = express();
app.use(express.json());
var server = new Server(
{ name: "sse-server", version: "1.0.0" },
{ capabilities: { tools: {}, resources: {} } }
);
// Register tools and resources (same as stdio)
server.setRequestHandler("tools/list", function() {
return {
tools: [{
name: "get-time",
description: "Get the current server time",
inputSchema: { type: "object", properties: {} }
}]
};
});
server.setRequestHandler("tools/call", function(request) {
if (request.params.name === "get-time") {
return {
content: [{ type: "text", text: new Date().toISOString() }]
};
}
throw new Error("Unknown tool");
});
// Track active sessions
var sessions = {};
// SSE endpoint - client connects here for the event stream
app.get("/sse", function(req, res) {
var transport = new SSETransport("/messages", res);
var sessionId = transport.sessionId;
sessions[sessionId] = { transport: transport, connectedAt: new Date() };
console.log("Client connected: " + sessionId + " (total: " + Object.keys(sessions).length + ")");
// Clean up on disconnect
res.on("close", function() {
delete sessions[sessionId];
console.log("Client disconnected: " + sessionId);
});
server.connect(transport);
});
// Message endpoint - client sends JSON-RPC requests here
app.post("/messages", function(req, res) {
var sessionId = req.query.sessionId;
var session = sessions[sessionId];
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
session.transport.handlePostMessage(req, res);
});
// Health check
app.get("/health", function(req, res) {
res.json({
status: "ok",
activeSessions: Object.keys(sessions).length,
uptime: process.uptime()
});
});
var PORT = process.env.PORT || 3001;
app.listen(PORT, function() {
console.log("MCP SSE server running on http://localhost:" + PORT);
console.log(" SSE endpoint: GET /sse");
console.log(" Message endpoint: POST /messages?sessionId=xxx");
});
SSE Client Connection
var http = require("http");
// Simple SSE client for testing
function connectToSSEServer(serverUrl) {
return new Promise(function(resolve, reject) {
http.get(serverUrl + "/sse", function(res) {
var messageEndpoint = null;
res.on("data", function(chunk) {
var lines = chunk.toString().split("\n");
lines.forEach(function(line) {
if (line.startsWith("event: endpoint")) {
// Next data line has the endpoint
} else if (line.startsWith("data: ") && !messageEndpoint) {
messageEndpoint = line.slice(6).trim();
console.log("Message endpoint: " + messageEndpoint);
resolve({ messageEndpoint: serverUrl + messageEndpoint, eventStream: res });
} else if (line.startsWith("data: ")) {
try {
var message = JSON.parse(line.slice(6));
console.log("Received:", JSON.stringify(message, null, 2));
} catch (e) {
// Not JSON, skip
}
}
});
});
res.on("error", reject);
}).on("error", reject);
});
}
// Send a JSON-RPC request
function sendRequest(messageEndpoint, method, params) {
var postData = JSON.stringify({
jsonrpc: "2.0",
id: Date.now(),
method: method,
params: params || {}
});
var url = new URL(messageEndpoint);
return new Promise(function(resolve, reject) {
var req = http.request({
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(postData)
}
}, function(res) {
var body = "";
res.on("data", function(chunk) { body += chunk; });
res.on("end", function() { resolve(body); });
});
req.on("error", reject);
req.write(postData);
req.end();
});
}
WebSocket Transport
WebSocket provides full bidirectional communication over a single persistent connection.
Implementation
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var WebSocket = require("ws");
var http = require("http");
// Custom WebSocket transport
function WebSocketTransport(ws) {
this.ws = ws;
this._messageHandler = null;
this._closeHandler = null;
var self = this;
ws.on("message", function(data) {
try {
var message = JSON.parse(data.toString());
if (self._messageHandler) {
self._messageHandler(message);
}
} catch (e) {
console.error("Invalid JSON message:", e.message);
}
});
ws.on("close", function() {
if (self._closeHandler) {
self._closeHandler();
}
});
ws.on("error", function(err) {
console.error("WebSocket error:", err.message);
});
}
WebSocketTransport.prototype.start = function() {
return Promise.resolve();
};
WebSocketTransport.prototype.send = function(message) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
return Promise.resolve();
};
WebSocketTransport.prototype.close = function() {
this.ws.close();
return Promise.resolve();
};
WebSocketTransport.prototype.onMessage = function(handler) {
this._messageHandler = handler;
};
WebSocketTransport.prototype.onClose = function(handler) {
this._closeHandler = handler;
};
// Create HTTP server with WebSocket upgrade
var httpServer = http.createServer(function(req, res) {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", connections: wss.clients.size }));
} else {
res.writeHead(404);
res.end("Not found");
}
});
var wss = new WebSocket.Server({ server: httpServer });
wss.on("connection", function(ws, req) {
console.log("WebSocket client connected from " + req.socket.remoteAddress);
var mcpServer = new Server(
{ name: "ws-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Register handlers for this connection
mcpServer.setRequestHandler("tools/list", function() {
return {
tools: [{
name: "ping",
description: "Ping the server",
inputSchema: { type: "object", properties: {} }
}]
};
});
mcpServer.setRequestHandler("tools/call", function(request) {
if (request.params.name === "ping") {
return {
content: [{ type: "text", text: "pong at " + new Date().toISOString() }]
};
}
throw new Error("Unknown tool");
});
var transport = new WebSocketTransport(ws);
mcpServer.connect(transport);
ws.on("close", function() {
console.log("Client disconnected");
});
});
var PORT = process.env.PORT || 3002;
httpServer.listen(PORT, function() {
console.log("MCP WebSocket server on ws://localhost:" + PORT);
});
WebSocket Client
var WebSocket = require("ws");
function connectWebSocketMCP(url) {
return new Promise(function(resolve, reject) {
var ws = new WebSocket(url);
var requestId = 0;
var pending = {};
ws.on("open", function() {
console.log("Connected to MCP WebSocket server");
var client = {
request: function(method, params) {
return new Promise(function(resolve, reject) {
var id = ++requestId;
pending[id] = { resolve: resolve, reject: reject };
ws.send(JSON.stringify({
jsonrpc: "2.0",
id: id,
method: method,
params: params || {}
}));
// Timeout after 30 seconds
setTimeout(function() {
if (pending[id]) {
pending[id].reject(new Error("Request timeout"));
delete pending[id];
}
}, 30000);
});
},
close: function() { ws.close(); }
};
resolve(client);
});
ws.on("message", function(data) {
var msg = JSON.parse(data.toString());
if (msg.id && pending[msg.id]) {
if (msg.error) {
pending[msg.id].reject(new Error(msg.error.message));
} else {
pending[msg.id].resolve(msg.result);
}
delete pending[msg.id];
}
});
ws.on("error", reject);
});
}
// Usage
connectWebSocketMCP("ws://localhost:3002")
.then(function(client) {
return client.request("tools/list");
})
.then(function(result) {
console.log("Tools:", JSON.stringify(result, null, 2));
});
Choosing the Right Transport
// Decision matrix for transport selection
function recommendTransport(requirements) {
var scores = { stdio: 0, sse: 0, websocket: 0 };
// Deployment model
if (requirements.deployment === "local-cli") {
scores.stdio += 3;
} else if (requirements.deployment === "web-service") {
scores.sse += 2;
scores.websocket += 2;
} else if (requirements.deployment === "cloud") {
scores.sse += 3; // Better for load balancers
scores.websocket += 1;
}
// Client type
if (requirements.client === "desktop-app") {
scores.stdio += 3;
} else if (requirements.client === "web-browser") {
scores.sse += 2;
scores.websocket += 3;
} else if (requirements.client === "server-to-server") {
scores.websocket += 2;
scores.sse += 1;
}
// Concurrent clients
if (requirements.concurrentClients === 1) {
scores.stdio += 2;
} else if (requirements.concurrentClients <= 100) {
scores.sse += 2;
scores.websocket += 2;
} else {
scores.sse += 3; // SSE scales better behind load balancers
scores.websocket += 1;
}
// Notifications needed
if (requirements.serverNotifications) {
scores.sse += 2;
scores.websocket += 3;
scores.stdio += 1;
}
// Simplicity preference
if (requirements.preferSimple) {
scores.stdio += 2;
scores.sse += 1;
}
var recommendation = Object.keys(scores).reduce(function(best, transport) {
return scores[transport] > scores[best] ? transport : best;
}, "stdio");
return {
recommendation: recommendation,
scores: scores,
rationale: getTransportRationale(recommendation, requirements)
};
}
function getTransportRationale(transport, req) {
var rationales = {
stdio: "stdio is ideal for " + req.deployment + " with " + req.client + " clients. "
+ "No network overhead, simple deployment, process isolation.",
sse: "SSE works well for " + req.deployment + " supporting " + req.concurrentClients + " clients. "
+ "HTTP-compatible, firewall-friendly, good load balancer support.",
websocket: "WebSocket is best for " + req.client + " needing real-time bidirectional communication. "
+ "Low latency, full duplex, native browser support."
};
return rationales[transport];
}
// Example
var result = recommendTransport({
deployment: "web-service",
client: "web-browser",
concurrentClients: 50,
serverNotifications: true,
preferSimple: false
});
console.log("Recommended: " + result.recommendation);
console.log("Scores:", result.scores);
console.log("Rationale: " + result.rationale);
Complete Working Example: Multi-Transport MCP Server
A single MCP server that supports all three transports:
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var StdioTransport = require("@modelcontextprotocol/sdk/server/stdio.js").StdioServerTransport;
var SSETransport = require("@modelcontextprotocol/sdk/server/sse.js").SSEServerTransport;
var express = require("express");
var WebSocket = require("ws");
var http = require("http");
// ============================================================
// Multi-Transport MCP Server
// Supports stdio, SSE, and WebSocket connections simultaneously
// ============================================================
// Shared tool/resource definitions
function registerHandlers(server) {
server.setRequestHandler("tools/list", function() {
return {
tools: [
{
name: "echo",
description: "Echo a message",
inputSchema: {
type: "object",
properties: { message: { type: "string" } },
required: ["message"]
}
},
{
name: "server-info",
description: "Get server information",
inputSchema: { type: "object", properties: {} }
}
]
};
});
server.setRequestHandler("tools/call", function(request) {
switch (request.params.name) {
case "echo":
return { content: [{ type: "text", text: request.params.arguments.message }] };
case "server-info":
return {
content: [{
type: "text",
text: JSON.stringify({
uptime: process.uptime(),
memory: process.memoryUsage(),
nodeVersion: process.version,
transport: server._transportType || "unknown"
}, null, 2)
}]
};
default:
throw new Error("Unknown tool: " + request.params.name);
}
});
}
// Determine transport mode from arguments
var mode = process.argv[2] || "stdio";
if (mode === "stdio") {
// ---- stdio mode ----
var server = new Server(
{ name: "multi-transport", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server._transportType = "stdio";
registerHandlers(server);
var transport = new StdioTransport();
server.connect(transport).then(function() {
console.error("MCP server running (stdio mode)");
});
} else if (mode === "sse" || mode === "http") {
// ---- SSE mode ----
var app = express();
app.use(express.json());
var sessions = {};
app.get("/sse", function(req, res) {
var server = new Server(
{ name: "multi-transport", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server._transportType = "sse";
registerHandlers(server);
var sseTransport = new SSETransport("/messages", res);
sessions[sseTransport.sessionId] = sseTransport;
res.on("close", function() {
delete sessions[sseTransport.sessionId];
});
server.connect(sseTransport);
});
app.post("/messages", function(req, res) {
var sessionId = req.query.sessionId;
if (!sessions[sessionId]) {
res.status(404).json({ error: "Session not found" });
return;
}
sessions[sessionId].handlePostMessage(req, res);
});
app.get("/health", function(req, res) {
res.json({ status: "ok", mode: "sse", sessions: Object.keys(sessions).length });
});
var PORT = process.env.PORT || 3001;
app.listen(PORT, function() {
console.log("MCP server running (SSE mode) on port " + PORT);
});
} else if (mode === "websocket" || mode === "ws") {
// ---- WebSocket mode ----
var httpServer = http.createServer(function(req, res) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", mode: "websocket" }));
});
var wss = new WebSocket.Server({ server: httpServer });
wss.on("connection", function(ws) {
var server = new Server(
{ name: "multi-transport", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server._transportType = "websocket";
registerHandlers(server);
// Create WebSocket transport adapter
var wsTransport = {
start: function() { return Promise.resolve(); },
send: function(msg) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
return Promise.resolve();
},
close: function() { ws.close(); return Promise.resolve(); },
onMessage: function(handler) {
ws.on("message", function(data) {
try { handler(JSON.parse(data.toString())); } catch (e) { /* skip */ }
});
},
onClose: function(handler) { ws.on("close", handler); }
};
server.connect(wsTransport);
});
var PORT = process.env.PORT || 3002;
httpServer.listen(PORT, function() {
console.log("MCP server running (WebSocket mode) on port " + PORT);
});
} else {
console.error("Unknown mode: " + mode);
console.error("Usage: node server.js [stdio|sse|websocket]");
process.exit(1);
}
Usage:
# stdio mode (default)
node server.js stdio
# SSE mode
node server.js sse
# WebSocket mode
node server.js websocket
Common Issues & Troubleshooting
stdio: "SyntaxError: Unexpected token" from Server
Anything written to stdout that is not valid JSON-RPC breaks the protocol:
SyntaxError: Unexpected token 'S' at position 0
"Server started successfully" ← This went to stdout
Fix: Use console.error() for all logging. Or redirect stdout at startup.
SSE: Connection Drops After 30 Seconds Behind Proxy
Reverse proxies (nginx, cloudflare) timeout idle SSE connections:
# nginx config for SSE
location /sse {
proxy_pass http://mcp-server:3001;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400; # 24 hours
}
WebSocket: "Connection refused" in Production
WebSocket requires explicit proxy configuration:
# nginx config for WebSocket
location /ws {
proxy_pass http://mcp-server:3002;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
SSE: Multiple Clients Get Each Other's Responses
Each SSE client needs its own server instance. A common mistake is sharing a single server across sessions:
// WRONG: Single server for all clients
var server = new Server(...);
app.get("/sse", function(req, res) {
server.connect(new SSETransport("/messages", res)); // Conflicts!
});
// RIGHT: New server per client
app.get("/sse", function(req, res) {
var server = new Server(...);
registerHandlers(server);
server.connect(new SSETransport("/messages", res));
});
Best Practices
- Default to stdio for CLI and desktop integrations — It requires zero infrastructure, zero network configuration, and provides perfect isolation between clients.
- Use SSE for web-accessible MCP servers — SSE works through firewalls, load balancers, and CDNs without special configuration. The split between POST for requests and SSE for responses maps cleanly to HTTP semantics.
- Use WebSocket only when you need low-latency bidirectional streaming — WebSocket adds complexity. Unless you need sub-millisecond server-to-client notifications or high-frequency bidirectional messages, SSE is simpler.
- Never write non-JSON-RPC data to stdout in stdio mode — Use
console.error()for all logging. Consider redirecting stdout at process start as a safety net. - Create a new server instance per connection for HTTP transports — Sharing a server instance between clients causes response routing failures. Each connection should have its own server and transport.
- Implement health check endpoints for HTTP transports — Load balancers and monitoring systems need a way to verify the server is running. Add a
/healthendpoint that returns connection counts and uptime. - Set appropriate timeouts for each transport — stdio: process-level timeout. SSE: keep-alive every 30 seconds to prevent proxy disconnects. WebSocket: ping/pong every 30 seconds.
- Build your server to support multiple transports — Factor tool and resource handlers into shared functions. Switch transport based on a command-line argument or environment variable. This lets you develop with stdio and deploy with SSE.