Guide · Real-time / WebSocket
MCP Tools for Pusher Channels — auth signature, private channels, rate limits, webhooks
Three Pusher Channels behaviours catch developers building MCP real-time tools: the channel auth signature string format is exact and unforgiving — the HMAC-SHA256 must be computed over "${socket_id}:${channel_name}" with no extra fields, no spaces, and no JSON serialization; for presence channels the channel_data JSON string is appended as "${socket_id}:${channel_name}:${channel_data}" where channel_data must be the exact string that the client receives back, not a re-serialized version; any deviation returns 403 Forbidden with no diagnostic detail in the response body; the socket_id exclusion parameter silently excludes the triggering client from its own events — if you trigger a message from client A with socket_id: A, client A never receives it, which is the correct semantic for echo-suppression but becomes a confusing bug when testing with a single client; and messages exceeding 10KB are rejected at the trigger endpoint — Pusher returns HTTP 400 with {"error":"Event data must be less than 10 kB"} without truncation, so large payloads must be stored separately (S3, database) with only a reference key sent via Pusher.
TL;DR
Trigger server-side: pusher.trigger(channel, event, data). Auth endpoint: compute HMAC_SHA256(app_secret, "${socket_id}:${channel_name}"). Private channel prefix: private-. Presence channel prefix: presence-. Rate limit: 100 trigger calls/s on most plans. Message size limit: 10KB. Health check: pusher.get({ path: '/channels' }).
Client and credentials setup
Pusher uses four credentials — App ID, App Key, App Secret, and cluster. The App Key and cluster are public and sent to browser clients. The App Secret must stay server-side and is used only for signing auth responses and webhook verification. Never expose the App Secret in client-side code or environment variables accessible to front-end builds.
import Pusher from 'pusher';
// Server-side Pusher client (uses App Secret — never expose to clients)
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!, // e.g. 'eu', 'us2', 'ap2'
useTLS: true, // always use TLS; Pusher rejected plain HTTP in 2023
});
// Trigger a message from an MCP tool handler
async function triggerEvent(
channel: string,
event: string,
data: Record,
excludeSocketId?: string
): Promise {
// Pusher.trigger() is synchronous in the SDK but makes an HTTP POST
await pusher.trigger(channel, event, data, {
socket_id: excludeSocketId, // optional: exclude the triggering client
});
}
// Trigger to multiple channels at once (up to 100 channels per call)
async function triggerToMultiple(
channels: string[],
event: string,
data: Record
): Promise {
if (channels.length > 100) throw new Error('Pusher allows max 100 channels per trigger');
await pusher.trigger(channels, event, data);
}
Pusher triggers are rate-limited at approximately 100 requests per second on most plans. Unlike some message queues, exceeding the rate limit returns HTTP 429 — the SDK does not automatically retry. For high-throughput MCP tools, batch messages with triggerBatch() (up to 10 events per call) to reduce request count while staying within rate limits.
Channel auth — private and presence channels
Public channels require no auth. Private channels (prefixed private-) and presence channels (prefixed presence-) require server-side authentication. When a client subscribes to a private or presence channel, the Pusher client library makes a POST to your authEndpoint with socket_id and channel_name form fields. Your server must return a signed auth response.
import crypto from 'crypto';
// Auth endpoint for private channels
// Called by Pusher client library when subscribing to private-* channels
function authenticatePrivateChannel(
socketId: string,
channelName: string
): { auth: string } {
if (!channelName.startsWith('private-')) {
throw new Error(`Expected private channel, got: ${channelName}`);
}
// The string to sign is EXACTLY "${socket_id}:${channel_name}"
// No JSON, no extra fields, no whitespace
const stringToSign = `${socketId}:${channelName}`;
const signature = crypto
.createHmac('sha256', process.env.PUSHER_SECRET!)
.update(stringToSign)
.digest('hex');
return { auth: `${process.env.PUSHER_KEY!}:${signature}` };
}
// Auth endpoint for presence channels
// Must include channel_data with user_id (required) and user_info (optional)
function authenticatePresenceChannel(
socketId: string,
channelName: string,
userId: string,
userInfo: Record = {}
): { auth: string; channel_data: string } {
if (!channelName.startsWith('presence-')) {
throw new Error(`Expected presence channel, got: ${channelName}`);
}
// channel_data must be JSON-serialized exactly once
// Do NOT re-serialize it when including in the auth string
const channelData = JSON.stringify({ user_id: userId, user_info: userInfo });
// The string to sign includes channel_data as a raw string (not re-encoded)
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, // return the SAME string used for signing
};
}
// Express/Hono auth endpoint wiring
// app.post('/pusher/auth', (req, res) => {
// const { socket_id, channel_name } = req.body;
// if (channel_name.startsWith('private-')) {
// res.json(authenticatePrivateChannel(socket_id, channel_name));
// } else if (channel_name.startsWith('presence-')) {
// res.json(authenticatePresenceChannel(socket_id, channel_name, req.user.id, req.user.info));
// } else {
// res.status(403).json({ error: 'Cannot auth a public channel' });
// }
// });
The most common auth failure is double-serializing channel_data: if you JSON.stringify(JSON.stringify(...)) the channel data object before including it in the signing string, the signature will not match Pusher's expected value and the client receives 403 Forbidden with no body. The channel_data field in the response must be the same string used when computing the signature.
Batch triggers and message size constraints
Pusher's triggerBatch() sends multiple events in a single HTTP request, reducing latency and request count. Each event in the batch can target a different channel. The 10KB per-message limit applies to the data field of each event individually — not the total batch payload.
// Batch trigger — up to 10 events per call
async function triggerBatch(events: Array<{
channel: string;
name: string;
data: unknown;
socket_id?: string;
}>): Promise {
if (events.length > 10) throw new Error('Pusher batch allows max 10 events');
// Validate message sizes before sending to get actionable errors
for (const event of events) {
const serialized = JSON.stringify(event.data);
const sizeBytes = Buffer.byteLength(serialized, 'utf8');
if (sizeBytes > 10 * 1024) {
throw new Error(
`Event "${event.name}" on channel "${event.channel}" is ${sizeBytes} bytes — exceeds 10KB limit. ` +
'Store the payload externally and send a reference key instead.'
);
}
}
await pusher.triggerBatch(events);
}
// Pattern: store large payloads externally, send references via Pusher
async function publishLargePayload(
channel: string,
payloadType: string,
largeData: unknown,
storeFn: (data: unknown) => Promise // returns a retrieval URL/key
): Promise {
const storageRef = await storeFn(largeData);
await pusher.trigger(channel, payloadType, {
ref: storageRef,
size: JSON.stringify(largeData).length,
fetchUrl: `/api/payloads/${storageRef}`,
});
}
Client-side events on private channels
Client events (peer-to-peer events sent directly from one browser client to others without going through your server) are only available on private and presence channels, not public channels. Client event names must start with client-. Client events do NOT count against your server-side trigger rate limit, but they are rate-limited at 10 events/second per client. Client events are excluded from server-side webhooks.
// Server-side trigger (goes through Pusher servers, triggers webhooks)
await pusher.trigger('private-room-123', 'server-event', { msg: 'hello' });
// Client-side event (Pusher.js browser SDK, peer-to-peer via Pusher relay)
// NOTE: not available from server SDK — this is browser/client-only code
// const channel = pusherClient.subscribe('private-room-123');
// channel.trigger('client-typing', { userId: 'user_abc' });
// Server-side: read client events via webhooks (not directly triggerable from server)
// Webhook payload for client events:
// {
// "events": [{
// "name": "client-typing",
// "channel": "private-room-123",
// "user_id": "user_abc", // socket_id of the sender
// "data": "{\"userId\":\"user_abc\"}"
// }]
// }
Webhook verification and event types
Pusher sends webhooks for channel lifecycle events (channel occupied, channel vacated), member events on presence channels (member added, member removed), and client events if enabled. Webhook payloads are signed with an X-Pusher-Key header (your App Key) and an X-Pusher-Signature header (HMAC-SHA256 of the raw body with your App Secret).
import crypto from 'crypto';
// Verify a Pusher webhook request
function verifyPusherWebhook(
rawBody: string,
pusherKey: string,
pusherSignature: string
): boolean {
if (pusherKey !== process.env.PUSHER_KEY) return false;
const expectedSig = crypto
.createHmac('sha256', process.env.PUSHER_SECRET!)
.update(rawBody) // sign the RAW body string, not parsed JSON
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(pusherSignature, 'hex'),
Buffer.from(expectedSig, 'hex')
);
}
// Webhook handler (Express example)
// app.post('/webhooks/pusher', express.raw({ type: 'application/json' }), (req, res) => {
// const rawBody = req.body.toString('utf8');
// const isValid = verifyPusherWebhook(
// rawBody,
// req.headers['x-pusher-key'] as string,
// req.headers['x-pusher-signature'] as string
// );
// if (!isValid) return res.status(401).send('Invalid signature');
//
// const payload = JSON.parse(rawBody);
// for (const event of payload.events) {
// if (event.name === 'member_added') {
// console.log('Member joined:', event.channel, event.user_id);
// } else if (event.name === 'member_removed') {
// console.log('Member left:', event.channel, event.user_id);
// } else if (event.name === 'channel_occupied') {
// console.log('Channel became active:', event.channel);
// }
// }
// res.status(200).send('OK');
// });
// IMPORTANT: use express.raw() not express.json() for the webhook endpoint
// Parsing with express.json() re-serializes the body, which can alter whitespace
// and break the HMAC verification.
Webhook events for presence channel member changes fire asynchronously — they are not guaranteed to arrive in real-time. If your MCP tool needs an authoritative presence count, use the Pusher HTTP API to query channel presence directly rather than relying on webhook-derived counts.
Health probe — channel query and API connectivity
Pusher's HTTP API provides a /channels endpoint that returns the list of currently occupied channels, connection counts, and subscription info. This is the most reliable health probe for a Pusher-backed MCP server because it validates API credentials, network connectivity, and Pusher service availability in a single call.
// Health probe: query occupied channels
async function checkPusherHealth(): Promise
AliveMCP monitors Pusher-backed MCP endpoints by probing the /channels HTTP API and checking trigger latency — a trigger call that takes over 2 seconds typically indicates Pusher cluster overload or a network partition on the path to the Pusher cluster. Alert thresholds should be tuned per cluster (eu clusters add ~40ms vs us2 for requests originating in North America).
Related guides
- MCP Tools for Ably — channel auth, connection state machine, presence, history recovery
- 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 server webhook patterns
- MCP server uptime monitoring