Guide · Transport
MCP server WebSockets
MCP does not use WebSockets. This surprises developers who expect a bidirectional protocol for an interactive agent-to-server connection. The MCP specification uses HTTP with Server-Sent Events (SSE) for the server-to-client streaming direction — specifically the StreamableHTTP transport, where the client sends JSON-RPC requests as HTTP POSTs and the server streams progress notifications and responses as SSE events on a long-lived GET connection. Understanding why MCP chose this design — and what it means for proxies, monitoring, and integrating WebSocket backends into MCP tools — is essential for operating a production MCP server correctly.
TL;DR
MCP uses HTTP+SSE (StreamableHTTP), not WebSockets. Clients POST requests; servers stream responses via SSE on GET. The transport is HTTP/1.1 and HTTP/2 compatible, works through any standard reverse proxy (Caddy, nginx, AWS ALB) with one configuration change per proxy (flush_interval: -1 in Caddy; proxy_buffering off in nginx), and does not require WebSocket upgrade handling. WebSockets appear in MCP infrastructure only when a tool handler uses a WebSocket client to connect to a WebSocket backend service — this is a tool implementation detail, not an MCP transport concern. AliveMCP probes MCP servers by sending a real initialize JSON-RPC POST request and verifying the response, not by monitoring WebSocket handshakes — because there are no WebSocket handshakes in standard MCP.
How StreamableHTTP works
The StreamableHTTP transport uses two HTTP routes on the same server:
| Method | Path | Purpose | Body |
|---|---|---|---|
POST | /mcp | Client sends JSON-RPC request to server | JSON-RPC object |
GET | /mcp | Server pushes notifications to client via SSE | SSE event stream |
DELETE | /mcp | Client explicitly closes the session | Empty |
The mcp-session-id header correlates POSTs with the GET SSE stream. A client opens a GET connection to receive server-initiated notifications (progress updates, tool list changes), then sends all requests as individual POSTs. The session is symmetric in that both parties can initiate communication — the client via POST, the server via the SSE stream — but the transport itself is plain HTTP, not a WebSocket.
// Express StreamableHTTP setup — two routes, same path, different methods
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const app = express();
const sessions = new Map<string, { server: McpServer; transport: StreamableHTTPServerTransport }>();
// POST /mcp — handle incoming requests and initialize new sessions
app.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
if (sessionId && sessions.has(sessionId)) {
// Existing session — route to existing transport
const { transport } = sessions.get(sessionId)!;
await transport.handleRequest(req, res);
} else {
// New session — create server + transport
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new StreamableHTTPServerTransport({ sessionIdHeader: 'mcp-session-id' });
res.on('close', () => sessions.delete(transport.sessionId!));
await server.connect(transport);
sessions.set(transport.sessionId!, { server, transport });
await transport.handleRequest(req, res);
}
});
// GET /mcp — SSE stream for server-to-client notifications
app.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
const session = sessions.get(sessionId);
if (!session) return res.status(404).end();
await session.transport.handleRequest(req, res);
});
Proxy configuration for SSE
The most common operational issue with MCP servers is a reverse proxy that buffers the SSE response before forwarding it to the client — the client waits for the entire response instead of receiving events as they arrive. Each proxy requires one change:
| Proxy | Required setting | Where to add it |
|---|---|---|
| Caddy | flush_interval -1 | In the reverse_proxy block for the /mcp route |
| nginx | proxy_buffering off; | In the location /mcp block |
| AWS ALB | No change required | ALB passes through chunked responses by default |
| Cloudflare | Max 100s timeout on free/pro plans | Use keep-alive ping pattern (see below) or upgrade to Enterprise |
| Kubernetes Ingress (nginx) | nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" | Ingress annotation |
For Cloudflare's 100-second proxy timeout on SSE streams, send a keep-alive comment event at a cadence shorter than the timeout:
// Keep-alive ping for long-lived SSE streams behind Cloudflare
setInterval(() => {
for (const [sessionId, { transport }] of sessions) {
transport.sendPing?.(); // SDK method if available, or write raw SSE comment
}
}, 90_000); // every 90 seconds — under Cloudflare's 100s limit
These are the same infrastructure requirements as any other SSE-based service (stock tickers, live dashboards). If you have existing SSE infrastructure, your MCP server reuses the same configuration. If this is your first SSE service, the AliveMCP streaming guide has the full configuration table for each proxy.
Using WebSocket backends inside MCP tool handlers
Some services you want to expose as MCP tools use WebSocket APIs — real-time trading feeds, live collaborative editing, game server state. The MCP transport being HTTP+SSE does not prevent you from connecting to these services from inside a tool handler. The WebSocket connection is a detail of the tool's implementation, not the MCP transport:
// Tool handler that connects to a WebSocket backend
import WebSocket from 'ws';
import { z } from 'zod';
server.tool(
'get_live_price',
'Get the current live price of a ticker symbol',
{ symbol: z.string().toUpperCase() },
async ({ symbol }) => {
return new Promise((resolve) => {
const ws = new WebSocket('wss://feeds.example.com/prices');
const timeout = setTimeout(() => {
ws.close();
resolve({ isError: true, content: [{ type: 'text', text: 'Price feed timeout' }] });
}, 5_000);
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.symbol === symbol) {
clearTimeout(timeout);
ws.close();
resolve({ content: [{ type: 'text', text: JSON.stringify(msg) }] });
}
});
ws.on('error', (err) => {
clearTimeout(timeout);
resolve({ isError: true, content: [{ type: 'text', text: `WebSocket error: ${err.message}` }] });
});
});
}
);
Two things to note: (1) open and close the WebSocket connection inside the tool handler — do not hold a persistent WebSocket connection at module scope per-tool, as this creates N open connections for N concurrent sessions even when no tool calls are active; (2) always set a timeout and handle the error case as isError: true, not a thrown exception, so the session stays open if the WebSocket backend is down.
Why MCP chose HTTP+SSE over WebSockets
HTTP+SSE has three operational advantages over WebSockets for the MCP use case: (1) standard HTTP request-response semantics mean each tool call has an obvious correlation between request and response, making tracing and logging straightforward; (2) HTTP load balancers distribute POST requests across instances without sticky routing — only the GET SSE stream requires session affinity (usually solved with mcp-session-id header routing), whereas WebSockets require per-connection routing for all messages; (3) HTTP is universally supported by infrastructure tooling — health checks, rate limiters, auth middleware, and observability tools all work on HTTP requests natively. AliveMCP's probe uses standard POST /mcp with a JSON-RPC initialize body — the same request a real client sends — because HTTP makes that trivially implementable without a WebSocket client.
Related questions
Does the MCP spec include a WebSocket transport option?
The MCP 2025-03 specification defines StreamableHTTP as the primary transport and stdio as the local transport. There is no WebSocket transport in the current spec. An earlier version of the SDK included an experimental SSE-only transport (not StreamableHTTP) that has since been superseded. Check the @modelcontextprotocol/sdk changelog if you are upgrading from a pre-2025 version — the transport API changed.
Can I wrap a WebSocket-only service as an MCP server?
Yes. Write a thin adapter: an Express HTTP server with the StreamableHTTP transport, where tool handlers open a WebSocket connection to the underlying service, make the request, close the WebSocket, and return the result. This pattern converts any WebSocket service into an MCP-compatible tool surface. The MCP client sees HTTP; the upstream service sees WebSocket. The adapter is the translation layer.
Do WebSocket keep-alive pings conflict with MCP session keep-alives?
No — they operate on different connections. WebSocket keep-alive pings are sent on the WebSocket connection between your tool handler and the upstream service. MCP session keep-alives (SSE comment events) are sent on the SSE GET connection between your MCP server and the MCP client. They are independent connections with independent keep-alive intervals.
How does AliveMCP probe a StreamableHTTP server?
AliveMCP sends a POST /mcp request with a JSON-RPC initialize body, including the correct Content-Type: application/json header, and verifies that the response is a valid initialize result with the expected shape. It then sends tools/list and verifies a non-empty, schema-valid tool array. Both requests are HTTP POST — no WebSocket connection, no SSE stream. The probe completes in milliseconds and measures the round-trip latency of a real client operation.
Further reading
- MCP server streaming — SSE infrastructure requirements and progress notifications
- MCP server SDK — StreamableHTTP transport internals and session lifecycle
- MCP server error handling — handling WebSocket backend failures as isError results
- MCP server timeout — timeout configuration for tool calls including WebSocket backends
- MCP server health check — how initialize-based probes work without WebSocket
- AliveMCP — HTTP-based MCP server monitoring with initialize + tools/list verification