excalidraw-mcp-collab
Allows an AI agent to draw on an Excalidraw board with per-board access control, respecting read-only policies.
Provides authentication via Firebase ID tokens and stores encrypted scene data and shared history in Firestore.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@excalidraw-mcp-collabdraw a rectangle on the board 'project-alpha'"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
excalidraw-access-backend
Standalone Node + TypeScript backend for a self-hosted Excalidraw fork with
per-board access control (Firebase project excalidraw-team). It provides:
MCP remote endpoint (
ALL /mcp) that lets an AI agent draw on a real collab board as a specific user. The agent's writes respect the board's read-only policy and are attributed in shared history asБот <name>.MCP connect-token mint / list / revoke endpoints.
Filesystem-backed image file service that replaces Firebase Storage, with the same per-board ACL the room server enforces.
The service never modifies the frontend or the room fork; it matches their wire formats (encryption, socket protocol, Firestore scene/history doc shapes).
How it works
Socket auth = exchanged Firebase ID token
The collab (socket.io) server authenticates clients with a Firebase ID
token and runs its own ACL on join-room. The Admin SDK can only mint a
custom token for a uid, so the bot:
admin.auth().createCustomToken(uid)exchanges it for an ID token via Identity Toolkit (
accounts:signInWithCustomToken?key=${FIREBASE_WEB_API_KEY})connects with
auth: { token: idToken }
The room server therefore resolves the bot as the user, so its existing
read/write enforcement applies automatically: a viewer-token bot's
server-broadcast frames are dropped by the room server, and this service also
refuses to broadcast/persist when the token role is viewer.
Encryption
src/encryption.ts replicates the frontend
(packages/excalidraw/data/encryption.ts) exactly using Node Web Crypto
(globalThis.crypto.subtle): a 22-char base64url AES-128-GCM key imported via
JWK { alg: "A128GCM", k, kty: "oct" }, 12-byte random IV. Verified
byte-compatible by round-trip.
Scene + history persistence
src/scene.ts ports the Admin-SDK equivalent of excalidraw-app/data/firebase.ts:
scenes/{roomId}={ sceneVersion, ciphertext, iv }(encrypted elements).shared history index
scenes/{roomId}~history+ per-entry payloadscenes/{roomId}~history~{entryId}, matchingSceneHistoryentry shape andMAX_SCENE_HISTORY_ENTRIESso the frontend HistorySidebar renders bot entries (withauthor).
Byte fields are written as Node Buffer (the Admin SDK has no web-only Bytes
class); the underlying Firestore bytesValue is identical to what the web SDK
Bytes produces, so data.ciphertext.toUint8Array() on the frontend reads the
same bytes.
Related MCP server: @kamiazya/whiteboard-mcp
Endpoints
Method | Path | Auth | Purpose |
|
| Firebase ID token (Bearer) | Mint a connect token for |
|
| Firebase ID token | List caller's tokens. |
|
| Firebase ID token | Revoke a token the caller owns. |
|
| connect token (Bearer or | MCP Streamable HTTP endpoint; lazily attaches a |
|
| optional Firebase ID token | Store raw opaque bytes. |
|
| optional Firebase ID token | Return raw bytes. |
The file bytes are already client-encrypted + compressed; the service stores and returns them verbatim.
MCP tools
list_boards— boards the token's account can reach through the bot, with the bot's access level on each and the board'sdescriptionwhen it has one. For bots with the folders permission each entry also carriesfolder: { folderId, name }when the board sits in one of the owner's folders (a failed folder lookup degrades to the plain list).create_board— new empty board owned by the token's account, bound to the calling bot withwritein the same batch (board doc +boardKeys+ the bot's allow-list entry commit together). Gated by the per-botcanCreateBoardsflag the owner sets in the bot's settings; ateam-visible board additionally requires the owning account to be a member of the shared team. Rate-limited to 10 boards per hour per bot (in-memory). An optionalfolderIdfiles the new board into one of the owner's folders (needs the folders sub-permission below; the folder is resolved before the board is written, and a failed filing is reported asfolderWarningnext to the created board rather than thrown). An optionaldescriptionis stored with the board.set_board_description— sets (or, with"", removes) the short blurb the app shows under a board's name in the board list. Allowed only when the bot could write to the board (allow-list binding, bot policy, account ACL) and the token's account may change the board's settings — its owner, or a team admin on a team board — mirroringfirestore.rules. Writes only the board doc; no collab connection is opened. Descriptions from both tools are normalized to one paragraph of at most 300 characters, the limitfirestore.rulesenforces for browser writes.rename_board— renames a board (title, normalized to one line of at most 120 characters; an empty name is refused). Same permission check asset_board_description; returns{ boardId, title, previousTitle }.list_folders,create_folder— the owner's personal home-page folders (users/{uid}/folders), which group boards without affecting access. Gated by the per-botcanCreateFoldersflag, a sub-permission that only counts whilecanCreateBoardsis also on.create_folderis idempotent by case-insensitive name and returns{ folderId, name, created }; capped at 20 per hour per bot (in-memory) and 100 folders per account.list_foldersonly echoes board ids the calling bot is bound to. Folder docs carry exactly the keysfirestore.rulesallows (name,boardIds,createdAt,updatedAt) — an extra field would make the owner's later edits from the browser fail.move_board_to_folder— files a board the bot can reach (read is enough) into one of the owner's folders, or withfolderId: nulltakes it out of every folder; a board sits in at most one folder, so the move is one batch. Needs the same folders permission. Returns{ boardId, title, folder }.get_bot_info— the bot's permissions (create boards / folders), bound boards and quotas with usage and reset time, before it tries anything.query_elements— reads with the sharedtargetselector (ids, frameIds, frameName, groupId, region, type, role, kind, slot, textContains, textRegex, hasLink, after), compact summaries with labels inline,formatrows/md/graph,aggregate:"bounds", amaxCharscut withnextCursor,source:"stored"for the persisted copy, andscope(folder or board list) to search a whole series from stored scenes.batch_create,update_elements,move_elements,delete_elements,restore,repair_scene,create_diagram,set_table,set_legend,layout_frames,create_frame,frame_add_children,arrange,group_elements,ungroup_elements,reorder,replace_text— writes. Each one plans in a staged transaction (src/engine/), lints what it touched before and after, and commits once. They all takeoptions(dryRun,strict,lint,expect,snap,verify,note) and return one envelope:changed {created, updated, deleted, revived},labels,persisted,ignoredFields,relaidOut/rerouted/collateral,lint {new, resolved, persisting}. Seeread_me(src/guide.ts) for the agent-facing contract.validate_scene,measure_text,render,board_log,export— read-only (MCPreadOnlyHint).rendercovers the whole board, a rectangle, atarget(e.g. a frame by name) or a set of ids, with tiles and sheets for large areas;exportwrites the picture to disk and returns a link that expires instead of pushing bytes through the conversation.copy_elements,create_stack,apply_ops— duplicate a block (also onto another board), keep a column's gaps through later edits, and run several operations as one commit.set_board_profile— the style contract of a board or a whole folder (boardProfiles/{board:<id>|folder:<id>}, server-only): the type scale, spacing, what each palette role and stroke style means, and the patterns that must never wrap. Planners take their defaults from it (so a table on the seventh board matches the first),set_legend {fromProfile:true}draws the legend from it, andvalidate_scene {profile:"visual-qa"}checks the board against it (style_font_size_off_profile,type_scale_violation,hierarchy_inverted,semantic_conflict,role_color_mismatch). A board's own profile wins field by field over its folder's; passing onlyscopereads it back. Without a profile those rules are skipped, not guessed at.
Tools that were folded into others (the read_me "Which tool" section carries
the table for agents): describe_scene, get_bounds, element_at,
render_scene/render_region/render_element, delete_region,
bring_to_front/send_to_back, connect, scene_diff, get_diagram_guide.
Errors are structured: {error:{code, message, retryable, retryAfterSec?, details}} with codes not_found, forbidden, rate_limited, conflict,
invalid_args, unsupported_field, internal.
See docs/verification-tools.md for the engine, the lint, the renderer and the
composite planners.
Each commit broadcasts a SCENE_UPDATE over server-broadcast, merge-persists
the scene into Firestore in a transaction (so a concurrent human session is
never clobbered), writes a journal entry in that same transaction, and appends
a history entry attributed Бот <name>. Commits of one bot are serialized
through a queue, so a persist never snapshots another write half-way through.
The journal lives in scenes/{roomId}/log/{commitId}: what changed, by whom,
with the note the agent passed, and an encrypted copy of the affected elements
as they were before that commit (skipped for very large commits). That is
what board_log reads and what lets restore {from: commitId} (and
mode:"revert") put a board back long after the 24 h tombstones are gone.
Entries carry expiresAt for a Firestore TTL policy — enable it once per
project:
gcloud firestore fields ttls update expiresAt \
--collection-group=log --enable-ttl --project=excalidraw-team
``` The
persist step reports back which stored copies won the version race; the bot
adopts them into memory, so its reads never show what a reload would lose, and
an element the write deliberately created (or revived) is lifted above any
stored tombstone of the same id. A re-created id always gets a version above
its tombstone.
The bot keeps stable ownership of the ids it creates. Only for a short grace
window after it last wrote an element (`RESURRECTION_WINDOW_MS`, 12 s, at most
3 times) does it resist an incoming deletion — that window covers the
stale-tombstone race where an out-of-sync live session drops a just-created
element. Once the window passes, a human deleting or editing a bot element is
respected and wins immediately.
Text metrics and the PNG renderer use the client's fonts, vendored as TTF in
`assets/fonts/` (regenerate with `assets/fonts/build.py`).
## Setup
```bash
cp .env.example .env # fill in the values
npm install
npm run build
npm start # or: npm run devRequired env (see .env.example):
GOOGLE_APPLICATION_CREDENTIALS— absolute path to the service-account JSON (Admin SDK).FIREBASE_WEB_API_KEY— the webapiKey(AIzaSy...) from the SDK config; required for the custom-token → ID-token exchange.WS_SERVER_URL— the collab server (defaulthttp://localhost:3002).FIREBASE_PROJECT_ID(defaultexcalidraw-team),PORT,CORS_ORIGIN,DATA_DIR,PUBLIC_BASE_URL.
Mint a token + paste the MCP config
curl -X POST http://localhost:3015/mcp/tokens \
-H "Authorization: Bearer <FIREBASE_ID_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"boardId":"<roomId>"}'The response configSnippet is a ready-to-paste remote-MCP client config:
{
"mcpServers": {
"excalidraw-board": {
"type": "http",
"url": "http://localhost:3015/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}The agent connecting with that config draws on the board as the token's user.
Deploy notes
Run behind TLS and set
PUBLIC_BASE_URLsomcpUrlin token responses is correct.Mount
DATA_DIRon persistent storage (it replaces Firebase Storage, and holdsexports/— the files behindexportlinks).Set
INTERNAL_SECRET: it signs export links. With it empty the server signs with a per-process key, so every export link dies on restart.The proxy must route
/exports/to the backend (bothproxy/nginx.confandk8s/proxy.confin the stack repo do).The service holds in-memory
CollabBotinstances keyed by connect token; it is intended to run as a single process. Horizontal scaling would need a shared bot registry / sticky routing (not implemented).Firestore security rules must allow the service account to read
boards,boardKeys,teamsand read/writescenes*andmcpTokens. (The Admin SDK bypasses rules;create_boardwritesboards,boardKeysand the caller'sbotsdocument, and the folder tools writeusers/{uid}/folders.)PUBLIC_APP_ORIGIN— origin the Excalidraw app is served from, used to put an openableurlin thecreate_boardresponse. Falls back toPUBLIC_BASE_URL, which is the same origin in the default stack.
Not yet verified live
End-to-end testing needs real credentials and a running room server, which are not available in this build environment. The following paths are structurally complete and type-checked but not exercised against live infrastructure:
Firebase Admin init with a real service account and
verifyIdToken.Custom-token → ID-token exchange against Identity Toolkit, and the room server accepting that ID token and applying read-only for viewer tokens.
Live socket handshake (
init-room→join-room→first-in-room/new-user/room-user-change) andclient-broadcastdecryption / reconciliation timing. The handshake resolves on the first membership event or after a 4s fallback.Actual Firestore writes to
scenes/{roomId}andscenes/{roomId}~history*and the frontend HistorySidebar rendering theБот <name>entries.The frontend reading files written by
PUT /files/*(path-shape and opaque byte passthrough are implemented; the exactContent-Type/CORS headers the frontend expects onGETwere set permissively but not validated against a live client).Fractional index ordering interop: the public
fractional-indexing@3.3.0package is used; the frontend uses@excalidraw/fractional-indexing@3.3.0(a fork with identical key output), assumed byte-compatible but not co-tested.
This server cannot be deployed
Maintenance
Related MCP Connectors
Collaborative whiteboard MCP server — create objects, connectors, C4 diagrams, and manage boards
Real-time collaborative whiteboard — AI agents and humans edit the same board live over MCP.
Remote MCP server for The Colony — a social network for AI agents (posts, DMs, search, marketplace).
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Related MCP Servers
- AlicenseBqualityFmaintenanceSecurity-hardened MCP server for Excalidraw with API key auth, rate limiting, real-time WebSocket sync, and 14 diagramming tools161,098 npm3MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to collaboratively draw and annotate Excalidraw diagrams in real-time via MCP tools, synced to a browser canvas.10Apache 2.0
- FlicenseNot gradedqualityCmaintenanceMCP server for a persistent local whiteboard that lets AI assistants read, add, update, and delete canvas elements, with undo/redo and provenance tracking.-
- FlicenseNot gradedqualityAmaintenanceMCP server for Escalidrau, a collaborative macOS whiteboard. Lets AI agents read, draw, edit, rearrange, and export diagrams on a shared canvas in real time.1-