Guide · Real-time / WebSocket

MCP Tools for Ably — channel auth, connection state machine, presence, history recovery

Three Ably behaviours catch developers building MCP real-time tools: JWT token expiry bounds the connection independently of token refresh — Ably initiates token renewal at 80% of the TTL elapsed, not at expiry, but if the authCallback or authUrl returns a new token too slowly (or not at all) the connection drops to disconnected state; the connection does not wait indefinitely for the callback; the connection and channel state machines are independent — a connected connection can have channels in failed state (typically due to capability mismatch or namespace restrictions), and a channel failure does not surface on connection.on('failed') — you must listen on channel.on('failed') separately; and presence state at re-attachment is a snapshot, not an event stream — calling presence.get() after a channel re-attaches returns the current member set, but members who joined and left during the detach window do not appear in that snapshot, meaning presence count can drift if you only process real-time events without periodically reconciling against presence.get().

TL;DR

Server publish: rest.channels.get('ch').publish('event', data). Client subscribe: realtime.channels.get('ch').subscribe('event', handler). Auth: new Ably.Realtime({ authUrl: '/token', authMethod: 'POST' }). Connection health: listen on connection.on('connected' | 'disconnected' | 'failed'). Channel health: listen on channel.on('attached' | 'suspended' | 'failed'). History: channel.history({ untilAttach: true }) to recover messages missed during a detach.

Ably.Rest vs Ably.Realtime — choosing the right client

Ably provides two client types with different connection semantics. Ably.Rest makes individual HTTP requests and holds no persistent connection — use it on MCP server instances for publishing messages, generating tokens, and querying history without consuming a connection slot. Ably.Realtime maintains a persistent WebSocket connection (or long-poll fallback) and is appropriate when the MCP server itself needs to subscribe to channels or track presence in real time.

import Ably from 'ably';

// Server-side publisher — no persistent connection
const rest = new Ably.Rest({
  key: process.env.ABLY_API_KEY!,
});

// Publish from an MCP tool handler
async function publishToChannel(channel: string, event: string, data: unknown) {
  const ch = rest.channels.get(channel);
  await ch.publish(event, data);
}

// Server-side subscriber — persistent connection (use sparingly on server)
const realtime = new Ably.Realtime({
  key: process.env.ABLY_API_KEY!,
  // Prefer authUrl/authCallback for client-facing connections (no key exposure)
});

// Subscribe to a channel
function subscribeToChannel(channel: string, event: string, handler: (msg: Ably.Message) => void) {
  const ch = realtime.channels.get(channel);
  ch.subscribe(event, handler);
  return () => ch.unsubscribe(event, handler);
}

The API key (appId.keyId:keySecret) has root capability over your Ably app — never expose it to clients. Use it only in server-side code (MCP server, token generation endpoint). For browser or mobile clients, issue short-lived tokens with scoped capabilities via the token auth flow.

Token auth — JWT capability grants and refresh

Ably's token auth flow separates credential management from channel access. The MCP server (or a dedicated auth service) holds the API key and issues short-lived tokens to clients. Tokens carry a capability claim that restricts which channels and operations the holder can perform. A token without an explicit capability grant for a channel results in a channel failed state with error code 40160 (unauthorized) — not a connection failure.

import Ably from 'ably';
import * as jwt from 'jsonwebtoken';

const rest = new Ably.Rest({ key: process.env.ABLY_API_KEY! });

// Issue a scoped Ably JWT token
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];

  // Build capability: each channel gets subscribe+publish+presence
  const capability: Record = {};
  for (const ch of channels) {
    capability[ch] = ['subscribe', 'publish', 'presence', 'history'];
  }

  const payload = {
    'x-ably-capability': JSON.stringify(capability),
    'x-ably-clientId': userId,
  };

  return jwt.sign(payload, keySecret, {
    keyid: `${appId}.${keyName}`,
    expiresIn: '1h',
    // Ably renews at 80% elapsed (48min for 1h token)
    // Ensure your authCallback endpoint responds in < 30s or the connection drops
  });
}

// Alternative: use Ably's built-in token request (avoids manual JWT)
async function issueAblyTokenRequest(userId: string, channels: string[]) {
  const capability: Record = {};
  for (const ch of channels) {
    capability[ch] = ['subscribe', 'publish', 'presence', 'history'];
  }

  return rest.auth.createTokenRequest({
    clientId: userId,
    capability: JSON.stringify(capability),
    ttl: 3600 * 1000, // ms
  });
}

// Client configuration using authUrl
// The client automatically renews the token before expiry
const clientConfig: Ably.ClientOptions = {
  authUrl: '/api/ably-token',
  authMethod: 'POST',
  authHeaders: { 'Authorization': 'Bearer YOUR_SESSION_TOKEN' },
  // authCallback alternative if you need custom logic:
  // authCallback: async (tokenParams, callback) => {
  //   const token = await fetchTokenFromServer(tokenParams);
  //   callback(null, token);
  // },
};

Token renewal timing is critical: Ably's client initiates renewal when 80% of the token TTL has elapsed. If your auth endpoint takes more than a few seconds to respond, the connection enters disconnected state and then attempts reconnection, which counts against your connection minutes. Keep the auth endpoint fast (under 500ms) and pre-generate tokens where possible to avoid blocking the renewal path.

Connection state machine

The Ably realtime connection transitions through seven states. Understanding the semantics of each state prevents incorrect retry logic and missed reconnection events.

const realtime = new Ably.Realtime({ authUrl: '/api/ably-token' });

realtime.connection.on((stateChange: Ably.ConnectionStateChange) => {
  const { current, previous, reason } = stateChange;
  console.log(`Connection: ${previous} → ${current}`, reason?.message);

  switch (current) {
    case 'connected':
      // Connection established and authenticated. Channels can now attach.
      break;

    case 'disconnected':
      // Temporary loss — Ably will retry automatically with exponential backoff.
      // Your channels remain in their current state (usually 'attaching').
      // Do NOT manually reconnect here — the SDK handles it.
      break;

    case 'suspended':
      // Extended disconnection (> 2 minutes by default).
      // Ably has given up on fast reconnection but will retry every 30s.
      // Channels enter 'suspended' state. History may have gaps.
      // Show "reconnecting..." UI to users.
      break;

    case 'failed':
      // PERMANENT failure — typically auth error (40101, 40102, 40160)
      // or app suspended (40300). Will NOT automatically recover.
      // Must create a new Ably.Realtime instance.
      console.error('Permanent connection failure:', reason?.code, reason?.message);
      break;

    case 'closing':
      // close() was called, connection is shutting down gracefully.
      break;

    case 'closed':
      // Connection closed cleanly. Channels are detached.
      break;
  }
});

// Check current state before operations
function ensureConnected(): Promise {
  return new Promise((resolve, reject) => {
    if (realtime.connection.state === 'connected') {
      resolve();
      return;
    }
    realtime.connection.once('connected', () => resolve());
    realtime.connection.once('failed', () =>
      reject(new Error('Ably connection permanently failed'))
    );
  });
}

The distinction between disconnected and suspended matters for user experience: during disconnected, the client is actively retrying and recovery is imminent; during suspended, Ably has backed off significantly and channel history may have gaps that require explicit recovery. In failed, no automatic recovery is possible — you must instantiate a new client.

Channel state and message recovery

Each channel has its own state machine that tracks attachment to the Ably network. When a connection is re-established after a suspended period, channels that were attached re-attach automatically, but messages published during the suspension may have been missed. The untilAttach: true option in channel.history() recovers exactly the messages sent since the last successful attachment.

const channel = realtime.channels.get('agent:session:abc123');

channel.on((stateChange: Ably.ChannelStateChange) => {
  const { current, previous, reason, resumed } = stateChange;
  console.log(`Channel ${channel.name}: ${previous} → ${current}`);

  if (current === 'attached') {
    if (!resumed) {
      // Channel re-attached but continuity was broken (messages may have been missed).
      // 'resumed' is false when the channel re-attached after a suspension.
      // Fetch history to fill the gap.
      recoverMissedMessages(channel);
    }
  }

  if (current === 'failed') {
    // Channel-level failure — auth (40160), namespace restriction, or app error.
    // Does NOT affect other channels or the connection.
    console.error(`Channel ${channel.name} failed:`, reason?.code, reason?.message);
  }

  if (current === 'suspended') {
    // Connection is suspended — channel will re-attach when connection recovers.
    // No action needed here; handle in 'attached' + !resumed above.
  }
});

async function recoverMissedMessages(ch: Ably.RealtimeChannel) {
  // untilAttach: true fetches messages published since the last successful attach
  // This is more precise than a fixed time window
  const page = await ch.history({ untilAttach: true, limit: 100 });
  const missedMessages = page.items.reverse(); // history is newest-first
  for (const msg of missedMessages) {
    console.log('Replaying missed message:', msg.name, msg.data);
    // Re-emit through your handler or process directly
  }
}

// Subscribe before attaching to avoid race conditions
channel.subscribe('agent-event', (msg) => {
  console.log('Received:', msg.data);
});

The resumed flag on the state change is your signal: resumed: true means Ably maintained message continuity for you (short disconnection, history buffered); resumed: false means the gap exists and you must query history to recover. Always check this flag rather than assuming continuity after any reconnection.

Presence — snapshots vs real-time events

Ably presence tracks which clients are currently in a channel. The key correctness invariant: presence.get() always returns the authoritative current snapshot, but presence.subscribe() delivers real-time delta events. To avoid drift, always seed your local presence state from presence.get() after channel attachment, then apply delta events on top.

const channel = realtime.channels.get('room:lobby');
const presenceMap = new Map();

async function initPresence() {
  // Wait until channel is attached before fetching presence
  await channel.attach();

  // Seed presence from authoritative snapshot
  const members = await channel.presence.get();
  presenceMap.clear();
  for (const member of members) {
    presenceMap.set(member.clientId!, member);
  }
  console.log(`Initial presence: ${presenceMap.size} members`);

  // Apply real-time deltas
  channel.presence.subscribe((presenceMsg) => {
    const { clientId, action } = presenceMsg;
    if (!clientId) return;

    switch (action) {
      case 'enter':
      case 'update':
        presenceMap.set(clientId, presenceMsg);
        break;
      case 'leave':
        presenceMap.delete(clientId);
        break;
    }
    console.log(`Presence ${action}: ${clientId} — total: ${presenceMap.size}`);
  });
}

// Re-sync presence after a channel re-attachment with broken continuity
channel.on('attached', async (stateChange: Ably.ChannelStateChange) => {
  if (!stateChange.resumed) {
    // Presence may have drifted during the gap — re-seed the snapshot
    const members = await channel.presence.get();
    presenceMap.clear();
    for (const member of members) {
      presenceMap.set(member.clientId!, member);
    }
    console.log(`Re-synced presence after gap: ${presenceMap.size} members`);
  }
});

// Enter presence (for realtime clients that are members themselves)
async function enterPresence(data: { name: string; role: string }) {
  await channel.presence.enter(data);
}

// Update presence data without leaving/re-entering
async function updatePresenceData(data: { name: string; role: string }) {
  await channel.presence.update(data);
}

Presence data is stored in Ably's cluster memory — not persisted to disk. Each presence message has a timestamp set by Ably servers, so client clock skew does not affect ordering. The maximum presence data payload is 64KB per member — store lightweight state (IDs, status strings) rather than full application state in presence data.

Publishing from MCP tool handlers

When an MCP tool needs to push updates to connected clients (e.g., an agent action triggers a UI refresh), the server-side publish via Ably.Rest is appropriate. It uses the API key directly and does not require a persistent connection, making it suitable for serverless and stateless MCP servers.

import Ably from 'ably';

const rest = new Ably.Rest({ key: process.env.ABLY_API_KEY! });

// MCP tool: broadcast result to a room channel
async function broadcastAgentResult(
  sessionId: string,
  result: { toolName: string; output: unknown; durationMs: number }
): Promise {
  const channelName = `agent:session:${sessionId}`;
  await rest.channels.get(channelName).publish('tool-result', {
    ...result,
    publishedAt: new Date().toISOString(),
  });
}

// MCP tool: publish to multiple channels in one HTTP request (batch publish)
async function broadcastToMultipleChannels(
  channels: string[],
  event: string,
  data: unknown
): Promise {
  // Ably REST supports publishing to up to 100 channels per request
  // using the batch publish API
  const messages = channels.map((channel) => ({
    channels: [channel],
    messages: [{ name: event, data }],
  }));

  // Single channel publish for simple cases
  if (channels.length === 1) {
    await rest.channels.get(channels[0]).publish(event, data);
    return;
  }

  // For multi-channel, use the request API directly
  await rest.request('POST', '/messages', 2, {}, messages);
}

The REST publish API is synchronous from the caller's perspective — the await resolves when Ably has accepted the message, not when subscribers have received it. If you need confirmation of delivery to subscribers, use Ably's delivery receipts feature (available on higher plans) or implement an application-level acknowledgement pattern via a separate channel.

Health probe — connection and channel liveness

For MCP servers that hold a persistent Ably connection, health checks should verify both connection state and channel attachment. A connected client with all channels in attached state is fully healthy. A client in disconnected or suspended is degraded. A client in failed is down.

function checkAblyHealth(realtime: Ably.Realtime, channels: string[]): object {
  const connState = realtime.connection.state;
  const connError = realtime.connection.errorReason;

  const channelStates: Record = {};
  for (const name of channels) {
    const ch = realtime.channels.get(name);
    channelStates[name] = ch.state;
  }

  const healthy = connState === 'connected' &&
    Object.values(channelStates).every((s) => s === 'attached');

  return {
    healthy,
    connection: { state: connState, error: connError?.message },
    channels: channelStates,
  };
}

// For stateless REST-only MCP servers, probe Ably reachability via REST stats
async function checkAblyRestHealth(rest: Ably.Rest): Promise {
  try {
    // stats() is a lightweight API call that validates the API key and network path
    const stats = await rest.stats({ limit: 1 });
    return { healthy: true, statsAvailable: stats.items.length >= 0 };
  } catch (err) {
    return { healthy: false, error: String(err) };
  }
}

// Expose health on MCP server startup
const rest = new Ably.Rest({ key: process.env.ABLY_API_KEY! });
async function runStartupCheck() {
  const health = await checkAblyRestHealth(rest);
  if (!health.healthy) {
    console.error('Ably connectivity check failed:', health);
    process.exit(1);
  }
  console.log('Ably connectivity OK');
}

		

AliveMCP monitors Ably-backed MCP endpoints by checking the connection state event cadence and detecting when a client transitions to suspended or failed without recovering — a pattern that indicates either an auth misconfiguration or a network partition that Ably's own retry logic cannot resolve.

Related guides