Skip to main content
Glama
ZunbaRan

tldraw-mcp-app

by ZunbaRan
README.md
# Standalone tldraw MCP App

This repository is an independently runnable MCP Server and MCP App
distribution for tldraw. Clients connect to it over strict MCP
`2026-07-28`; they do not embed this repository's editor or persistence code.
The server does not import OpenChamber or OpenCode. Any compatible client that
supports the MCP Apps UI extension can connect to its Streamable HTTP
endpoint.

The current distribution does not provide a stdio transport or legacy SSE.
Run one HTTP service and configure clients with its URL.

Documentation:

- [Architecture and client/server boundary](docs/ARCHITECTURE.md)
- [Semantic elements protocol v2](docs/SEMANTIC_PROTOCOL_V2.md)
- [Operations, backup, restore, and upgrades](docs/OPERATIONS.md)
- [Automated and real-Host acceptance](docs/ACCEPTANCE.md)
- [Repository development](docs/DEVELOPMENT.md)
- [MCP 2026 Server development guide](docs/MCP_2026_SERVER_DEVELOPMENT_GUIDE.md)
- [MCP 2026 implementation lessons](docs/MCP_2026_IMPLEMENTATION_EXPERIENCE.md)

The repository intentionally keeps the existing interoperability contract:

- standalone distribution version: `1.3.0`
- bundled upstream editor version: `tldraw v5.0.2`
- MCP endpoint: `http://127.0.0.1:39512/mcp`
- health endpoint: `http://127.0.0.1:39512/health`
- App resource: `ui://openchamber/interop-tldraw-contract-v5.0.2`
- model-only contract Tool: `tldraw_read_me`
- preferred model/App Tools: `tldraw_create_view` and
  `tldraw_patch_shapes` (semantic protocol v2)
- compatible model/App Tools: `tldraw_open_canvas` and
  `tldraw_patch_diagram`
- model-only catalog/lifecycle Tools: `tldraw_list_canvases`,
  `tldraw_archive_canvas`, `tldraw_unarchive_canvas`,
  `tldraw_request_delete_canvas`, and `tldraw_delete_canvas`
- App-only Tools: `tldraw_get_canvas_state`, `tldraw_apply_operations`,
  `tldraw_save_canvas`, `tldraw_export_snapshot`,
  `tldraw_list_canvas_revisions`, `tldraw_restore_canvas_revision`, and the
  `tldraw_asset_{begin_upload,append_chunk,commit_upload,read_chunk,abort_upload}`
  content-addressed asset protocol
- persistent, revision-fenced, multi-canvas state with bounded exact history

The `openchamber` segment in the resource URI is retained only as a stable
protocol identity so existing sessions and pins continue to resolve. It is not
a runtime dependency.

> This public source repository is provided for inspection, evaluation, and
> interoperability testing under its all-rights-reserved [LICENSE](LICENSE).
> Public visibility does not make it open source or grant redistribution or
> production-use rights. Review
> [LICENSE.tldraw.md](LICENSE.tldraw.md) and
> [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) before any distribution or
> production use.

## Quick start

Requirements: Node.js 22.18 or newer.

```bash
npm ci
npm run build
npm start
```

In another terminal:

```bash
npm run status
npm run probe
npm stop
```

`npm start` runs a managed background process. Logs, PID metadata, and canvas
state default to `~/.local/share/tldraw-mcp-app` on macOS/Linux and
`%LOCALAPPDATA%\\tldraw-mcp-app` on Windows. It refuses to stop a process it
does not own.

Use an isolated runtime and port for tests:

```bash
TLDRAW_MCP_RUNTIME_DIR=/tmp/tldraw-mcp-test \
TLDRAW_MCP_PORT=39513 \
npm start

TLDRAW_MCP_URL=http://127.0.0.1:39513/mcp npm run probe

TLDRAW_MCP_RUNTIME_DIR=/tmp/tldraw-mcp-test \
TLDRAW_MCP_PORT=39513 \
npm stop
```

## Client connection

The server is transport-level independent of OpenChamber. A compatible client
only needs the MCP URL and MCP Apps support. An OpenCode-shaped example is in
[examples/opencode.json](examples/opencode.json):

```json
{
  "mcp": {
    "interop-tldraw-2026": {
      "type": "remote",
      "url": "http://127.0.0.1:39512/mcp",
      "oauth": false,
      "timeout": 30000,
      "enabled": true
    }
  }
}
```

Existing OpenChamber/OpenCode installations should keep the
`interop-tldraw-2026` connection key shown above. Changing only the service URL
is safe; renaming the key can break restoration of historical MCP App parts.

Clients without MCP Apps negotiation may still use the model-facing Tools.
They receive the same validated `structuredContent` canvas state plus a text
fallback instead of the interactive `ui://` surface. App-only Tools are marked
with `_meta.ui.visibility: ["app"]` and must never be exposed to the model tool
picker.

The package is named `@zunbaran/tldraw-mcp-app`. It remains marked
`private: true` to prevent accidental npm publication; that flag is independent
of this GitHub repository's public visibility. The process, data directory,
Docker image, client configuration, and release artifact are standalone.

## Transport and capability contract

| Capability | Support |
|---|---|
| MCP protocol | strict `2026-07-28` discovery/negotiation |
| Transport | Streamable HTTP at `/mcp` |
| MCP Apps | `io.modelcontextprotocol/ui`, offline `ui://` resource |
| stdio | not implemented |
| legacy SSE / legacy initialize | rejected |
| OAuth | not implemented by this private loopback service |

The protocol and extension versions are independent. "MCP `2026-07-28`"
in this repository refers to the Core protocol and Streamable HTTP wire
contract. "MCP Apps" refers to the separately versioned
[`io.modelcontextprotocol/ui` stable specification dated `2026-01-26`](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx).
The bundled View pins `@modelcontextprotocol/ext-apps` `1.7.5`; capabilities
that exist only in the Apps draft are described as draft SDK capabilities,
not as part of Core `2026-07-28`.

In particular, `ui/download-file` is an optional, capability-negotiated API
implemented by the pinned `ext-apps` SDK. It is not guaranteed by the MCP Apps
stable `2026-01-26` contract. Hosts that do not advertise it still get a fully
usable canvas, but SVG/PNG saving is reported as unavailable rather than being
silently simulated. The same rule applies to the SDK's model-context modality
capability fields: the App filters to advertised modalities, while remaining
compatible with a stable Host that exposes only the base `updateModelContext`
operation.

`/health` proves process and artifact identity only. It does not prove that a
client negotiated Apps, filtered App-only Tools, loaded the sandboxed UI, or
connected AppBridge.

## Model diagram workflow

`tldraw_read_me` is a read-only discovery Tool. With a `canvasId`, it returns
the current canvas and revision when present, plus the exact patch limits and
revision rules.

For new work, use `tldraw_create_view` with semantic elements protocol v2.
It supports geo shapes, text, notes, arrows, lines, frames, groups, hierarchy,
bindings, style and ordering with stable IDs. Use `tldraw_patch_shapes` for
later `create`, `update`, `delete`, `bind`, and `reorder` transactions. Every
patch carries exact `expectedRevision` and unique `transactionId` fences; the
whole final graph commits once or not at all. See
[the protocol guide](docs/SEMANTIC_PROTOCOL_V2.md) and the checked examples:

- [create-view arguments](examples/semantic-create-view.json)
- [semantic-patch arguments](examples/semantic-patch.json)

`tldraw_open_canvas` keeps its existing behavior for compatible node/edge
clients: it opens a known canvas, creates one only when `createIfMissing` is
true, and can seed a new canvas with a bounded `initialDiagram`.

Use `tldraw_patch_diagram` for subsequent model-authored edits. Every call must
include the canvas's exact `expectedRevision`. The `nodes` and `edges` objects
each accept `upsert`, `update`, and `remove` arrays. Upserts are complete
items; updates contain an existing `id` plus only the changed fields. A patch
is applied transactionally, with a maximum of 100 final nodes, 200 final
edges, 100 node operations, and 200 edge operations. Edge endpoints are
validated against the final graph, so a patch can remove a node and its edges,
or add nodes and their connecting edges, in one revision.

Failed validation and stale revisions change nothing. Successful note and
diagram operations advance only their target canvas and retain any saved real
tldraw editor snapshot.

Semantic and legacy operations share the same canvas revision. The server
maintains a compatibility projection instead of creating two independent
canvases.

Canvas catalog/lifecycle operations are scoped to the server-configured
workspace. Archive is revision fenced and recoverable. Deletion requires an
already archived canvas plus a separate request that returns the confirmation
token consumed by `tldraw_delete_canvas`; the record is moved to private
recoverable trash rather than erased. The configured default canvas is
protected from archive and delete.

## Canvas identity and persistence

`tldraw_open_canvas` returns a stable `canvasId` and `revision`. Every mutation
must send the same identity plus `expectedRevision`. Stale or cross-canvas
writes are rejected. `Save` persists the same canvas; it does not invent a new
canvas ID. `tldraw_save_canvas` also requires an `idempotencyKey`, so retrying a
lost response with the same input returns the original committed result rather
than creating another revision. Diagram and note mutations never erase a saved
editor snapshot. The state file is atomically replaced and written with
owner-only permissions.

Inline mode uses lightweight SVG/DOM and never mounts the tldraw editor.
`ontoolinputpartial` incrementally draws complete, valid items from streaming
Tool arguments; incomplete nodes and dangling edges are withheld until they
are safe to render. **Edit** requests fullscreen, the only mode that mounts the
verified official tldraw editor. Model-authored changes use the revision-bound
diagram patch Tool; fullscreen Save submits canonical diagram changes,
App-only note operations, and the lossless editor snapshot together in one
atomic `tldraw_save_canvas` request without changing `canvasId`.

The editor supports up to 16 lossless pages. Exactly one page carries the
`openchamberSemanticPrimaryPage` marker: semantic v2 and legacy model Tools
read and patch only that primary page, while Save persists every page, page
record, shape, binding, and document setting. Existing unmarked snapshots are
migrated by preferring the page that already contains canonical semantic
shapes, then the snapshot's active page. Inline mode previews only the primary
page, so pages are never visually overlaid. Fullscreen restores the normal
tldraw page menu; SVG/PNG exports include only the currently selected page and
include its page-name slug in the filename.

Save retry and in-flight edit detection fence the complete snapshot document,
not only primary-page shapes. Session camera, selection, and current-page state
are excluded from that document fingerprint, and a page switch made while Save
is in flight is restored after the accepted snapshot loads. A snapshotless new
canvas resets the prior document, page records, assets, and view state before
canonical reconciliation. Server-originated resets and reconciliation bypass
shape locks and do not enter the user's Undo history.

Each successful revision archives the superseded full canvas state. The Server
retains at most 32 historical revisions per canvas within a 4 MiB aggregate
history budget. `tldraw_get_canvas_state` can read an exact retained revision,
`tldraw_list_canvas_revisions` identifies retained and current entries, and
`tldraw_restore_canvas_revision` copies a retained revision into a new,
monotonically increasing current revision. Restore requires the current
`expectedRevision` and an `idempotencyKey`; it never rewinds revision numbers
or merges concurrent work.

A historical Tool result or pin stays on its exact retained revision. It is
labelled read-only even when a newer current revision exists; **Refresh latest**
is the explicit action that leaves historical review, while **Restore** creates
the new current revision described above. Separate App instances refresh the
same `canvasId` on focus, return to visibility, HostContext changes, and a
fixed five-second poll, so chat and pin/workbench copies do not remain silently
stale.

If newer Server state arrives while the editor is dirty, the App preserves the
local draft and queues the authoritative state. There is no automatic merge.
**Save** is available only for a conflict-free dirty draft; after a concurrent
update it is disabled while **Review server** shows the semantic delta and
**Discard** explicitly loads the authoritative revision. Fullscreen exit is
blocked while edits remain open. SVG and PNG use tldraw's real renderers,
record an App-only export receipt, and then call the official
`ui/download-file` flow. A compatible Host must confirm and perform the
download; a receipt alone is not a downloaded file.

## App-only asset protocol

The Server exposes a private, App-only chunk protocol for PNG, JPEG, and WebP
bytes. `tldraw_asset_begin_upload` binds a declared byte length, MIME type, and
SHA-256 digest to one existing `canvasId` and returns an upload id plus a
short-lived bearer token. Only that begin result contains the token, and its
human-readable content never repeats it. Append, commit, read, abort, and all
errors return allowlisted fields without filesystem paths.

`tldraw_asset_append_chunk` accepts canonical base64 and an exact byte offset;
decoded chunks are limited to the asset store's advertised `chunkSize`
(currently 192 KiB). Commit publishes content only after byte length, digest,
and detected media type all match. Reads return bounded base64 chunks from a
content-addressed `mcp-asset:<sha256>` object. Asset data lives under the same
configured store root and scope/workspace namespace as canvases and survives a
Server restart.

`tldraw_save_canvas` accepts an optional, strict `assetManifest` containing
`sha256`, `mimeType`, and `byteLength`. The Server extracts only asset records
from `snapshot.document.store`, permits only image records whose `props.src` is
exactly `mcp-asset:<sha256>`, and requires the manifest digest set to match those
references exactly without duplicates. Every manifest item must already exist
in the workspace asset store with identical metadata. Omission is compatible
with older clients only when the snapshot has no asset records.

The validated allowlist is stored in current canvas state and every retained
revision, so historical exact reads and restores keep the asset binding that
was valid at that revision. The bundled App accepts PNG, JPEG, and WebP images
from its file picker, drag-and-drop, and clipboard paths. Each image is limited
to 12 MiB, uploaded through bounded 192 KiB App-only chunks, and replaced with
its verified `mcp-asset:<sha256>` URI before it may be saved. URL images, SVG,
video, embeds, and other open media paths remain disabled. The Server never
deletes assets during Save. Instead, after the canvas store is completely
restored at process startup, revision-aware garbage
collection scans every active and archived canvas, its current state, and all
retained revisions before computing one exact keep-set. Unreferenced committed
blobs remain protected for at least 24 hours, so a newly uploaded image that
has not yet been saved cannot be removed by the next restart. The startup pass
can delete, audit in dry-run mode, or be disabled; `/health` reports only
aggregate counts and byte totals, never asset paths or upload authority.

## Compatible host requirements

Server health alone does not mean a model or App can use this integration. A
compatible host must complete all of these independent steps:

1. negotiate strict MCP `2026-07-28` and carry the Apps extension capability
   on the requests that resolve the Tool result;
2. expose only `model`/`model + app` Tools to the model while keeping App-only
   Tools behind a session/message/server/resource-bound AppBridge;
3. load the verified offline `ui://` resource in an isolated document and
   provide `HostContext` updates for inline, fullscreen, and workbench sizes;
4. implement the pinned Apps SDK's optional `ui/download-file` capability if
   SVG/PNG downloads are advertised, including a user confirmation before
   writing; this API is capability-gated and is not required by Apps stable
   `2026-01-26`;
5. preserve the exact retained revision represented by historical Tool results
   and pins, expose an explicit latest-state refresh, and refresh clean live
   instances from the same `canvasId` on lifecycle signals;
6. advertise only the `updateModelContext` modalities it supports. The App
   emits only advertised text and/or `structuredContent`, never a snapshot or
   an unadvertised modality. Its bounded semantic summary preserves `canvasId`
   and `revision` and reports added, removed, moved, resized, and renamed items
   plus added, removed, repointed, and renamed edges.

The verified App HTML is multi-megabyte. Raising the MCP resource limit was
necessary but not sufficient: directly encoding the 4 MB+ App into a `data:`
URL also failed in the reference Host. Do not infer a universal browser URL threshold;
keep large App markup out of bootstrap URLs.

OpenChamber's reference path is:

```text
Host-owned raw App HTML with injected resource CSP
  → small opaque data: Loader
  → source/nonce/identity-authenticated chunk transfer
  → sandboxed about:srcdoc App
```

The Host sends the raw HTML through nonce- and identity-bound messages. The
Loader validates the complete transfer, drops the raw chunks, and the Host
commits the final document through `iframe.srcdoc`. The final App stays in an
opaque sandbox origin and remains cross-origin with the Host.

tldraw's `StyleEmbedder` creates an internal `about:blank` frame while exporting
so it can measure isolated browser-default styles. Because the final App iframe
uses `sandbox="allow-scripts"` without `allow-same-origin`, its active sandbox
origin flag also applies when it creates a nested browsing context. The helper
frame receives a new opaque origin, so `frame.contentDocument` is unavailable;
adding `about:` to CSP does not make the two opaque origins same-origin.

The reviewed pinned-source patch handles that expected condition by treating
isolated default styles as unavailable and using `NO_STYLES` for default-style
elision. It may inline more filtered computed CSS, but it keeps tldraw's normal
style, font, media, pseudo-element, and SVG-to-PNG pipeline intact. Do not add
`allow-same-origin`: that would weaken the Host/App security boundary rather
than repair the exporter. Loader navigation, nonce, source, session, message,
server, resource, and Tool checks remain fail-closed.

The MCP resource uses verified `data:` URLs for embedded assets and currently
also records `about:` as a non-network frame source. The latter is neither an
origin grant nor a requirement of the exporter fallback, and a Host may reject
or normalize unsupported scheme sources. `connectDomains` stays empty. Hosts
must not interpret these declarations as permission to reach any network
origin.

The full editor keeps bundled icon/font data and the pinned English, Simplified
Chinese, and Traditional Chinese translations on verified `data:` URLs. Test
the actual disconnected editor—source-string or artifact-hash checks cannot
prove that toolbar and minimap icons render or that HostContext locale changes
select the intended language.

Three exact, hash-pinned source patches are part of this distribution: the
opaque-sandbox style exporter fallback, offline translation decoding for
verified JSON `data:` URLs, and export handling that preserves already-resolved
media `data:` URLs instead of trying to fetch them under `connect-src 'none'`.
Their patch bytes plus pristine and patched target hashes are recorded in
`config/upstream-lock.json`; an unknown preimage fails the build closed.

Export acceptance must also run in a real browser with the exact
`sandbox="allow-scripts"` topology, production CSP, and a canvas containing a
rich-text label that exercises `foreignObject` style embedding. Trigger both
SVG and PNG, assert that `frame must have a document` is absent and the bridge
remains usable, and inspect the decoded SVG/PNG rather than only matching the
patch source. Automated renderer/receipt/download-policy checks still do not
prove a file was saved; manual acceptance must choose a path and inspect both
resulting files.

## Environment contract

Primary variables:

| Variable | Default | Purpose |
|---|---|---|
| `TLDRAW_MCP_HOST` | `127.0.0.1` | Listen address |
| `TLDRAW_MCP_PORT` | `39512` | Listen port |
| `TLDRAW_MCP_RUNTIME_DIR` | platform data directory | Managed process files |
| `TLDRAW_MCP_STORE_ROOT` | next to state path | Namespaced catalog, canvas records, history, recoverable trash, and content-addressed assets |
| `TLDRAW_MCP_SCOPE_ID` | `local-user` | Stable deployment/user namespace identity |
| `TLDRAW_MCP_WORKSPACE_ID` | `default-workspace` | Stable workspace namespace identity |
| `TLDRAW_MCP_INSTALLATION_ID` | resolved store root | Stable installation identity seed used to partition App caches |
| `TLDRAW_MCP_STATE_PATH` | runtime state file | Legacy monolithic state import source and rollback evidence |
| `TLDRAW_MCP_CANVAS_ID` | `interop-acceptance` | Initial/default canvas |
| `TLDRAW_MCP_ALLOWED_HOSTS` | loopback hostnames | HTTP Host allowlist; required for non-loopback bind |
| `TLDRAW_MCP_ALLOWED_ORIGINS` | loopback hostnames | HTTP Origin-host allowlist; required for non-loopback bind |
| `TLDRAW_MCP_APP_HTML_PATH` | sibling `dist/app.html` | Verified App bundle |
| `TLDRAW_MCP_PROVENANCE_PATH` | sibling provenance | App integrity metadata |
| `TLDRAW_MCP_ASSET_GC_MODE` | `delete` | Startup-only revision-aware asset maintenance: `delete`, `dry-run`, or `off` |
| `TLDRAW_MCP_URL` | loopback MCP URL | Probe target |

For migration, corresponding `OPENCHAMBER_TLDRAW_MCP_*` variables are accepted
as lower-priority aliases. New deployments should use `TLDRAW_MCP_*`.

Scope and workspace are server configuration, not model Tool arguments. Keep
them stable across restarts and upgrades: changing either intentionally selects
a different logical canvas catalog. Literal namespace values are validated and
hashed before storage paths are derived.

## Builds and verification

The checked-in `dist/app.html` is the verified offline tldraw v5.0.2 App. It
embeds its icons, fonts, translations, CSS, and JavaScript. Normal server
development does not rebuild it:

```bash
npm test
npm run build:server
npm run verify:dist
```

For the complete standalone gate, including an isolated managed service and
real protocol probe, run:

```bash
npm run accept
```

The acceptance runner executes tests, build, verification, start, probe, and
stop serially to keep memory bounded. It prints the path to a private JSON
report. Real sandbox/editor/download acceptance remains a separate Host-level
gate described in [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md).

`npm run build:app` reuses an already verified upstream checkout in `.cache`.
`npm run bootstrap:app` explicitly downloads and prepares the pinned upstream
source. Bootstrap is intentionally heavyweight and is never run implicitly by
`npm ci`, tests, the normal server build, or Docker.

`npm run probe` checks the strict protocol and Apps negotiation, semantic v2
create/patch/idempotent replay, catalog archive/unarchive/double-confirmation
recoverable delete, App-only history/restore visibility, the required Save
`idempotencyKey`, exact historical reads, revision listing, snapshot retention,
and the verified App resource/hash. The protocol and App source-contract tests additionally cover
multi-instance stale-revision recovery, bounded history and restore semantics,
streaming partial rendering, the lightweight inline/fullscreen boundary,
conflict choices, model-context modalities/deltas, and export ordering.

See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for repository maintenance and
[config/upstream-lock.json](config/upstream-lock.json) for the pinned source
and artifact hashes.

## Docker

```bash
docker compose up --build -d
docker compose ps
TLDRAW_MCP_URL=http://127.0.0.1:39512/mcp npm run probe
docker compose down
```

The Compose service binds only to loopback, uses a named volume for canvas
state, and does not rebuild the tldraw App. The process binds `0.0.0.0` inside
the container, so Compose explicitly supplies loopback Host and Origin
allowlists. If a reviewed reverse proxy is added, set both allowlists to the
exact proxy-facing hostnames (hostname only, without scheme, port, path, or
wildcard) before starting.

This image and Compose file are for private development/interoperability only.
They do not confer a tldraw production license and must not be published as a
public production service.