Build and Debug Realtime Features with Claude Code and the Apinator MCP Server

Give Claude Code, Claude Desktop or Cursor live access to your Apinator channels with the @apinator/mcp MCP server. Setup, a worked order-status session, and what the agent can see that it could not before.

AI coding agents are good at writing realtime code and bad at knowing whether it works. Claude Code can wire up a private-orders-42 channel, write the auth endpoint and the client subscription, and tell you confidently that order updates will now appear live. Then you open two browser tabs, place an order, and nothing moves. Was the event published? Did the client subscribe? Did the auth endpoint return a signature the server accepted? The agent cannot see any of that, so it guesses, and you debug.

The Apinator MCP server closes that gap. It is a small Model Context Protocol server that gives an agent live access to one Apinator app: it can publish events, see which channels have subscribers, compute the exact auth signature your endpoint should return, and subscribe to a channel over WebSocket to watch events arrive. The agent stops reasoning about what should happen and starts looking at what does.

This post covers the setup, then walks through a realistic session: adding a live order-status feature, debugging a private channel that refuses to subscribe, and confirming the payload shape the client actually receives. It ends with the security notes you should read before handing the server to an agent.

TL;DR

  • @apinator/mcp exposes five tools to any MCP client: publish_event, list_channels, get_channel, sign_channel_auth and watch_channel.
  • Setup is one claude mcp add command, or an mcpServers JSON block for Claude Desktop and Cursor. Four environment variables are required.
  • The payoff is verification: the agent starts a watch, triggers the publish, and reads the real event back instead of assuming it arrived.
  • APINATOR_READ_ONLY=1 removes publish_event for an agent that should only observe. The secret never leaves the server process.

What the MCP server is and why an agent needs it

MCP is the protocol coding agents use to call external tools. A client such as Claude Code starts the server as a child process, asks it which tools it offers, and lets the model call them during a session. @apinator/mcp is one of those servers. It wraps the @apinator/server SDK and a WebSocket client, and exposes them as five tools:

Tool What it does
publish_event Publish an event to one channel or up to 100 channels. data can be an object, an array or a string. Optional socket_id excludes the sender. Event names starting with realtime: are rejected. Removed in read-only mode.
list_channels Active channels (at least one subscriber right now) with subscriber counts, optionally filtered by prefix.
get_channel One channel's subscriber count, or active: false when nobody is subscribed.
sign_channel_auth The auth (and channel_data) your auth endpoint should return for a private- or presence- subscription.
watch_channel Connects over WebSocket, subscribes (signing private and presence auth itself), and collects events for a number of seconds (default 10, max 60).

It also serves two resources the agent can read on demand, apinator://reference/protocol (frames, system events, error codes, channel types and the auth signature) and apinator://docs/llms.txt (the docs index), plus a prompt called add-realtime-feature that walks the agent through choosing channels, wiring both SDKs and verifying the result.

Why does this matter more for realtime than for, say, a database? Because realtime bugs are invisible from the code. A REST endpoint that returns the wrong shape fails a test. An event published to the wrong channel name, or with a payload the client does not expect, or before the client has subscribed, succeeds silently on the server and simply never shows up in the browser. The only way to know is to be subscribed and watch. Without the MCP server, that observer is you. With it, the agent can be the observer, which means it can close the loop itself: write the code, run it, see the event, compare the payload, fix the mismatch, see it again.

Setup

You need four values from the Apinator console: the app ID, key, secret and the cluster the app was created in (eu or us). Node.js 18 or newer must be on your PATH, since the server runs through npx.

Claude Code

One command registers the server for the current project:

Terminal
claude mcp add apinator \
  -e APINATOR_APP_ID=your-app-id \
  -e APINATOR_KEY=your-key \
  -e APINATOR_SECRET=your-secret \
  -e APINATOR_CLUSTER=eu \
  -- npx -y @apinator/mcp

Start a new Claude Code session afterwards and the five tools are available. You can confirm with /mcp, which lists connected servers and their tools.

Claude Desktop, Cursor and other clients

Every client that reads an mcpServers JSON config takes the same block. In Claude Desktop it goes in claude_desktop_config.json; in Cursor it goes in .cursor/mcp.json at the project root or the global MCP settings.

{
  "mcpServers": {
    "apinator": {
      "command": "npx",
      "args": ["-y", "@apinator/mcp"],
      "env": {
        "APINATOR_APP_ID": "your-app-id",
        "APINATOR_KEY": "your-key",
        "APINATOR_SECRET": "your-secret",
        "APINATOR_CLUSTER": "eu"
      }
    }
  }
}

Environment variables

Variable Required Description
APINATOR_APP_ID yes App ID
APINATOR_KEY yes API key
APINATOR_SECRET yes API secret. Used to sign requests; never returned by any tool
APINATOR_CLUSTER yes Region the app was created in, eu or us
APINATOR_READ_ONLY no 1 removes publish_event, so the agent can only observe
APINATOR_API_URL no REST base URL of a local or self-hosted data plane
APINATOR_WS_URL no WebSocket base URL of a local or self-hosted data plane

The server validates these at startup and exits with a message naming anything missing or malformed, so a typo in the cluster shows up before the first tool call rather than as a confusing 401 later.

Read-only mode

If the agent's job is to investigate rather than build, add APINATOR_READ_ONLY=1 to the env block (or another -e flag in Claude Code). publish_event is removed from the tool list entirely, not merely disabled, so there is nothing for the agent to call by mistake. The four remaining tools are all annotated readOnlyHint, which lets clients that honour the annotation auto-approve them. A session like "watch private-orders-42 for a minute and tell me whether the checkout service is still publishing" needs nothing more than this.

A worked session: live order status

Here is how a session goes once the server is registered. The project is a Next.js storefront with a Node backend; the orders table changes status as a worker moves each order from paid to packed to shipped, and we want the account page to update without a refresh.

Step 1: start from the prompt

The server ships a prompt called add-realtime-feature. In Claude Code it appears as a slash command once the server is connected; in other clients it shows up in the prompts picker. It takes a feature argument and an optional stack:

/mcp__apinator__add-realtime-feature "live order status on the account page" "Next.js + Node"

The prompt tells the agent to read the protocol resource first, then look at the codebase and pick channel and event names before writing much code. It nudges toward private- channels for per-user data, presence- for "who is online", cache- when late joiners need the latest state, and public channels only for data anyone may see. Then it lays out the server side (install the server SDK, publish where the data changes, add an auth endpoint for private channels, keep the secret server-side), the client side (install @apinator/client, connect, subscribe, bind, clean up on unmount), and finally verification with watch_channel.

For this feature the agent proposes private-orders-{userId} with one event, order-status, carrying { orderId, status, updatedAt }. Private, because order history belongs to one customer. It asks whether the account page should also show the current status when it first loads, and we say no, the page already renders that from the database, so a plain private- channel rather than private-cache- is fine.

Step 2: the server side

The agent installs @apinator/server and adds the publish call next to the status update in the worker:

JavaScript
// workers/fulfilment.js
import { Apinator } from "@apinator/server";

const realtime = new Apinator({
  appId: process.env.APINATOR_APP_ID,
  key: process.env.APINATOR_KEY,
  secret: process.env.APINATOR_SECRET,
  cluster: process.env.APINATOR_CLUSTER,
});

export async function setOrderStatus(order, status) {
  await db.orders.update(order.id, { status });
  await realtime.trigger({
    channel: `private-orders-${order.userId}`,
    name: "order-status",
    data: JSON.stringify({ orderId: order.id, status, updatedAt: new Date().toISOString() }),
  });
}

And an auth endpoint as a Route Handler, which checks the channel belongs to the signed-in user before signing:

JavaScript
// app/api/realtime/auth/route.js
import { Apinator } from "@apinator/server";
import { getSession } from "@/lib/session";

const realtime = new Apinator({ /* same four values */ });

export async function POST(request) {
  const session = await getSession(request);
  const form = await request.formData();
  const socketId = form.get("socket_id");
  const channel = form.get("channel_name");

  if (!session || channel !== `private-orders-${session.userId}`) {
    return new Response("Forbidden", { status: 403 });
  }
  return Response.json(realtime.authenticateChannel(socketId, channel));
}

Step 3: the client side

In the account page component:

JavaScript
"use client";
import { useEffect, useState } from "react";
import { Apinator } from "@apinator/client";

export function OrderStatusFeed({ userId, initialOrders }) {
  const [orders, setOrders] = useState(initialOrders);

  useEffect(() => {
    const client = new Apinator({
      appKey: process.env.NEXT_PUBLIC_APINATOR_KEY,
      cluster: process.env.NEXT_PUBLIC_APINATOR_CLUSTER,
      authEndpoint: "/api/realtime/auth",
    });
    client.connect();
    const channel = client.subscribe(`private-orders-${userId}`);
    channel.bind("order-status", (data) => {
      setOrders((prev) =>
        prev.map((o) => (o.id === data.orderId ? { ...o, status: data.status } : o))
      );
    });
    return () => {
      client.unsubscribe(`private-orders-${userId}`);
      client.disconnect();
    };
  }, [userId]);

  // render orders
}

So far this is what any capable agent would produce. The difference starts now.

Step 4: debugging the subscription with sign_channel_auth

The agent opens the account page in the dev server and calls get_channel on private-orders-42. The result is active: false. Nobody is subscribed, so the client subscription failed somewhere. The browser console shows a realtime:subscription_error with status 401, meaning the server rejected the signature the auth endpoint returned.

This is where sign_channel_auth earns its place. The agent reads the socket_id from the realtime:connection_established frame in the console (something like node-eu-1.42) and asks the MCP server what the correct answer would be:

{ "socket_id": "node-eu-1.42", "channel": "private-orders-42" }

The tool returns the auth string the subscription needs, in the form {key}:{hex signature}. The agent then posts the same two values to the project's own auth endpoint and compares. The key prefix matches, but the signatures differ, which means the endpoint signed the same string with a different secret. Looking at the environment, the bug is that .env.local still holds the secret of an older app, while NEXT_PUBLIC_APINATOR_KEY points at the new development app. The client subscribes with one app's key and the endpoint signs with another app's secret, so the server rejects it. The agent replaces the secret, re-runs the comparison, and the two signatures now match byte for byte.

Without the tool, this is the kind of bug that eats an afternoon, because every component looks fine in isolation. With it, the agent had a known-good signature to compare against in one call.

Step 5: watching the event arrive

Now the agent verifies delivery end to end. It starts a watch on the channel:

{ "channel": "private-orders-42", "seconds": 30, "until_events": 1 }

watch_channel connects over WebSocket, signs the private subscription itself, and listens for up to 30 seconds, returning early as soon as one event arrives. While it is listening, the agent runs the real code path, calling the worker's setOrderStatus for order 1001 in the dev environment. (It could equally call publish_event to send a synthetic event, which is useful when the real trigger is awkward to reach, but exercising the actual route proves the whole chain.)

The watch returns:

{
  "channel": "private-orders-42",
  "socket_id": "node-eu-1.57",
  "watched_ms": 1843,
  "stopped": "until_events_reached",
  "events_received": 1,
  "events_kept": 1,
  "events_dropped": 0,
  "events": [
    {
      "event": "order-status",
      "channel": "private-orders-42",
      "data": { "orderId": 1001, "status": "packed", "updatedAt": "2026-10-17T09:14:03.218Z" },
      "received_at": "2026-10-17T09:14:03.301Z",
      "offset_ms": 1792
    }
  ],
  "notes": []
}

The agent now compares the payload against what the client binds. The component reads data.orderId and data.status, both present. But it notices orderId is a number while initialOrders from the page loader has string IDs, so the o.id === data.orderId comparison would never match and the UI would silently ignore every update. It fixes the comparison, and the feature works on the first manual test.

That last catch is the whole argument for the tool. Nothing in the server code, the auth endpoint or the component was wrong on its own. The mismatch only existed in the shape of the event as delivered, and the only way to see it was to receive the event.

Step 6: confirming the client is really subscribed

Finally, with the account page open, the agent calls list_channels with prefix private-orders- and sees private-orders-42 with one subscriber. If you open the page in a second tab, get_channel reports two. Channels exist only while someone is subscribed, so this is a direct check that the client-side code did its job, independent of whether any event has been sent yet.

A note on cache channels

If the agent had chosen private-cache-orders-42 so that a late-arriving tab gets the latest status immediately, the verification order becomes more forgiving. On cache- and private-cache- channels the server replays the last event to each new subscriber, so a watch started after the publish still receives it. The watch result flags this in its notes, pointing out that the first event may be older than the watch itself. On ordinary channels, start the watch first and publish second, or the event is gone before anyone is listening.

Security notes

The MCP server is a trusted tool with real power over your app. Four things to know before you hand it to an agent:

  • The secret stays in the server process. The agent never receives APINATOR_SECRET. Tool results and error messages are scrubbed of it, and API error bodies are shortened to a one-line message. What the agent sees is signatures and event data.
  • publish_event reaches every subscriber. An agent that calls it against your production app is talking to your users. Set APINATOR_READ_ONLY=1 for any agent that should only look.
  • sign_channel_auth mints valid auth for any channel name. It is exactly as powerful as your own auth endpoint with the ownership check removed. Only give the server to agents you would trust with that endpoint.
  • Use a separate app for development. Apinator apps are free, so create one per environment and point the MCP server at the development app. Then the worst case for a wrong publish_event is a stray event in a channel only your dev client is watching.

Where to go next

The MCP Server section of the documentation covers installation, every environment variable, the full tool reference and read-only mode. The package is @apinator/mcp on npm, with source and issues on GitHub. Register it once, and the next time you ask an agent to add something live, it can tell you it works because it watched it happen.