Real-time / WebSocket · 2026-07-24 · Real-time arc
MCP Tools for Real-time / WebSocket Systems: Authentication Spectrum, Connection State Machines, and Message Delivery Guarantees
Five real-time systems — Ably, Pusher Channels, Socket.IO, Centrifugo, and Liveblocks — all solve the same problem (pushing events from server to connected clients in real time) while making fundamentally different decisions about how access is authenticated, how connection state is managed, and what delivery guarantees the platform provides. These differences determine how an MCP server integrating with each system must be structured — and what breaks silently when those structures are misunderstood. Ably's token auth flow issues JWTs with channel-level capability grants, but the connection and channel state machines are independent: a connected connection can have channels in failed state due to a capability mismatch, and that failure does not surface on the connection's event stream — you must subscribe to channel.on('failed') separately. Pusher's channel auth signature is exact: the HMAC-SHA256 string for a presence channel is "${socket_id}:${channel_name}:${channel_data}" where channel_data is the raw JSON string — double-serializing it (calling JSON.stringify on an already-serialized string) produces a different signature and a silent 403 Forbidden with no diagnostic body. Socket.IO is not WebSocket: raw WebSocket clients fail to connect because Socket.IO uses an EIO=4 framing protocol layered over WebSocket — and in multi-node deployments, io.to(room).emit() reaches only sockets on the same node unless the Redis adapter is configured, silently dropping messages to users on other nodes. Centrifugo requires two separate JWT types — a connection JWT and a per-channel subscription JWT — and the subscription JWT must carry the exact channel string in a channel claim; a valid connection JWT cannot substitute for it, and a mismatched claim returns error code 103 on an HTTP 200 response that code checking only the status code will miss. Liveblocks CRDT types are wrappers, not plain objects — LiveObject.toObject() is not recursive, so nested LiveObject values inside a storage tree produce {} when passed to JSON.stringify, and the data loss is completely silent at the serialization point. This post covers all three patterns with working code for each system, a composite health probe comparison table, and a platform selection guide for MCP server real-time use cases.
TL;DR
Five real-time systems, three patterns. (1) Authentication model spectrum: Ably — JWT capability grants scoping per-channel operations; connection JWT ≠ channel auth; Ably initiates token renewal at 80% of TTL elapsed, not at expiry; authUrl or authCallback must respond in under 30s or connection drops to disconnected; Pusher — server-side HMAC-SHA256 auth endpoint per subscription; string-to-sign is exactly "${socket_id}:${channel_name}" for private channels and "${socket_id}:${channel_name}:${channel_data}" for presence channels where channel_data is the raw unserialized JSON string; Socket.IO — middleware-based JWT auth at handshake time; token in socket.handshake.auth.token; identity propagated via socket.data; namespace middleware does not propagate to dynamic namespaces; Centrifugo — two distinct JWT types sharing the same secret: connection JWT (sub only) and subscription JWT (sub + exact channel claim); subscription JWT refreshed via getToken callback at 75% of TTL elapsed independently of connection JWT; Liveblocks — server-signed room token (liveblocks.prepareSession(userId).allow(roomId, FULL_ACCESS).authorize()); token carries specific roomId — cannot enter a different room with the same token; userInfo attached to presence for all room members. (2) Connection state machines and reconnection: Ably — seven-state machine: initialized → connecting → connected → disconnected → suspended → failed → closing → closed; channel states are independent (connected connection can have failed channel); suspended means extended disconnection with history gaps; failed is permanent (new client instance required); Pusher — stateless HTTP trigger model; no persistent connection state in server SDK; client connection managed by Pusher.js; Socket.IO — client reconnects automatically; connection state recovery (v4.6+) buffers events during disconnection window; socket.recovered flag indicates whether recovery succeeded; multi-node requires sticky sessions plus Redis adapter or events are silently dropped; Centrifugo — subscription token expiry causes subscription disconnect (not connection disconnect); subscription re-enters failed state if getToken callback fails; history epoch tracks Centrifugo restarts so clients detect unrecoverable gaps; Liveblocks — rooms GC'd when no members for a period; reconnecting client may find storage in an inconsistent intermediate state; room.getStorage() resolves only after full sync completes. (3) Message delivery guarantees and history/recovery: Ably — at-least-once with client-side history buffer; channel.history({ untilAttach: true }) recovers exactly the gap since last attachment; resumed: false flag on state change signals that gap recovery is needed; presence re-sync required after gap; Pusher — at-most-once, fire-and-forget; no message history; no gap recovery; Socket.IO — acknowledgements provide per-message confirmation but require socket.timeout(ms).emit() to prevent permanent memory leaks; connection state recovery buffers in adapter; Centrifugo — epoch+offset gap recovery; epoch mismatch (Centrifugo restart) requires full application state fetch; history is node-local without Redis broker; Liveblocks — CRDT storage is conflict-free concurrent; presence state is persistent-ephemeral (survives while member is connected); broadcast is fire-and-forget (not stored, not replayed, sender excluded); storageUpdated webhook carries no diff — call getStorageDocument() for current state.
Pattern 1: The Authentication Model Spectrum
Real-time system authentication looks like a solved problem — every system has an API key or secret. But the authentication structures across these five platforms are fundamentally different: who generates the credential, what scope it carries, how it expires, and how it is renewed. Getting this wrong produces failures that vary from silent 403s with no diagnostic body (Pusher) to permanent connection failures (Ably) to silently dropped messages because a subscription is in failed state while the connection appears healthy (Centrifugo).
Ably: JWT Capability Grants and the Token Renewal Window
Ably's authentication separates the API key (held server-side) from the capability grants (carried by short-lived tokens issued to clients). The token carries a capability claim that restricts which channels and operations the holder can access. A token without capability for a specific channel produces a channel-level failure (error code 40160) — not a connection failure — and the failure only appears on channel.on('failed'), not on connection.on('failed').
import Ably from 'ably';
import jwt from 'jsonwebtoken';
// Server-side: issue a scoped Ably JWT with per-channel capability grants
function issueAblyToken(userId: string, channels: string[]): string {
const [appId, keyName] = process.env.ABLY_API_KEY!.split(':')[0].split('.');
const keySecret = process.env.ABLY_API_KEY!.split(':')[1];
const capability: Record<string, string[]> = {};
for (const ch of channels) {
capability[ch] = ['subscribe', 'publish', 'presence', 'history'];
}
return jwt.sign(
{ 'x-ably-capability': JSON.stringify(capability), 'x-ably-clientId': userId },
keySecret,
{ keyid: `${appId}.${keyName}`, expiresIn: '1h' }
// Ably initiates renewal at 80% elapsed (48 min for 1h token)
// Auth endpoint must respond in <30s or connection drops to 'disconnected'
);
}
// Client configuration — authUrl handles renewal automatically
const clientConfig: Ably.ClientOptions = {
authUrl: '/api/ably-token',
authMethod: 'POST',
authHeaders: { 'Authorization': 'Bearer YOUR_SESSION_TOKEN' },
};
The 80% renewal timing is a critical operational detail: if your auth endpoint is slow (database round-trips, cold starts on serverless), the Ably client may enter disconnected state before the renewal completes. Keep token issuance under 500ms. The connection and channel state machines being independent is equally important: always attach listeners to both connection.on() and channel.on() independently — a connected connection tells you nothing about the state of individual channels, especially for channels that require specific capability grants.
Pusher: Exact HMAC Signature Format and Presence Channel Pitfalls
Pusher channel authentication is entirely server-side: the client sends socket_id and channel_name to your auth endpoint, and you return a signed response. The signature format has no tolerance for variation — the string-to-sign is exact, and any deviation returns HTTP 403 with a body of {"error":"Invalid signature"} and no further detail. The most common failure point is presence channels, where channel_data must be included in the signing string exactly as it appears in the response — double-serialization (calling JSON.stringify on an already-serialized string) produces a mismatch because the second serialization adds escape characters that change the string.
import crypto from 'crypto';
function authenticatePresenceChannel(
socketId: string,
channelName: string,
userId: string,
userInfo: Record<string, unknown> = {}
): { auth: string; channel_data: string } {
// channel_data must be JSON-serialized exactly ONCE
const channelData = JSON.stringify({ user_id: userId, user_info: userInfo });
// The signing string appends channel_data as the raw string — NOT re-serialized
const stringToSign = `${socketId}:${channelName}:${channelData}`;
const signature = crypto
.createHmac('sha256', process.env.PUSHER_SECRET!)
.update(stringToSign)
.digest('hex');
return {
auth: `${process.env.PUSHER_KEY!}:${signature}`,
channel_data: channelData, // same string used for signing
};
}
The socket_id exclusion parameter deserves separate attention: passing socket_id: clientSocketId to pusher.trigger() excludes that specific client from receiving the event — it does not suppress the event for all clients. This is correct for echo-suppression (preventing a client from receiving its own actions) but becomes a confusing single-client test failure: if your test client triggers an event and passes its own socket_id, it receives nothing and appears to have a broken connection when the connection is fine.
Socket.IO: Middleware-Based Auth and the Namespace Boundary
Socket.IO authentication happens in middleware that runs before the socket is admitted to the server. The middleware has access to socket.handshake.auth (where clients pass tokens) and socket.handshake.headers. After middleware validation, identity is propagated via socket.data to all event handlers for that socket's lifetime. Middleware errors are not silent — they surface on the client as connect_error events — but there is a namespace scoping issue that catches developers.
import jwt from 'jsonwebtoken';
// Root namespace middleware — applies to ALL connections
io.use(async (socket, next) => {
const token = socket.handshake.auth?.token as string | undefined;
if (!token) return next(new Error('Missing auth token'));
try {
const payload = jwt.verify(token.replace('Bearer ', ''), process.env.JWT_SECRET!) as {
sub: string;
sessionId: string;
};
socket.data.userId = payload.sub;
socket.data.sessionId = payload.sessionId;
next();
} catch {
next(new Error('Token expired or invalid'));
}
});
// Namespace-level middleware — ONLY applies to /admin, not to root namespace
const adminNs = io.of('/admin');
adminNs.use(adminAuthMiddleware); // does not propagate to other namespaces
io.on('connection', (socket) => {
const { userId } = socket.data; // set by middleware above
socket.join(`user:${userId}`);
});
Root namespace middleware (io.use()) applies to all connections on the root namespace. Namespace-level middleware (io.of('/admin').use()) applies only to that namespace. Dynamic namespaces (io.of(/^\/room-\d+$/))) require their own middleware — root middleware does not propagate. This means an MCP server that uses both the root namespace for clients and an admin namespace for server-to-server communication needs separate middleware on each. If the admin namespace sends events that reach the root namespace via server-side emit, those events carry whatever identity the admin connection established — not the client's identity from root middleware.
Centrifugo: Dual JWT Types and Independent Subscription Token Expiry
Centrifugo's authentication model is unique in requiring two separate JWT types: the connection JWT (authenticates the client to the Centrifugo server at connect time) and per-channel subscription JWTs (authorizes access to each specific private channel). Both JWTs are signed with the same secret (CENTRIFUGO_TOKEN_SECRET), but they carry different claims and serve entirely different purposes. The connection JWT cannot substitute for a subscription JWT — Centrifugo will reject the subscription with error code 103 (permission denied) even if the connection JWT is valid.
import jwt from 'jsonwebtoken';
const CENTRIFUGO_SECRET = process.env.CENTRIFUGO_TOKEN_SECRET!;
// Connection JWT — authenticates the client to Centrifugo
function issueConnectionToken(userId: string): string {
return jwt.sign({ sub: userId }, CENTRIFUGO_SECRET, { expiresIn: '1h' });
}
// Subscription JWT — authorises a SPECIFIC private channel
// The 'channel' claim must match the exact channel string including namespace prefix
function issueSubscriptionToken(userId: string, channel: string): string {
return jwt.sign(
{ sub: userId, channel }, // channel: 'agent:session-abc123' not 'session-abc123'
CENTRIFUGO_SECRET,
{ expiresIn: '30m' } // shorter TTL — refreshed at 75% elapsed via getToken callback
);
}
The subscription token expiry is independent of the connection token expiry. Centrifugo disconnects the subscription (not the connection) when the subscription token's exp claim passes. The client must implement a getToken callback for each subscription — Centrifugo initiates renewal at 75% of the subscription token TTL elapsed. If the callback returns an error, the subscription enters failed state but the connection remains open. This design means an MCP server can handle many subscriptions with different TTLs on a single connection, but each subscription's refresh lifecycle must be managed independently.
Liveblocks: Room-Scoped Tokens and the userInfo Identity Surface
Liveblocks uses a server-generated session token to authorize room entry. The auth flow is client-initiated: the Liveblocks client library calls your auth endpoint with the room ID; your server validates access, calls the Liveblocks SDK to generate a session scoped to that room and user, and returns the token. The critical constraint is that the token carries a specific roomId — a token issued for room A cannot be used to enter room B, even with the same user identity. This is the correct security design, but it means multi-room clients need one auth call per room.
import { Liveblocks } from '@liveblocks/node';
const liveblocks = new Liveblocks({ secret: process.env.LIVEBLOCKS_SECRET_KEY! });
async function liveblocksAuthHandler(
userId: string,
userInfo: { name: string; avatar?: string },
roomId: string
): Promise<{ token: string }> {
const canAccess = await userCanAccessRoom(userId, roomId);
if (!canAccess) throw new Error('Forbidden');
const session = liveblocks.prepareSession(userId, { userInfo });
session.allow(roomId, session.FULL_ACCESS);
const { body, status } = await session.authorize();
if (status !== 200) throw new Error('Liveblocks authorize failed');
return JSON.parse(body) as { token: string };
}
The userInfo object is attached to the user's presence and shared with all other current room members as other.info. It is not stored in CRDT storage and disappears when the user disconnects. Keep it under 1KB (Liveblocks enforces this). The auth endpoint response time directly affects room entry latency for every user — Liveblocks does not begin establishing the room connection until the token is returned. An auth endpoint with database round-trips or external API calls should cache tokens for the session duration.
Pattern 2: Connection State Machines and Reconnection Handling
Every real-time system has a connection lifecycle — what happens when the network drops, when a token expires mid-session, or when a server node restarts. The differences in how these five systems handle reconnection determine how an MCP server must structure its recovery logic, what state it must track locally, and what signals it can trust to know when recovery is complete.
Ably: Seven-State Machine with Independent Channel States
Ably's realtime connection transitions through seven states. The distinction between disconnected and suspended determines recovery strategy: disconnected means the client is actively retrying with short backoff and recovery is typically fast; suspended means reconnection has been attempted for more than two minutes, Ably has backed off significantly, and channel history may have gaps that require explicit recovery. failed is permanent — no automatic recovery is possible and a new Ably.Realtime instance must be created.
const realtime = new Ably.Realtime({ authUrl: '/api/ably-token' });
realtime.connection.on((stateChange: Ably.ConnectionStateChange) => {
const { current, previous, reason } = stateChange;
switch (current) {
case 'connected':
break; // channels can now attach
case 'disconnected':
break; // SDK retrying — do not manually reconnect
case 'suspended':
// Show "reconnecting..." UI — history may have gaps on recovery
break;
case 'failed':
// Permanent — must create a new Ably.Realtime instance
console.error('Permanent connection failure:', reason?.code);
break;
}
});
// Channel state is independent of connection state
const channel = realtime.channels.get('agent:session:abc');
channel.on('attached', (stateChange: Ably.ChannelStateChange) => {
if (!stateChange.resumed) {
// Channel re-attached after a gap — recover missed messages
recoverMissedMessages(channel);
}
});
channel.on('failed', (stateChange: Ably.ChannelStateChange) => {
// Channel-level failure (auth, namespace restriction) — does NOT fail the connection
console.error('Channel failed:', stateChange.reason?.code, stateChange.reason?.message);
});
The independence of connection and channel state machines means that monitoring the connection state alone is insufficient for an MCP health probe. A channel in failed state due to a capability mismatch will never recover automatically regardless of connection state — and if that channel is an MCP server's primary event bus, the server is effectively dead while reporting a healthy connection. Always check both connection state and channel state in health probes.
Pusher: Stateless Server Model with No Connection State
Pusher's server-side SDK is fundamentally stateless — it makes individual HTTP POST requests to trigger events and has no concept of a persistent server-side connection. The pusher.trigger() call is an HTTP request; there is no server-to-Pusher WebSocket connection to manage, reconnect, or monitor. Connection state is exclusively a client-side concern managed by the Pusher.js browser library. This makes Pusher the simplest platform from an MCP server reconnection standpoint — there is no server connection to reconnect.
import Pusher from 'pusher';
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID!,
key: process.env.PUSHER_KEY!,
secret: process.env.PUSHER_SECRET!,
cluster: process.env.PUSHER_CLUSTER!,
useTLS: true,
});
// No persistent connection — each call is an HTTP request
async function triggerEvent(channel: string, event: string, data: unknown): Promise<void> {
await pusher.trigger(channel, event, data);
}
// Health probe: validate credentials and API reachability via /channels endpoint
async function checkPusherHealth(): Promise<object> {
try {
const result = await pusher.get({ path: '/channels' });
const body = await result.json() as { channels: Record<string, unknown> };
return { healthy: true, occupiedChannels: Object.keys(body.channels ?? {}).length };
} catch (err) {
return { healthy: false, error: String(err) };
}
}
The trade-off for simplicity is that Pusher provides no server-side tools for tracking which clients are connected or managing connection lifecycles. Presence information requires either HTTP API polling (pusher.get({ path: '/channels/presence-room/users' })) or webhook-based member tracking (which arrives asynchronously and should not be used for authoritative counts). For MCP tools that need to know whether specific clients are connected before emitting events, Pusher requires building that tracking layer separately.
Socket.IO: Multi-Node Partitioning and Connection State Recovery
Socket.IO's most dangerous failure mode is not connection loss — it is the silent message drop in multi-node deployments without the Redis adapter. When a client's CONNECT lands on node A and a subsequent room emit happens on node B, the emit succeeds silently but reaches zero recipients because the room is empty on node B. There is no error, no exception, and no indication that the message was not delivered. The Redis adapter solves this by broadcasting room emits through Redis pub/sub to all nodes.
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
async function setupRedisAdapter(io: SocketIOServer) {
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
}
// Connection state recovery (v4.6+) — server buffers events during disconnect
const io = new SocketIOServer(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000, // buffer for 2 minutes
skipMiddlewares: true,
},
});
io.on('connection', (socket) => {
if (socket.recovered) {
// Client reconnected — missed events already delivered automatically
} else {
// First connection or recovery window expired — send full current state
sendFullStateToClient(socket);
}
});
Sticky sessions are still recommended even with the Redis adapter because the initial HTTP polling handshake must land on the same node as the WebSocket upgrade. Without sticky sessions, a client might poll on node A (establishing a session) and then upgrade to WebSocket on node B (which has no record of the session), producing an HTTP 400 response. The fix is either sticky sessions at the load balancer (IP hash or cookie-based) or skipping polling entirely (transports: ['websocket'] on the client) which bypasses the session-continuity requirement at the cost of slower initial connection in environments where WebSocket is restricted.
Centrifugo: Subscription-Level Disconnect vs Connection-Level Disconnect
Centrifugo separates subscription token expiry from connection token expiry. When a connection JWT expires, Centrifugo disconnects the entire connection. When a subscription JWT expires, Centrifugo disconnects only that subscription — the connection remains open and other subscriptions continue working. The client must implement the getToken callback on each subscription to handle renewal. If the callback fails (network error, expired session), that subscription enters a failed state without affecting the connection.
// Client-side subscription with token refresh callback (centrifuge-js)
// const sub = centrifuge.newSubscription('agent:session-abc', {
// since: lastSeenPosition, // { epoch: '...', offset: 42 }
// recoverable: true,
//
// // Called when subscription token is about to expire (at 75% of TTL)
// getToken: async (ctx) => {
// const response = await fetch('/api/centrifugo/subscription-token', {
// method: 'POST',
// body: JSON.stringify({ channel: ctx.channel }),
// });
// const { token } = await response.json();
// return token; // must return a fresh token quickly or subscription fails
// },
// });
//
// sub.on('subscribed', (ctx) => {
// if (ctx.wasRecovering && !ctx.recovered) {
// // Epoch mismatch or gap too large — fetch full application state
// fetchFullApplicationState();
// }
// });
// Server-side: check which subscriptions are active per channel
async function getChannelPresence(channel: string) {
type PresenceResult = {
presence: Record<string, { client: string; user: string }>
};
const result = await centrifugo.presence(channel) as PresenceResult;
return Object.values(result.presence ?? {}).map(m => ({ clientId: m.client, userId: m.user }));
}
The epoch-based recovery system is Centrifugo's mechanism for detecting unrecoverable gaps. Each Centrifugo node maintains an epoch string that changes on restart or history clear. When a client reconnects and provides an epoch that no longer matches the current epoch, Centrifugo signals that recovery is impossible — the client must discard its local state and fetch current state from the application layer. The epoch mismatch is not an error; it is an expected condition after a server restart, and the client must handle it gracefully rather than treating it as a fatal failure.
Liveblocks: Room Lifecycle and the GC Window
Liveblocks rooms are created on first entry and garbage-collected after a period when all members have left. The GC behavior means a reconnecting client may find a room in different states depending on how long it was empty. Liveblocks handles reconnection transparently for clients that were members of a room — the client library re-authenticates and re-enters the room automatically. The risk is in MCP server code that caches room objects or presence state between requests: after a room is GC'd and recreated, the CRDT storage starts fresh (or with whatever initial values were set), and any cached state is stale.
// Always enter rooms fresh rather than reusing cached room objects
async function getOrEnterRoom(client: ReturnType<typeof createClient>, roomId: string) {
const { room, leave } = client.enterRoom(roomId);
// getStorage() waits until full CRDT sync completes — always returns current state
const { root } = await room.getStorage();
return { room, root, leave };
}
// Pattern: enter → operate → leave (stateless per MCP tool invocation)
async function readRoomStorage(roomId: string, key: string): Promise<unknown> {
const client = createClient({ authEndpoint: '/api/liveblocks-auth' });
const { root, leave } = await getOrEnterRoom(client, roomId);
try {
return deepConvert(root.get(key)); // recursive LiveObject → plain object conversion
} finally {
leave();
}
}
function deepConvert(value: unknown): unknown {
if (value instanceof LiveObject) {
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value.toObject())) obj[k] = deepConvert(v);
return obj;
}
if (value instanceof LiveList) return value.toArray().map(deepConvert);
if (value instanceof LiveMap) {
const obj: Record<string, unknown> = {};
value.forEach((v, k) => { obj[k] = deepConvert(v); });
return obj;
}
return value;
}
The deepConvert pattern is necessary because LiveObject.toObject() is explicitly documented as not recursive. Calling it on a LiveObject that contains nested LiveObject values returns a shallow copy where the nested values are still LiveObject instances — not plain objects. Passing the result to JSON.stringify then produces {} for those nested values silently. This is the single most common Liveblocks data loss bug in practice.
Pattern 3: Message Delivery Guarantees and History Recovery
At-most-once, at-least-once, exactly-once, CRDT — each platform makes a different promise about message delivery, and those promises determine whether clients can safely resume after a reconnection or must fetch current state from a separate application-layer source. For MCP servers that emit events to clients (tool results, agent state updates, progress notifications), the delivery model determines what retry logic is needed and what state must be tracked server-side.
Ably: At-Least-Once Delivery with Gap Recovery
Ably provides at-least-once delivery via channel message history buffers. When a client reconnects after a short disconnection, Ably replays messages the client missed during the disconnection window. For longer gaps, the client must explicitly request history. The resumed flag on channel state changes is the authoritative signal: resumed: true means Ably maintained message continuity; resumed: false means a gap exists and the client must fetch history to fill it.
const channel = realtime.channels.get('agent:session:abc123');
channel.on('attached', async (stateChange: Ably.ChannelStateChange) => {
if (!stateChange.resumed) {
await recoverMissedMessages(channel);
await resyncPresence(channel);
}
});
async function recoverMissedMessages(ch: Ably.RealtimeChannel) {
// untilAttach: true returns exactly messages published since last attach
const page = await ch.history({ untilAttach: true, limit: 100 });
const missed = page.items.reverse(); // history is newest-first
for (const msg of missed) {
processMessage(msg.name, msg.data); // re-emit through your handler
}
}
async function resyncPresence(ch: Ably.RealtimeChannel) {
// presence.get() returns authoritative snapshot — delta events may have drifted
const members = await ch.presence.get();
presenceMap.clear();
for (const m of members) presenceMap.set(m.clientId!, m);
}
Presence sync after gap recovery requires the same approach as initial presence setup: seed from presence.get() (authoritative snapshot) rather than relying on accumulated delta events. Members who joined and left during the gap window do not appear in the delta stream after reconnection — only members currently present appear in presence.get(). If you only process delta events, the presence count drifts from the actual count after every gap.
Pusher: At-Most-Once with No History
Pusher's delivery model is at-most-once, fire-and-forget: messages that are not received by a connected client are lost. There is no history buffer, no gap recovery, and no way to retrieve past messages. A client that disconnects and reconnects has no way to get messages published during the disconnection. This is the correct model for ephemeral events (cursor positions, live typing indicators, presence notifications) but is inappropriate for MCP tool result delivery where the client must receive every result exactly once.
// For MCP tool results: never use fire-and-forget channels for delivery
// Pattern: store result in a database, send a notification via Pusher
async function publishToolResult(
sessionId: string,
result: { toolName: string; output: unknown }
): Promise<void> {
// Store durably first (database, cache)
const resultId = await storeResultDurably(sessionId, result);
// Send a lightweight notification — client fetches the full result via REST
await pusher.trigger(`private-session-${sessionId}`, 'tool-result-available', {
resultId,
toolName: result.toolName,
fetchUrl: `/api/results/${resultId}`,
});
}
// Message size limit: 10KB per event — store large payloads externally
async function publishLargePayload(channel: string, event: string, largeData: unknown): Promise<void> {
const ref = await storeExternally(largeData);
await pusher.trigger(channel, event, { ref, fetchUrl: `/api/payloads/${ref}` });
}
The 10KB message size limit is a hard constraint at the Pusher HTTP trigger API level — exceeding it returns HTTP 400 immediately. Chunking large payloads across multiple Pusher events is possible but complex and error-prone to reassemble. The correct pattern is to store large payloads externally (database, S3, Redis) and send a reference with a fetch URL, letting the client retrieve the full payload independently. This also solves the at-most-once problem for large payloads — the client can re-fetch if the notification was missed.
Socket.IO: Acknowledgements and the Infinite Callback Problem
Socket.IO's acknowledgement system provides per-message request-response confirmation: the emitter passes a callback, and the receiver calls it to confirm processing. Without a timeout, acknowledgement callbacks never fire if the receiver disconnects before calling them — they are closure references held in the server's memory indefinitely, accumulating as connected clients disconnect without acknowledging. The fix is always using socket.timeout(ms).emit() (Socket.IO v4.6+) for any emit where the acknowledgement response is required for correctness.
// Always use timeout for acknowledgements — otherwise callbacks leak on disconnect
async function emitWithAck(
socket: import('socket.io').Socket,
event: string,
data: unknown,
timeoutMs = 5000
): Promise<unknown> {
return new Promise((resolve, reject) => {
socket.timeout(timeoutMs).emit(event, data, (err: Error | null, response: unknown) => {
if (err) reject(new Error(`Ack timeout after ${timeoutMs}ms for "${event}"`));
else resolve(response);
});
});
}
// For room-wide broadcasts with per-member acks (Socket.IO v4.6+)
async function broadcastToRoomWithAcks(
room: string,
event: string,
data: unknown,
timeoutMs = 5000
): Promise<Map<string, unknown>> {
const [errors, responses] = await io.timeout(timeoutMs).to(room).emitWithAck(event, data);
return new Map(responses.map((resp: unknown, i: number) => [`socket-${i}`, resp]));
}
Connection state recovery (v4.6+) provides a buffer-based gap fill for short disconnections: the server stores events emitted to a socket during its disconnection window (up to maxDisconnectionDuration) in the adapter, and replays them on reconnection. The client signals recovery success via socket.recovered. Recovery failure (socket.recovered === false) means the window expired or the socket session was not preserved — the server must send a full state sync. This makes recovery explicit and testable, unlike Ably's automatic history replay which requires the resumed flag to detect.
Centrifugo: Epoch-Offset Gap Recovery and the Broker Dependency
Centrifugo's history recovery model is the most precise of the five systems: every published message is assigned a monotonically increasing offset within its channel and an epoch (a string that identifies the history generation — it changes when Centrifugo restarts or clears history). The client stores the last seen epoch and offset and provides them when resubscribing to request only the gap.
// Server: query gap since client's last position
async function getHistorySince(
channel: string,
epoch: string,
offset: number
): Promise<{ messages: unknown[]; recovered: boolean }> {
type HistoryResult = {
publications: Array<{ data: unknown; offset: number }> | null;
epoch: string;
offset: number;
};
const result = await centrifugo.history(channel, 100, { epoch, offset }) as HistoryResult;
return {
messages: result.publications ?? [],
// null publications signals epoch mismatch — client must fetch full application state
recovered: result.publications !== null,
};
}
// In clustered deployments WITHOUT Redis broker, history is node-local
// Presence queries also return per-node counts — query all nodes for cluster total
async function getClusterPresence(channel: string, nodeUrls: string[]) {
const results = await Promise.allSettled(
nodeUrls.map(url => fetch(`${url}/api`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `apikey ${process.env.CENTRIFUGO_API_KEY}` },
body: JSON.stringify({ method: 'presence', params: { channel } }),
}).then(r => r.json() as Promise<{ result: { presence: Record<string, { client: string; user: string }> } }>))
);
const seenClients = new Set<string>();
const allMembers: Array<{ clientId: string; userId: string }> = [];
for (const r of results) {
if (r.status === 'fulfilled' && r.value.result?.presence) {
for (const [clientId, m] of Object.entries(r.value.result.presence)) {
if (!seenClients.has(clientId)) {
seenClients.add(clientId);
allMembers.push({ clientId, userId: m.user });
}
}
}
}
return { count: allMembers.length, members: allMembers };
}
The Redis broker requirement for consistent history and presence in a cluster is an operational decision point: without it, each Centrifugo node maintains its own history and presence, making multi-node deployments behaviorally different from single-node. An MCP server that works correctly in development (single Centrifugo node) may silently exhibit incorrect presence counts and incomplete history in production (multi-node). The simplest fix is configuring "broker": "redis" in Centrifugo's configuration before deploying multi-node.
Liveblocks: CRDT Conflict Resolution and the Presence vs Broadcast Semantic
Liveblocks provides two distinct types of real-time state: storage (persistent CRDT data shared by all room members, synchronized and conflict-resolved by Liveblocks) and ephemeral state (presence for per-user transient state, broadcast for one-time fire-and-forget events). The semantic distinction is critical for correctness — using broadcast where storage or presence is needed produces state that disappears when the sender disconnects.
// Storage: persistent, conflict-free, replayed to all members including late joiners
room.batch(() => {
const config = root.get('agentConfig') as LiveObject<Record<string, unknown>>;
config.set('status', 'running'); // Last Write Wins per field
config.set('startedAt', new Date().toISOString());
});
// Presence: ephemeral state visible to all CURRENT members while connected
// Reset to null on disconnect — not stored, not replayed to new joiners
room.updatePresence({ cursor: { x: 100, y: 200 }, status: 'typing' });
// Broadcast: fire-and-forget transient event
// NOT stored, NOT replayed, NOT received by sender
// Use for: animations, reactions, pings, one-time notifications
room.broadcastEvent({ type: 'typing-started', at: Date.now() });
// Server-side broadcast (no room join required)
await liveblocks.broadcastEvent(roomId, { type: 'tool-result', toolName: 'search', ref: resultId });
// This arrives as a broadcast event to all current room members
// It is NOT persisted — clients who are offline or join later will not receive it
// For durable delivery, write to storage instead
CRDT conflict resolution for LiveObject uses Last Write Wins per field: concurrent writes to the same field from different clients are resolved by Liveblocks servers, one value wins deterministically, and the losing write is discarded. For LiveList, concurrent insertions at the same position are both preserved — the list grows to accommodate both. This makes LiveList the correct type for append-only data (event logs, tool outputs) and LiveObject the correct type for current-state data where only the latest value matters.
Composite Health Probe Comparison
Health probes for real-time systems must go beyond "can I reach the API" — they must verify the specific capabilities the MCP server relies on. A WebSocket server that accepts connections but drops messages due to an adapter misconfiguration, or a Centrifugo node that returns healthy HTTP 200 responses while subscriptions silently fail due to expired tokens, will not be caught by a TCP-level probe.
| System | Minimal health probe | What it misses | Full probe adds |
|---|---|---|---|
| Ably | rest.stats({ limit: 1 }) — validates API key + network path |
Channel capability mismatches; realtime connection state | Create realtime client, attach to test channel, verify channel.state === 'attached' |
| Pusher | pusher.get({ path: '/channels' }) — validates credentials + cluster reachability |
Auth endpoint correctness; client connectivity | Test trigger to a known channel + verify via HTTP API that channel became occupied |
| Socket.IO | HTTP GET /health → io.sockets.fetchSockets().length |
Redis adapter health in multi-node; WebSocket upgrade path | io.timeout(2000).serverSideEmit('health-ping') propagates through adapter — if it times out, adapter is down |
| Centrifugo | GET /health → {"status":"ok"} — no auth required |
API key validity; subscription JWT issuance; history storage | POST /api with info method (requires API key) → validate node count and client metrics |
| Liveblocks | liveblocks.getRooms({ limit: 1 }) — validates secret key + API reachability |
Auth endpoint latency; room storage consistency | Enter a canary room, write a test value, read it back, leave — end-to-end CRDT roundtrip |
AliveMCP monitors real-time MCP endpoints by probing both the health endpoint and a functional roundtrip: publish an event, verify delivery within 2 seconds, measure p95 latency. A server that passes TCP-level health checks but shows increasing publish-to-delivery latency (over 500ms for Ably/Centrifugo, over 2s for Pusher trigger round-trip) indicates either adapter congestion (Redis saturation) or approaching rate limits. These latency signals are leading indicators of delivery failures that appear 5–10 minutes before the first outright message drop.
Failure Modes Cross-Reference
| Failure | System | Root cause | Fix |
|---|---|---|---|
Channel in failed while connection is connected |
Ably | Token capability does not include the channel (error 40160) | Add channel to capability grant; listen on channel.on('failed') separately |
| 403 Forbidden with no diagnostic body on channel auth | Pusher | HMAC signature mismatch — usually double-serialized channel_data |
Ensure channel_data is serialized exactly once and same string is used for signing and response |
| Messages silently not delivered to some users | Socket.IO | Multi-node deployment without Redis adapter; io.to(room).emit() only reaches sockets on same node |
Add @socket.io/redis-adapter; configure sticky sessions for upgrade handshake |
| Subscription error 103 (permission denied) despite valid connection | Centrifugo | Connection JWT used instead of subscription JWT; or subscription JWT channel claim is wrong | Issue separate subscription JWT with exact channel claim; check HTTP 200 response body for Centrifugo error field |
Empty object {} from nested storage read |
Liveblocks | LiveObject.toObject() is not recursive; nested LiveObject values passed to JSON.stringify produce {} |
Use recursive deepConvert() helper that handles LiveObject, LiveList, and LiveMap |
| Token renewal drops connection before new token arrives | Ably | Auth endpoint responding too slowly — Ably initiates renewal at 80% TTL elapsed; does not wait indefinitely | Cache pre-generated tokens server-side; keep auth endpoint response under 500ms |
| Presence count drifts after reconnection | Ably | Delta events applied without re-seeding from snapshot; members who joined/left during gap are missed | Always call presence.get() after re-attachment with resumed: false, then apply deltas |
| History returns empty despite published messages in cluster | Centrifugo | Multi-node without Redis broker; history is node-local, API hits different node than publisher | Configure "broker": "redis" in Centrifugo config for shared history storage |
Platform Selection Guide for MCP Server Real-time Use Cases
| Use case | Best fit | Why |
|---|---|---|
| MCP tool result streaming to browser client | Ably or Socket.IO | At-least-once delivery with gap recovery; acknowledgements available |
| Live cursor / presence for collaborative MCP agent view | Liveblocks | CRDT storage + presence semantics designed for collaborative state; conflict-free concurrent edits |
| Simple server-push notifications (no reconnection concern) | Pusher | Simplest server-side model; no persistent connection; stateless HTTP triggers |
| Self-hosted real-time for compliance or data-residency constraints | Centrifugo | Open-source server, self-hosted, full control over data routing and retention |
| MCP server with existing Node.js HTTP server, moderate scale | Socket.IO | Embeds in existing HTTP server; no external dependency for single-node; well-documented middleware auth pattern |
| MCP agent state that must survive client reconnection | Liveblocks or Ably | Liveblocks CRDT storage persists indefinitely; Ably history buffers up to 2 minutes of events |
| Fan-out to 1000+ concurrent agent clients | Ably or Centrifugo | Ably scales automatically as managed service; Centrifugo scales horizontally with Redis broker |
| Per-tool-call notification with at-most-once acceptable | Pusher | Low latency; no connection overhead; simple trigger API; webhooks for lifecycle events |
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 Socket.IO — multi-node adapter, connection recovery, acknowledgements
- MCP Tools for Centrifugo — subscription JWTs, history recovery, presence in clusters
- MCP Tools for Liveblocks — room auth, CRDT storage, presence and broadcast
- MCP Tools for Cloud Storage: Auth and Credential Spectrum, Presigned URL Lifetime Constraints, and Access Control Models
- MCP Tools for Job Queues: Delivery Model Spectrum, Retry and Failure Handling, and Scheduling Composition
- MCP server uptime monitoring