Building a Pusher Channels MCP Server for Realtime AI Messaging
Build an MCP server that gives AI agents the power to send realtime messages through Pusher Channels. Step-by-step TypeScript guide covering tool design, Pusher REST API integration, and Claude Desktop configuration.
Prerequisites
- Node.js 18 or later installed
- TypeScript fundamentals (types, async/await)
- A free Pusher Channels account (pusher.com)
- Basic familiarity with MCP concepts
What You'll Learn
- Build a complete MCP server with 7 production-ready tools
- Integrate Pusher Channels REST API with the MCP protocol
- Design tool schemas with Zod for AI-agent input validation
- Configure and test MCP servers with Claude Desktop
- Implement structured error handling for MCP tools
Technologies Covered
AI agents can read your databases, generate code, and search the web. But can they send a realtime notification to your dashboard the moment a deployment finishes? Can they broadcast a message to every connected user in your application? Until now, the answer was no.
The Model Context Protocol gives AI agents a standardized way to interact with external tools. Pusher Channels gives applications realtime messaging over WebSockets. Put them together and you get something genuinely useful: an AI agent that can push live updates to any connected client, query who is online, and manage realtime infrastructure on your behalf.
In this tutorial, you will build a complete MCP server for Pusher Channels from scratch. By the end, you will have a working server with seven tools that you can wire into Claude Desktop and start using immediately. The complete code is available at github.com/CrashBytes/ByteSizedExamples/tree/main/pusher-mcp-server.
What You Will Build
The server exposes seven tools covering the full Pusher Channels REST API surface:
- trigger_event — Send an event to one or more channels
- trigger_batch_events — Send up to 10 events in a single API call
- list_channels — List active channels with optional prefix filtering
- get_channel_info — Get subscription and user counts for a channel
- get_presence_users — List users connected to a presence channel
- authorize_channel — Generate auth tokens for private and presence channels
- terminate_user_connections — Disconnect a user from all channels
The architecture is straightforward. Claude communicates with your MCP server over stdio. Your server translates tool calls into Pusher REST API requests and returns the results as formatted text.
Claude ←→ MCP Server (stdio) ←→ Pusher REST API ←→ WebSocket Clients
Prerequisites
Before you start, make sure you have:
- Node.js 18+ installed (
node --versionto check) - A Pusher account — sign up free at pusher.com, create a Channels app, and note your App ID, Key, Secret, and Cluster from the dashboard
- Basic TypeScript knowledge — you should be comfortable with types, interfaces, and async/await
- MCP familiarity — if you are new to MCP, read our Building Production MCP Servers tutorial first for architecture fundamentals
This tutorial takes approximately 45 minutes to complete hands-on.
Project Setup
Create a new directory and initialize the project:
mkdir pusher-mcp-server
cd pusher-mcp-server
npm init -y
Install the three runtime dependencies:
npm install @modelcontextprotocol/sdk zod pusher
- @modelcontextprotocol/sdk — the official MCP TypeScript SDK with
McpServerand transport classes - zod — schema validation that the MCP SDK uses for tool input definitions
- pusher — the official Pusher server-side SDK wrapping their REST API
Install dev dependencies:
npm install -D typescript @types/node tsx
Update your package.json with these settings:
{
"type": "module",
"bin": {
"pusher-mcp-server": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"dev": "tsx src/index.ts"
}
}
Setting "type": "module" enables ES module imports. The bin field lets npm
create a global command when the package is installed. The chmod in the build
script makes the output executable.
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Create a .env.example to document the required credentials:
PUSHER_APP_ID=your_app_id
PUSHER_KEY=your_app_key
PUSHER_SECRET=your_app_secret
PUSHER_CLUSTER=us2
Finally, create the directory structure:
mkdir -p src/tools
Your project should now look like this:
pusher-mcp-server/
├── src/
│ └── tools/
├── .env.example
├── package.json
├── tsconfig.json
└── node_modules/
The Pusher Client Wrapper
Before building any tools, you need a way to create and reuse a Pusher client instance. The wrapper reads credentials from environment variables, validates they are all present, and creates the client only once.
Create src/pusher-client.ts:
// src/pusher-client.ts
import Pusher from 'pusher'
let client: Pusher | null = null
export function getPusherClient(): Pusher {
if (client) return client
const appId = process.env.PUSHER_APP_ID
const key = process.env.PUSHER_KEY
const secret = process.env.PUSHER_SECRET
const cluster = process.env.PUSHER_CLUSTER
// Collect all missing variables for a single clear error message
const missing: string[] = []
if (!appId) missing.push('PUSHER_APP_ID')
if (!key) missing.push('PUSHER_KEY')
if (!secret) missing.push('PUSHER_SECRET')
if (!cluster) missing.push('PUSHER_CLUSTER')
if (missing.length !== 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}. ` +
'Set these in your MCP server configuration or .env file.'
)
}
client = new Pusher({
appId: appId!,
key: key!,
secret: secret!,
cluster: cluster!,
useTLS: true,
})
return client
}
This uses lazy initialization — the Pusher client is not created until the first
tool call. This means the server starts instantly and only fails on missing
credentials when a tool is actually invoked, which produces a clearer error
message for the user. The useTLS: true option ensures all API requests use
HTTPS.
Your First Tool: trigger_event
This is the most fundamental Pusher operation — sending an event to one or more channels. It demonstrates the core pattern you will follow for every tool.
Create src/tools/trigger-event.ts:
// src/tools/trigger-event.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getPusherClient } from '../pusher-client.js'
export function registerTriggerEvent(server: McpServer) {
server.tool(
// Tool name — snake_case by convention
'trigger_event',
// Description — helps the AI decide when to use this tool
'Send an event to one or more Pusher channels. Use this to push ' +
'realtime messages to connected clients.',
// Input schema — Zod types that the SDK converts to JSON Schema
{
channel: z
.union([
z.string().min(1).max(200),
z.array(z.string().min(1).max(200)).min(1).max(100),
])
.describe('Channel name or array of channel names (max 100)'),
event: z
.string()
.min(1)
.max(200)
.describe("Event name to trigger (e.g. 'new-message', 'update')"),
data: z
.union([z.string(), z.record(z.unknown())])
.describe('Event payload — string or JSON object (max 10KB)'),
socketId: z
.string()
.optional()
.describe('Optional socket ID to exclude from receiving the event'),
},
// Handler — executes when the AI calls this tool
async ({ channel, event, data, socketId }) => {
try {
const pusher = getPusherClient()
const payload = typeof data === 'string' ? data : JSON.stringify(data)
const params = socketId ? { socket_id: socketId } : undefined
await pusher.trigger(channel, event, payload, params)
const channels = Array.isArray(channel) ? channel : [channel]
return {
content: [
{
type: 'text' as const,
text:
`Event "${event}" triggered on ${channels.length} ` +
`channel(s): ${channels.join(', ')}`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
content: [
{
type: 'text' as const,
text: `Failed to trigger event: ${message}`,
},
],
isError: true,
}
}
}
)
}
There are three important patterns here that apply to every tool you build:
Zod .describe() matters. The description on each field becomes part of the
JSON Schema that the AI reads. A clear description like
"Event name to trigger (e.g. 'new-message', 'update')" directly improves how
well the AI fills in parameters. Vague descriptions produce vague inputs.
Return text, not JSON. MCP tool responses are arrays of content blocks. While you could return structured data, returning human-readable text makes the AI's response to the user more natural. The AI does not need to parse JSON — it just reads the confirmation message.
Catch errors, do not throw. If a Pusher API call fails, you return an error
message as text with isError: true rather than throwing an exception. This
gives the AI a chance to understand what went wrong and either retry or inform
the user, rather than crashing the entire server.
Read-Only Tools: list_channels and get_channel_info
These two tools query the state of your Pusher app without modifying anything.
They use the Pusher get() method which wraps the REST API's GET endpoints.
Create src/tools/list-channels.ts:
// src/tools/list-channels.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getPusherClient } from '../pusher-client.js'
export function registerListChannels(server: McpServer) {
server.tool(
'list_channels',
'List all active channels in your Pusher app. Optionally filter by ' +
"prefix (e.g. 'presence-' or 'private-') and request subscription " +
'or user counts.',
{
prefix: z
.string()
.optional()
.describe(
"Filter channels by prefix (e.g. 'presence-', 'private-chat-')"
),
info: z
.array(z.enum(['user_count', 'subscription_count']))
.optional()
.describe('Additional attributes to include for each channel'),
},
async ({ prefix, info }) => {
try {
const pusher = getPusherClient()
const params: Record<string, string> = {}
if (prefix) params.filter_by_prefix = prefix
if (info?.length) params.info = info.join(',')
const response = await pusher.get({ path: '/channels', params })
if (response.status !== 200) {
return {
content: [
{
type: 'text' as const,
text: `Pusher API returned status ${response.status}`,
},
],
isError: true,
}
}
const body = (await response.json()) as {
channels: Record<
string,
{ user_count?: number; subscription_count?: number }
>
}
const channels = body.channels || {}
const names = Object.keys(channels)
if (names.length === 0) {
return {
content: [
{
type: 'text' as const,
text: prefix
? `No active channels matching prefix "${prefix}"`
: 'No active channels',
},
],
}
}
const lines = names.map(name => {
const ch = channels[name]
const parts = [name]
if (ch.subscription_count !== undefined)
parts.push(`subscriptions: ${ch.subscription_count}`)
if (ch.user_count !== undefined) parts.push(`users: ${ch.user_count}`)
return parts.join(' — ')
})
return {
content: [
{
type: 'text' as const,
text: `Active channels (${names.length}):\n${lines.join('\n')}`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
content: [
{
type: 'text' as const,
text: `Failed to list channels: ${message}`,
},
],
isError: true,
}
}
}
)
}
The get_channel_info tool follows the same pattern but queries a single
channel:
// src/tools/get-channel-info.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getPusherClient } from '../pusher-client.js'
export function registerGetChannelInfo(server: McpServer) {
server.tool(
'get_channel_info',
'Get detailed information about a specific Pusher channel, including ' +
'whether it is occupied and optional subscription/user counts.',
{
channel: z.string().min(1).max(200).describe('The channel name to query'),
info: z
.array(z.enum(['user_count', 'subscription_count']))
.optional()
.describe('Additional attributes to request'),
},
async ({ channel, info }) => {
try {
const pusher = getPusherClient()
const params: Record<string, string> = {}
if (info?.length) params.info = info.join(',')
const response = await pusher.get({
path: `/channels/${encodeURIComponent(channel)}`,
params,
})
if (response.status !== 200) {
return {
content: [
{
type: 'text' as const,
text: `Pusher API returned status ${response.status}`,
},
],
isError: true,
}
}
const body = (await response.json()) as {
occupied?: boolean
user_count?: number
subscription_count?: number
}
const lines = [`Channel: ${channel}`]
if (body.occupied !== undefined)
lines.push(`Occupied: ${body.occupied}`)
if (body.subscription_count !== undefined)
lines.push(`Subscriptions: ${body.subscription_count}`)
if (body.user_count !== undefined)
lines.push(`Users: ${body.user_count}`)
return {
content: [{ type: 'text' as const, text: lines.join('\n') }],
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
content: [
{
type: 'text' as const,
text: `Failed to get channel info: ${message}`,
},
],
isError: true,
}
}
}
)
}
Notice how the Pusher get() method takes a path and optional params. The
response is a standard fetch-like Response object that you call .json() on.
Both tools format the API response into readable text rather than dumping raw
JSON.
Presence Channel Tools
Presence channels are a special Pusher feature that tracks which users are
currently connected. They require channels to start with presence- and provide
user lists. Two tools cover this functionality.
Create src/tools/get-presence-users.ts:
// src/tools/get-presence-users.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getPusherClient } from '../pusher-client.js'
export function registerGetPresenceUsers(server: McpServer) {
server.tool(
'get_presence_users',
'List all users currently connected to a presence channel. Only works ' +
"with channels that start with 'presence-'.",
{
channel: z
.string()
.min(1)
.max(200)
.startsWith('presence-', {
message:
"Channel must be a presence channel (starts with 'presence-')",
})
.describe("Presence channel name (must start with 'presence-')"),
},
async ({ channel }) => {
try {
const pusher = getPusherClient()
const response = await pusher.get({
path: `/channels/${encodeURIComponent(channel)}/users`,
params: {},
})
if (response.status !== 200) {
return {
content: [
{
type: 'text' as const,
text: `Pusher API returned status ${response.status}`,
},
],
isError: true,
}
}
const body = (await response.json()) as {
users: Array<{ id: string }>
}
const users = body.users || []
if (users.length === 0) {
return {
content: [
{
type: 'text' as const,
text: `No users connected to ${channel}`,
},
],
}
}
const userList = users.map(u => ` ${u.id}`).join('\n')
return {
content: [
{
type: 'text' as const,
text: `Users on ${channel} (${users.length}):\n${userList}`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
content: [
{
type: 'text' as const,
text: `Failed to get presence users: ${message}`,
},
],
isError: true,
}
}
}
)
}
The Zod .startsWith("presence-") validation catches invalid channel names
before they hit the API. This is a key benefit of defining input schemas — the
MCP SDK rejects malformed inputs at the protocol level, so the AI gets immediate
feedback about what went wrong.
The authorize_channel tool generates authentication tokens that clients need
to subscribe to private or presence channels:
// src/tools/authorize-channel.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getPusherClient } from '../pusher-client.js'
export function registerAuthorizeChannel(server: McpServer) {
server.tool(
'authorize_channel',
'Generate an authorization token for a private or presence channel. ' +
'Useful when building auth endpoints for Pusher client connections.',
{
socketId: z
.string()
.min(1)
.describe('The socket ID from the client connection'),
channel: z
.string()
.min(1)
.max(200)
.describe('Private or presence channel name'),
presenceData: z
.object({
user_id: z.string().min(1).describe('Unique user identifier'),
user_info: z
.record(z.unknown())
.optional()
.describe('Optional user metadata (name, avatar, etc.)'),
})
.optional()
.describe(
'Required for presence channels — identifies the connecting user'
),
},
async ({ socketId, channel, presenceData }) => {
try {
if (
!channel.startsWith('private-') &&
!channel.startsWith('presence-')
) {
return {
content: [
{
type: 'text' as const,
text: 'Channel must start with "private-" or "presence-"',
},
],
isError: true,
}
}
if (channel.startsWith('presence-') && !presenceData) {
return {
content: [
{
type: 'text' as const,
text: 'presenceData is required for presence channels',
},
],
isError: true,
}
}
const pusher = getPusherClient()
const auth = pusher.authorizeChannel(socketId, channel, presenceData)
return {
content: [
{
type: 'text' as const,
text: `Authorization for ${channel}:\n${JSON.stringify(auth, null, 2)}`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
content: [
{
type: 'text' as const,
text: `Failed to authorize channel: ${message}`,
},
],
isError: true,
}
}
}
)
}
Note that authorizeChannel() is synchronous — it generates an HMAC signature
locally without making a network request. This makes it fast and reliable. The
presence data validation happens in the handler rather than the Zod schema
because the requirement is conditional on the channel type.
Batch Events and User Management
The remaining two tools handle efficiency and moderation.
trigger_batch_events sends up to 10 events in a single HTTP request, which is
significantly more efficient than calling trigger_event ten times:
// src/tools/trigger-batch-events.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getPusherClient } from '../pusher-client.js'
export function registerTriggerBatchEvents(server: McpServer) {
server.tool(
'trigger_batch_events',
'Send up to 10 events in a single API call. More efficient than ' +
'triggering events individually.',
{
events: z
.array(
z.object({
channel: z.string().min(1).max(200).describe('Target channel name'),
name: z.string().min(1).max(200).describe('Event name'),
data: z
.union([z.string(), z.record(z.unknown())])
.describe('Event payload'),
socketId: z.string().optional().describe('Socket ID to exclude'),
})
)
.min(1)
.max(10)
.describe('Array of events to send (max 10)'),
},
async ({ events }) => {
try {
const pusher = getPusherClient()
const batch = events.map(e => ({
channel: e.channel,
name: e.name,
data: typeof e.data === 'string' ? e.data : JSON.stringify(e.data),
...(e.socketId ? { socket_id: e.socketId } : {}),
}))
await pusher.triggerBatch(batch)
const summary = events
.map(e => ` "${e.name}" → ${e.channel}`)
.join('\n')
return {
content: [
{
type: 'text' as const,
text: `Batch of ${events.length} event(s) triggered:\n${summary}`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
content: [
{
type: 'text' as const,
text: `Failed to trigger batch: ${message}`,
},
],
isError: true,
}
}
}
)
}
terminate_user_connections is a moderation tool that forces a user offline
across all channels. This is the only destructive tool in the server:
// src/tools/terminate-user.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { getPusherClient } from '../pusher-client.js'
export function registerTerminateUser(server: McpServer) {
server.tool(
'terminate_user_connections',
'Disconnect all connections for a specific user. Useful for ' +
'moderation or security — forces a user offline across all channels.',
{
userId: z.string().min(1).describe('The user ID to disconnect'),
},
async ({ userId }) => {
try {
const pusher = getPusherClient()
await pusher.terminateUserConnections(userId)
return {
content: [
{
type: 'text' as const,
text: `All connections terminated for user "${userId}"`,
},
],
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
content: [
{
type: 'text' as const,
text: `Failed to terminate connections: ${message}`,
},
],
isError: true,
}
}
}
)
}
The clear description "forces a user offline across all channels" helps the AI understand the severity of this action. When the AI considers using this tool, the description gives it enough context to confirm with the user first rather than disconnecting someone unprompted.
Wiring It Together: The Server Entry Point
With all seven tools implemented, the entry point simply creates the server, registers every tool, and starts the stdio transport.
Create src/index.ts:
#!/usr/bin/env node
// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { registerTriggerEvent } from './tools/trigger-event.js'
import { registerTriggerBatchEvents } from './tools/trigger-batch-events.js'
import { registerListChannels } from './tools/list-channels.js'
import { registerGetChannelInfo } from './tools/get-channel-info.js'
import { registerGetPresenceUsers } from './tools/get-presence-users.js'
import { registerAuthorizeChannel } from './tools/authorize-channel.js'
import { registerTerminateUser } from './tools/terminate-user.js'
const server = new McpServer({
name: 'pusher-channels',
version: '1.0.0',
})
// Register all tools
registerTriggerEvent(server)
registerTriggerBatchEvents(server)
registerListChannels(server)
registerGetChannelInfo(server)
registerGetPresenceUsers(server)
registerAuthorizeChannel(server)
registerTerminateUser(server)
// Start the server
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
console.error('Pusher MCP Server running on stdio')
}
main().catch(error => {
console.error('Fatal error:', error)
process.exit(1)
})
The shebang line (#!/usr/bin/env node) at the top makes the compiled file
directly executable when installed as a global npm package. The console.error
calls write to stderr, which is important — MCP uses stdout for JSON-RPC
messages, so any console.log would corrupt the protocol. Always use
console.error for server logging in stdio-based MCP servers.
Build the server:
npm run build
You should see no errors, and a build/ directory containing the compiled
JavaScript.
Testing Your Server
Before connecting to Claude, verify the server starts correctly:
node build/index.js
The server should hang waiting for stdin input (this is correct behavior for an MCP server running on stdio). Press Ctrl+C to exit.
For automated testing, you can use vitest with mocked Pusher calls. Here is an example testing the trigger_event tool:
// tests/trigger-event.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the pusher module before importing the tool
vi.mock('pusher', () => {
return {
default: vi.fn().mockImplementation(() => ({
trigger: vi.fn().mockResolvedValue({}),
})),
}
})
describe('trigger_event', () => {
it('should trigger an event on a channel', async () => {
// Set env vars for the client
process.env.PUSHER_APP_ID = 'test'
process.env.PUSHER_KEY = 'test'
process.env.PUSHER_SECRET = 'test'
process.env.PUSHER_CLUSTER = 'us2'
const { getPusherClient } = await import('../src/pusher-client.js')
const client = getPusherClient()
await client.trigger('my-channel', 'my-event', '{"msg":"hello"}')
expect(client.trigger).toHaveBeenCalledWith(
'my-channel',
'my-event',
'{"msg":"hello"}'
)
})
})
For a full testing framework purpose-built for MCP servers, see our tutorial on @crashbytes/mcp-test-kit.
Claude Desktop Integration
Open the Claude Desktop configuration file:
macOS:
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
Windows:
code %APPDATA%\Claude\claude_desktop_config.json
Add the Pusher server to the mcpServers section:
{
"mcpServers": {
"pusher": {
"command": "node",
"args": ["/absolute/path/to/pusher-mcp-server/build/index.js"],
"env": {
"PUSHER_APP_ID": "your_app_id",
"PUSHER_KEY": "your_app_key",
"PUSHER_SECRET": "your_app_secret",
"PUSHER_CLUSTER": "us2"
}
}
}
}
Replace the path and credentials with your actual values. Save the file and fully restart Claude Desktop (Cmd+Q on macOS, not just closing the window).
After restarting, click the "Add files, connectors, and more" icon in Claude Desktop. You should see the Pusher server listed with seven available tools.
Trying It Out
The best way to verify everything works end-to-end is to watch events arrive in real time.
-
Open the Pusher Debug Console — in your Pusher dashboard, navigate to your app and click "Debug Console." This shows all events on your app in real time.
-
Ask Claude to send an event. Try a prompt like:
"Send a hello event to the test-channel channel with the data message Hello from Claude and timestamp 2026-02-11"
-
Watch the Debug Console. Within a second, you should see the event appear with the channel name, event name, and full payload. This confirms the entire pipeline works: Claude parsed your intent, called the
trigger_eventtool, the MCP server forwarded it to Pusher's REST API, and the event was broadcast. -
Try a query. Ask Claude:
Show me all active channels on my Pusher app
If you have any active connections (including the Debug Console), Claude will list them.
- Test a realistic workflow. Build something more practical:
"Send a batch of events: a build-started event to the ci-cd channel, a notification event to the alerts channel with level info and message Deploy initiated, and a status-update event to the dashboard channel with status deploying"
Claude will use the trigger_batch_events tool to send all three events in a
single API call.
Next Steps
You now have a fully functional MCP server for Pusher Channels. Here are some ways to extend it:
Publish to npm. Add a publishConfig field with access set to public in
your package.json and run npm publish. This lets anyone install your server
globally with npm install -g @crashbytes/pusher-mcp-server.
Add CI/CD. Create a GitHub Actions workflow that builds and type-checks on every push. See the repository for the project structure to build on.
Extend with webhooks. Pusher can send webhooks when channels are created, users join/leave, and events occur. You could add MCP resources that expose webhook data, letting the AI monitor your realtime infrastructure rather than just send events.
Production patterns. For logging, metrics, security hardening, and advanced patterns like caching and retry logic, see our Building Production MCP Servers tutorial.
Key Takeaways
- MCP servers act as bridges between AI agents and external APIs. The Pusher MCP server translates tool calls into REST API requests.
- The MCP TypeScript SDK uses Zod schemas for input validation. The
.describe()method on each field is critical — it is the primary documentation the AI reads when deciding how to use your tools. - Always return text content from tool handlers, and catch errors rather than throwing them. The AI handles readable error messages far better than stack traces.
- Use
console.errorinstead ofconsole.login stdio-based MCP servers. stdout is reserved for JSON-RPC protocol messages. - Each tool should be self-contained in its own file with a single
registerfunction. This keeps the codebase maintainable as you add more tools.
The complete source code is available at github.com/CrashBytes/ByteSizedExamples/tree/main/pusher-mcp-server.