Mcp

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/sdk package 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 /health endpoint 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.

References

Powered by Contentful