Guide · Real-time / WebSocket

MCP Tools for Liveblocks — room auth, CRDT storage, presence, broadcast events

Three Liveblocks behaviours catch developers building MCP collaborative tools: LiveObject and LiveList values are CRDT wrappers, not plain JavaScript objects — calling storage.get('key') returns a LiveObject instance, not the underlying data; reading its properties requires .get('field') and converting to a plain object requires .toObject() (for LiveObject) or .toArray() (for LiveList); passing a LiveObject directly to JSON.stringify produces an empty object {} with no error, which is a silent data loss bug; presence and broadcast events serve different purposes with different persistence semantics — presence (room.updatePresence()) is ephemeral state that persists as long as the client is connected and is visible to all current room members via room.getOthers(); broadcast (room.broadcastEvent()) is a fire-and-forget transient event that is not stored, not replayed to new joiners, and not received by the sender; combining them incorrectly (e.g., using broadcast where presence is needed) produces state that disappears when the sender disconnects; and room access requires a server-generated token signed with your Liveblocks secret key — the token carries the roomId and userId and Liveblocks validates the room claim; a token issued for room session-A cannot be used to enter room session-B even with the same userId.

TL;DR

Auth endpoint: liveblocks.prepareSession(userId).allow(roomId, ['room:write']).authorize(). Storage mutation: root.set('key', value) or root.get('list').push(item). Presence: room.updatePresence({ cursor: {x, y} }). Others: room.subscribe('others', callback). Broadcast: room.broadcastEvent({ type: 'ping' }). Webhook verify: HMAC-SHA256 of body with secret key checked against X-Liveblocks-Signature-V2.

Room authorization — server-side token generation

Liveblocks uses a server-generated token to authorize room entry. The auth flow: the client calls your auth endpoint with the room it wants to join; your server validates the request, calls the Liveblocks Node.js SDK to generate a session token scoped to that room and user, and returns the token; the client passes the token to Liveblocks on connect. The token is signed with your Liveblocks secret key and validated by Liveblocks servers.

import { Liveblocks } from '@liveblocks/node';

const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

// Auth endpoint — called by @liveblocks/client when entering a room
// POST /api/liveblocks-auth
// Body: { room: 'room-id' }
async function liveblocksAuthHandler(
  userId: string,
  userInfo: { name: string; avatar?: string },
  roomId: string
): Promise<{ token: string }> {
  // Validate user can access this room (your business logic)
  const canAccess = await userCanAccessRoom(userId, roomId);
  if (!canAccess) throw new Error('Forbidden');

  // Prepare a session with user identity and room permission
  const session = liveblocks.prepareSession(userId, {
    userInfo, // attached to the user's presence and visible to others
  });

  // Grant permission for this specific room
  // Permissions: 'room:write' or 'room:read'
  session.allow(roomId, session.FULL_ACCESS); // equivalent to ['room:write']

  // Generate the signed token
  const { body, status } = await session.authorize();
  if (status !== 200) throw new Error('Liveblocks authorize failed');

  const parsed = JSON.parse(body) as { token: string };
  return { token: parsed.token };
}

// For MCP servers that need server-side room access (no client token needed):
// Use the REST API directly with the secret key
async function getServerSideRoomData(roomId: string) {
  const room = await liveblocks.getRoom(roomId);
  return room;
}

async function sendServerSideEvent(roomId: string, event: unknown) {
  await liveblocks.broadcastEvent(roomId, event);
}

The userInfo object passed to prepareSession is attached to the user's presence and shared with all other room members — it appears in the others list as other.info. Keep it lightweight (name, avatar URL, role) since it is sent over the wire on every room join. The total userInfo payload must be under 1KB.

CRDT storage — LiveObject and LiveList

Liveblocks storage uses a CRDT (Conflict-free Replicated Data Type) model for concurrent editing. Mutations to storage are optimistically applied locally and then synchronized with Liveblocks servers which resolve conflicts deterministically. The storage root is a LiveObject; it can contain nested LiveObject, LiveList, and LiveMap instances, as well as plain JSON-serializable values.

import { createClient, LiveList, LiveMap, LiveObject } from '@liveblocks/client';

const client = createClient({
  authEndpoint: '/api/liveblocks-auth',
});

// Enter a room and access storage
async function enterRoomAndGetStorage(roomId: string) {
  const { room, leave } = client.enterRoom(roomId);

  // Wait until storage is loaded
  const { root } = await room.getStorage();

  return { room, root, leave };
}

// Reading storage — must use .get() and .toObject()
async function readAgentState(roomId: string) {
  const { root, leave } = await enterRoomAndGetStorage(roomId);

  try {
    // root.get() returns the stored value (LiveObject, LiveList, or plain value)
    const agentConfig = root.get('agentConfig');

    if (agentConfig instanceof LiveObject) {
      // Convert LiveObject to plain JS object for serialization
      const plain = agentConfig.toObject();
      // Nested LiveObjects also need toObject() — toObject() is NOT recursive
      // For deeply nested structures, use a recursive conversion:
      return deepConvert(agentConfig);
    }

    return agentConfig; // plain JSON value (string, number, boolean, null, array, object)
  } finally {
    leave(); // always leave to free the connection
  }
}

// Recursive LiveObject → plain object conversion
function deepConvert(value: unknown): unknown {
  if (value instanceof LiveObject) {
    const obj: Record = {};
    for (const [k, v] of Object.entries(value.toObject())) {
      obj[k] = deepConvert(v);
    }
    return obj;
  }
  if (value instanceof LiveList) {
    return value.toArray().map(deepConvert);
  }
  if (value instanceof LiveMap) {
    const obj: Record = {};
    value.forEach((v, k) => { obj[k] = deepConvert(v); });
    return obj;
  }
  return value; // plain JSON value
}

// Writing to storage — mutations are synchronous locally, async to server
async function updateAgentConfig(
  roomId: string,
  updates: Record
): Promise {
  const { room, root, leave } = await enterRoomAndGetStorage(roomId);

  try {
    // Use room.batch() to group multiple mutations into one atomic operation
    room.batch(() => {
      let agentConfig = root.get('agentConfig') as LiveObject> | undefined;

      if (!agentConfig) {
        // Initialize storage structure if it doesn't exist
        agentConfig = new LiveObject({ ...updates });
        root.set('agentConfig', agentConfig);
      } else {
        for (const [key, value] of Object.entries(updates)) {
          agentConfig.set(key, value);
        }
      }
    });

    // Mutations are optimistically applied — no explicit await for server sync
    // The Liveblocks client handles retry and conflict resolution internally
  } finally {
    leave();
  }
}

// LiveList operations
async function appendToList(roomId: string, listKey: string, item: unknown) {
  const { room, root, leave } = await enterRoomAndGetStorage(roomId);
  try {
    room.batch(() => {
      let list = root.get(listKey) as LiveList | undefined;
      if (!list) {
        list = new LiveList([item]);
        root.set(listKey, list);
      } else {
        list.push(item);
      }
    });
  } finally {
    leave();
  }
}

CRDT conflict resolution for LiveObject uses Last Write Wins per field: if two clients set the same field to different values concurrently, one value wins deterministically based on Liveblocks server ordering. LiveList insertions are positional — concurrent insertions at the same position are both preserved (interleaved). This means LiveList length always increases under concurrent writes, never silently dropping items.

Presence vs broadcast events — when to use each

Presence and broadcast events solve different problems. Presence is persistent state about a connected user that is maintained as long as they are in the room. Broadcast is an ephemeral one-way message that fires once and is not buffered or stored. The semantic distinction matters for MCP tools that need to communicate state vs. trigger one-time actions.

// Presence: persistent ephemeral state visible to all current room members
// Use for: cursor position, selected element, user status, typing indicator
// NOT stored — resets to null when user disconnects
function updateCursorPresence(room: ReturnType['room'], cursor: { x: number; y: number } | null) {
  room.updatePresence({ cursor });
  // cursor: null = not hovering anywhere (e.g., user left the viewport)
}

// Read other users' presence
function subscribeToOthers(room: ReturnType['room']) {
  return room.subscribe('others', (others) => {
    // others is an array of { connectionId, id, info, presence }
    const cursors = others
      .filter((other) => other.presence.cursor !== null)
      .map((other) => ({
        userId: other.id,
        name: other.info?.name,
        cursor: other.presence.cursor,
      }));
    console.log('Active cursors:', cursors);
  });
}

// Broadcast event: fire-and-forget transient message
// Use for: reactions, signals, pings, "someone is looking at this" notifications
// NOT persisted, NOT buffered, NOT replayed to late joiners
// The SENDER does NOT receive their own broadcast
function broadcastReaction(room: ReturnType['room'], emoji: string) {
  room.broadcastEvent({ type: 'reaction', emoji });
}

// Receive broadcast events from others
function subscribeToBroadcast(room: ReturnType['room']) {
  return room.subscribe('event', ({ event, connectionId, user }) => {
    console.log('Broadcast received:', event, 'from', user?.id ?? connectionId);
    if (event.type === 'reaction') {
      showReactionAnimation(event.emoji);
    }
  });
}

// MCP server: send a broadcast event to a room without joining it
// (Using Liveblocks Node.js SDK server-side API)
async function notifyRoomFromServer(roomId: string, message: { type: string; [key: string]: unknown }) {
  await liveblocks.broadcastEvent(roomId, message);
  // This is delivered to all current room members as a broadcast event
  // It is NOT stored in room history
}

A common mistake is using broadcast events to maintain state that needs to survive reconnection (e.g., broadcasting a "user selected this item" event and tracking it locally). When a user refreshes, they miss all prior broadcasts. Use storage or presence for state that must persist across reconnections; reserve broadcast for transient signals like animations, cursor flashes, or "someone viewed this page" pings.

Row-level security (RLS) policies

Liveblocks supports Row Level Security policies that determine which users can access which rooms and what permissions they have, evaluated server-side by Liveblocks before granting room entry. RLS policies are defined in the Liveblocks dashboard or via the API and can be static or dynamic (calling your own API to evaluate access).

// Option 1: Token-based auth (most common) — your server controls access
// The auth endpoint (shown above) is the RLS enforcement point

// Option 2: Liveblocks-managed RLS (for simpler use cases)
// Define policies in the Liveblocks dashboard per room pattern:
// Room pattern: "session-{sessionId}"
// Policy: allow users where userId matches the session owner

// Option 3: Programmatic RLS via liveblocks.prepareSession with per-room grants
async function multiRoomAuthHandler(
  userId: string,
  requestedRooms: string[]  // client requests multiple rooms in one call
): Promise<{ token: string }> {
  const session = liveblocks.prepareSession(userId);

  // Evaluate access for each requested room
  for (const roomId of requestedRooms) {
    const canWrite = await userCanWriteToRoom(userId, roomId);
    const canRead = await userCanReadRoom(userId, roomId);

    if (canWrite) {
      session.allow(roomId, session.FULL_ACCESS);
    } else if (canRead) {
      session.allow(roomId, session.READ_ACCESS);
    }
    // If neither, the room is not granted — Liveblocks will reject entry
  }

  const { body, status } = await session.authorize();
  if (status !== 200) throw new Error('Authorization failed');

  const parsed = JSON.parse(body) as { token: string };
  return { token: parsed.token };
}

Webhook verification and room lifecycle events

Liveblocks sends webhooks for room lifecycle events: roomCreated, roomDeleted, userEntered, userLeft, and storageUpdated. Webhooks are signed with your Liveblocks webhook secret and verified via the X-Liveblocks-Signature-V2 header.

import { WebhookHandler } from '@liveblocks/node';

const webhookHandler = new WebhookHandler(process.env.LIVEBLOCKS_WEBHOOK_SECRET!);

// Express webhook endpoint
// app.post('/webhooks/liveblocks', express.raw({ type: 'application/json' }), (req, res) => {
//   let event;
//   try {
//     event = webhookHandler.verifyRequest({
//       headers: req.headers as Record,
//       rawBody: req.body.toString(),
//     });
//   } catch (err) {
//     return res.status(400).send('Invalid webhook signature');
//   }
//
//   switch (event.type) {
//     case 'userEntered':
//       console.log(`User ${event.data.userId} entered room ${event.data.roomId}`);
//       break;
//     case 'userLeft':
//       console.log(`User ${event.data.userId} left room ${event.data.roomId}`);
//       break;
//     case 'storageUpdated':
//       // Storage was modified — event.data.roomId is the room, no diff is sent
//       // Must fetch storage via REST API if you need the new state
//       syncStorageToDatabase(event.data.roomId);
//       break;
//     case 'roomDeleted':
//       cleanupRoomResources(event.data.roomId);
//       break;
//   }
//   res.status(200).send('OK');
// });

// Fetch room storage via REST API (for storageUpdated webhook handler)
async function fetchRoomStorage(roomId: string) {
  // Returns the full storage as a JSON document
  const storageData = await liveblocks.getStorageDocument(roomId, 'json');
  return storageData;
}

// Manual webhook verification without the SDK helper
function verifyLiveblocksWebhook(
  rawBody: string,
  signatureHeader: string,
  webhookSecret: string
): boolean {
  const crypto = require('crypto');
  // Header format: "v1=,t="
  const parts = signatureHeader.split(',');
  const hmacPart = parts.find(p => p.startsWith('v1='))?.replace('v1=', '') ?? '';
  const timestampPart = parts.find(p => p.startsWith('t='))?.replace('t=', '') ?? '';

  const payloadToSign = `${timestampPart}.${rawBody}`;
  const expectedHmac = crypto
    .createHmac('sha256', webhookSecret)
    .update(payloadToSign)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(hmacPart, 'hex'),
    Buffer.from(expectedHmac, 'hex')
  );
}

The storageUpdated webhook does not include the storage diff — it only signals that storage changed. If you need to sync storage to a database on every change, use the REST API getStorageDocument() call inside the webhook handler. This adds latency compared to in-memory CRDT sync, so only do it if your use case genuinely requires server-side persistence of room storage.

Health probe — room and API reachability

Liveblocks does not expose a public health endpoint, but you can probe API reachability by listing rooms — a lightweight call that validates your secret key and network path to Liveblocks servers. For MCP servers that manage specific rooms, probing for the existence of those rooms confirms both API access and room lifecycle state.

// Health probe: verify API connectivity
async function checkLiveblocksHealth(): Promise {
  try {
    // getRooms is lightweight and validates the secret key
    const { data: rooms } = await liveblocks.getRooms({ limit: 1 });
    return {
      healthy: true,
      apiReachable: true,
      sampleRoomCount: rooms.length,
    };
  } catch (err) {
    return {
      healthy: false,
      error: String(err),
      // Common errors:
      // 403 = invalid secret key
      // 429 = rate limited (too many API calls per second)
      // ECONNREFUSED = network partition to Liveblocks
    };
  }
}

// Room-specific health: check if expected rooms exist and are accessible
async function checkRoomHealth(roomIds: string[]): Promise {
  const results = await Promise.allSettled(
    roomIds.map(async (roomId) => {
      const room = await liveblocks.getRoom(roomId);
      return { roomId, exists: true, createdAt: room.createdAt };
    })
  );

  return {
    rooms: results.map((r, i) =>
      r.status === 'fulfilled'
        ? r.value
        : { roomId: roomIds[i], exists: false, error: String((r as PromiseRejectedResult).reason) }
    ),
    allHealthy: results.every((r) => r.status === 'fulfilled'),
  };
}

		

AliveMCP monitors Liveblocks-backed MCP servers by probing the REST API health check and tracking room entry latency. Room entry latency over 1 second typically indicates that the Liveblocks auth endpoint (your server) is slow — the client waits for your auth endpoint to return before Liveblocks establishes the room connection. Keep auth endpoint response time under 200ms to maintain a smooth connection experience.

Related guides