Back to Tutorials
IntermediateAI/ML

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.

by Michael Eakins
24 min read
2/11/2026

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

TypeScriptNode.jsPusher ChannelsMCP ProtocolZodClaude Desktop

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 --version to 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 McpServer and 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.

  1. 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.

  2. 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"

  1. 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_event tool, the MCP server forwarded it to Pusher's REST API, and the event was broadcast.

  2. 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.

  1. 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.error instead of console.log in 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 register function. 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.

Last updated: 2/11/2026