Guide · Real-time / WebSocket
MCP Tools for Socket.IO — multi-node adapter, connection recovery, namespaces, acknowledgements
Three Socket.IO behaviours catch developers building MCP real-time tools: Socket.IO is not WebSocket — raw WebSocket clients cannot connect to a Socket.IO server and vice versa; the protocol starts with an HTTP polling handshake (EIO=4 negotiation) before upgrading, so clients using plain WebSocket or libraries like ws will fail to connect; you must use the official Socket.IO client library even if you configure transports: ['websocket'] to skip polling after the initial handshake; multi-node deployments require sticky sessions or a Redis adapter — without either, a client's CONNECT event on node A and subsequent SUBSCRIBE on node B land on different in-memory socket registries and rooms; io.to(room).emit() on node B cannot see sockets connected to node A, producing silently dropped messages; and acknowledgements never time out by default — if the receiving client disconnects before calling the callback, the acknowledgement callback on the server side never fires, hanging indefinitely; use socket.timeout(ms).emit(...) (Socket.IO v4.6+) to add a deadline or the ack callback becomes a permanent memory leak for disconnected clients.
TL;DR
Server emit: io.to(room).emit('event', data). Single-socket emit with ack: socket.timeout(5000).emit('event', data, (err, response) => {}). Auth middleware: io.use((socket, next) => { if (validToken(socket.handshake.auth.token)) next(); else next(new Error('unauthorized')); }). Multi-node: add @socket.io/redis-adapter. Connection recovery (v4.6+): new Server({ connectionStateRecovery: { maxDisconnectionDuration: 120_000 } }).
Server setup and transport configuration
Socket.IO wraps a Node.js HTTP server and manages the upgrade from HTTP polling to WebSocket. For MCP servers that expose Socket.IO over an existing Express or Hono HTTP server, pass the http.Server instance to the Socket.IO constructor. The CORS configuration in Socket.IO must match your client's origin — Socket.IO's CORS is separate from any HTTP-level CORS middleware you may already have.
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import express from 'express';
const app = express();
const httpServer = createServer(app);
const io = new SocketIOServer(httpServer, {
cors: {
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? ['http://localhost:3000'],
methods: ['GET', 'POST'],
credentials: true,
},
// Performance tuning for MCP workloads:
pingTimeout: 20_000, // ms before considering a non-responsive client disconnected
pingInterval: 25_000, // ms between server pings to detect stale connections
upgradeTimeout: 10_000, // ms to wait for WebSocket upgrade (long-polling fallback after)
maxHttpBufferSize: 1e6, // 1MB max per message; raise if MCP tools send large results
// Connection state recovery (Socket.IO v4.6+)
// Buffers missed events for reconnecting clients for up to maxDisconnectionDuration
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000, // 2 minutes
skipMiddlewares: true, // don't re-run auth middleware on recovery (token already validated)
},
});
httpServer.listen(parseInt(process.env.PORT ?? '3000', 10));
Setting transports: ['websocket'] on the client skips the HTTP polling phase but still uses the Socket.IO framing protocol — it is NOT the same as a raw WebSocket connection. The Socket.IO protocol adds EIO=4 packet encoding on top of WebSocket frames, which is why plain WebSocket clients see garbled data or connection refusals from Socket.IO servers.
Authentication middleware
Socket.IO middleware runs before the socket is admitted to the server and before any event handlers fire. It is the correct place to validate JWT tokens, API keys, or session cookies. Middleware errors surface on the client as connect_error events with the error message — they are not silent rejections.
import jwt from 'jsonwebtoken';
// Auth middleware — runs once per connection attempt
io.use(async (socket, next) => {
// Clients pass auth data via: socket = io(url, { auth: { token: 'Bearer ...' } })
const token = socket.handshake.auth?.token as string | undefined;
if (!token || !token.startsWith('Bearer ')) {
return next(new Error('Missing or invalid Authorization token'));
}
try {
const payload = jwt.verify(
token.replace('Bearer ', ''),
process.env.JWT_SECRET!
) as { sub: string; sessionId: string };
// Attach verified identity to socket for use in event handlers
socket.data.userId = payload.sub;
socket.data.sessionId = payload.sessionId;
next();
} catch (err) {
// The error message is sent to the client as connect_error.message
next(new Error('Token expired or invalid'));
}
});
// Event handler has access to socket.data after middleware runs
io.on('connection', (socket) => {
const { userId, sessionId } = socket.data;
console.log(`User ${userId} connected (session: ${sessionId})`);
// Auto-join personal room on connection
socket.join(`user:${userId}`);
// Emit to a specific user's room from any event handler or MCP tool
// io.to(`user:${userId}`).emit('notification', { msg: '...' });
});
Namespace-level middleware (added via io.of('/admin').use(...)) applies only to connections on that namespace. Root namespace middleware (io.use(...)) applies to all namespaces. If you need different auth policies per namespace, add middleware on each namespace separately — root middleware does not propagate to dynamic namespaces.
Rooms — isolation and membership
Rooms are server-side grouping mechanisms — they exist only in the Socket.IO server's memory and have no client-visible representation. Each socket automatically belongs to a room named after its own socket ID. Emitting to a room from server code broadcasts to all sockets currently in that room, across all nodes if using a Redis adapter.
io.on('connection', (socket) => {
const { userId } = socket.data;
// Join rooms programmatically (server-controlled)
socket.join(`user:${userId}`); // personal room
socket.join('broadcast:all'); // global room
// Clients can request room membership (server validates)
socket.on('join-session', async (sessionId: string, callback) => {
// Validate the user has permission to join this session
const allowed = await canUserJoinSession(userId, sessionId);
if (!allowed) {
callback({ error: 'Permission denied' });
return;
}
socket.join(`session:${sessionId}`);
callback({ ok: true, roomCount: io.sockets.adapter.rooms.get(`session:${sessionId}`)?.size });
});
socket.on('leave-session', (sessionId: string) => {
socket.leave(`session:${sessionId}`);
});
socket.on('disconnecting', () => {
// socket.rooms contains the rooms the socket is about to leave
// This is the last chance to clean up before the socket is removed
const rooms = [...socket.rooms].filter((r) => r !== socket.id);
console.log(`User ${userId} disconnecting from rooms:`, rooms);
});
});
// Server-side: emit to everyone in a session
function broadcastToSession(sessionId: string, event: string, data: unknown) {
io.to(`session:${sessionId}`).emit(event, data);
}
// Server-side: emit to everyone EXCEPT one socket (useful for relaying user actions)
function broadcastExcept(room: string, excludeSocketId: string, event: string, data: unknown) {
io.to(room).except(excludeSocketId).emit(event, data);
}
Acknowledgements and delivery guarantees
Socket.IO acknowledgements provide a request-response pattern over WebSocket: the emitter passes a callback as the last argument, and the receiver calls the callback to confirm processing. Without a timeout, the ack callback never fires if the receiver disconnects — the callback closure is held in memory indefinitely. Always add a timeout when the ack response is required for correctness.
// Server emits with acknowledgement timeout (Socket.IO v4.6+)
async function emitWithAck(
socket: import('socket.io').Socket,
event: string,
data: unknown,
timeoutMs = 5000
): Promise {
return new Promise((resolve, reject) => {
socket.timeout(timeoutMs).emit(event, data, (err: Error | null, response: unknown) => {
if (err) {
// err is non-null if timeout elapsed without ack
reject(new Error(`Ack timeout after ${timeoutMs}ms for event "${event}"`));
} else {
resolve(response);
}
});
});
}
// Client-side acknowledgement handler (on the receiving end)
// socket.on('tool-result', (data, callback) => {
// processResult(data);
// callback({ received: true, processedAt: Date.now() });
// });
// Emit to a room with acknowledgement from ALL members (Socket.IO v4.6+)
async function emitToRoomWithAcks(
room: string,
event: string,
data: unknown,
timeoutMs = 5000
): Promise
Multi-node deployment with Redis adapter
In a multi-node Socket.IO deployment (multiple Node.js processes behind a load balancer), sockets connected to different processes cannot see each other through the in-memory room registry. The @socket.io/redis-adapter uses Redis pub/sub to broadcast events across nodes so that io.to(room).emit() reaches all sockets in the room regardless of which node they are connected to.
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
async function setupRedisAdapter(io: SocketIOServer) {
// Two separate Redis connections required — one pub, one sub
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
console.log('Socket.IO Redis adapter active');
}
// Even with Redis adapter, sticky sessions are RECOMMENDED for the upgrade handshake.
// Without sticky sessions, the polling handshake may hit node A but the WebSocket
// upgrade hits node B — node B doesn't recognise the session ID and returns 400.
// Configure sticky sessions at the load balancer level:
// - Nginx: ip_hash; or hash $cookie_io_session_id;
// - AWS ALB: enable sticky sessions on the target group
// - For stateless deployments, disable polling transport entirely:
// new Server({ transports: ['websocket'] }) — forces direct WS without sticky sessions
// Admin namespace (separate from main io) for server-to-server MCP tool calls
const adminNamespace = io.of('/admin');
adminNamespace.use(adminAuthMiddleware);
adminNamespace.on('connection', (socket) => {
socket.on('broadcast-all', (event, data) => {
// Broadcast to all clients on the root namespace from admin namespace
io.emit(event, data);
});
});
An alternative to the Redis adapter for ephemeral workloads is @socket.io/cluster-adapter (for Node.js cluster mode on a single machine) or deploying Socket.IO as a single instance behind a reverse proxy. For multi-region deployments, the Redis adapter latency is bounded by your Redis cluster latency between regions — expect 20-80ms for cross-region room emissions.
Connection state recovery (v4.6+)
Socket.IO v4.6 introduced server-side connection state recovery. When enabled, the server buffers events emitted to a socket during its disconnection window and replays them on reconnection. The client can detect whether recovery succeeded via the socket.recovered flag — if false, the client missed events and must re-fetch state from your application layer.
// Server: enable recovery with 2-minute buffer
const io = new SocketIOServer(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
});
io.on('connection', (socket) => {
if (socket.recovered) {
// Client reconnected and received all missed events automatically
// No need to re-fetch state from DB — recovery handled it
console.log(`Socket ${socket.id} recovered — no state re-sync needed`);
} else {
// Either first connection, or recovery window expired, or new session
// Must send current state to the client
console.log(`Socket ${socket.id} — full state sync required`);
sendFullStateToClient(socket);
}
});
async function sendFullStateToClient(socket: import('socket.io').Socket) {
const { userId } = socket.data;
const state = await loadUserState(userId);
socket.emit('full-state', state);
}
// Client-side check after connection:
// socket.on('connect', () => {
// if (!socket.recovered) {
// // Fetch any state updates missed during disconnection
// socket.emit('request-state-sync');
// }
// });
Connection state recovery buffers events in the server's adapter (in-memory by default, or in Redis if using the Redis adapter). The buffer is per-socket, not per-room — if you emit to a room and one member is temporarily disconnected, only their personal buffer stores the missed events. Recovery requires the client to reconnect with the same socket ID, which the Socket.IO client library handles automatically using the session cookie it received on first connection.
Health probe — server and adapter health
Socket.IO does not expose a built-in health endpoint — you must add one to your HTTP server alongside the Socket.IO upgrade path. A useful probe checks active socket count, room counts, and adapter connectivity (for Redis adapter deployments).
// Add health endpoint to your Express app (alongside Socket.IO)
app.get('/health', async (req, res) => {
const socketCount = await io.sockets.fetchSockets().then((s) => s.length);
// Probe adapter health (Redis adapter specific)
let adapterHealthy = true;
try {
// serverSideEmit is propagated through the adapter — if it returns, adapter is up
await io.timeout(2000).serverSideEmit('health-ping');
} catch {
adapterHealthy = false; // timeout indicates adapter or Redis is unresponsive
}
const health = {
healthy: adapterHealthy,
connectedSockets: socketCount,
adapterType: io.sockets.adapter.constructor.name,
adapterHealthy,
serverUptime: process.uptime(),
};
res.status(health.healthy ? 200 : 503).json(health);
});
// Listen for serverSideEmit health-ping (all nodes must register this)
io.on('ping-health', (callback: () => void) => {
callback();
});
// Alternative: expose Socket.IO admin UI for observability
// import { instrument } from '@socket.io/admin-ui';
// instrument(io, { auth: { type: 'basic', username: 'admin', password: 'secret' } });
AliveMCP monitors Socket.IO-backed MCP servers by probing the health HTTP endpoint and tracking WebSocket upgrade success rate. A high rate of failed upgrades (where clients fall back to long-polling) indicates a reverse proxy misconfiguration — Nginx requires proxy_http_version 1.1 and proxy_set_header Upgrade $http_upgrade for WebSocket pass-through; without these, Socket.IO silently degrades to polling with 3-5x higher latency.
Related guides
- MCP Tools for Ably — channel auth, connection state machine, presence, history recovery
- MCP Tools for Pusher — channel auth signatures, private/presence channels, rate limits
- MCP Tools for Centrifugo — subscription JWTs, history recovery, presence in clusters
- MCP Tools for Liveblocks — room auth, CRDT storage, presence and broadcast
- MCP server WebSocket patterns
- MCP server health check patterns