An MCP server that gives an agent a canvas.
The agent pushes HTML frames onto it. A human opens the canvas in a browser, scrolls around, and drops pinned comments on the frames. The agent reads those comments back through MCP and replies inside the thread. That round trip โ draw โ comment โ read feedback โ is the whole product.
One process serves both halves โ an MCP server on stdio and the canvas web app over HTTP,
sharing one SQLite database and one event bus. You start nothing by hand: your MCP client
launches it, and the canvas comes up with it. An agent's push_html
shows up in the human's open browser within seconds, and a human's comment is readable by the agent on the next poll.
One line, nothing to clone and nothing to build:
claude mcp add tracepaper -- bunx github:caffeinum/tracepaper
or in any MCP client's JSON:
{ "mcpServers": { "tracepaper": { "command": "bunx", "args": ["github:caffeinum/tracepaper"] } } }
That is the whole install. bunx
resolves the GitHub repo directly โ no npm package needed โ and the canvas bundle compiles itself on first boot.
Requires Bun 1.3+, since the server uses bun:sqlite
and Bun.serve
:
curl -fsSL https://bun.sh/install | bash
npx -y github:caffeinum/tracepaper
also works โ the bin
is a plain Node launcher that
execs Bun, and prints an install line rather than a missing-interpreter error when Bun is
absent. But it needs Node and Bun to run one Bun program, so prefer bunx
.
BothIf you are changing tracepaper, point the client at your clone (bunx
andnpx
cache the fetched repo.bun run /path/to/tracepaper/src/index.ts
) โ otherwise your agent can sit on a weeks-old copy while you edit, with no sign that it is happening.
For hacking on it:
git clone https://github.com/caffeinum/tracepaper.git
cd tracepaper
bun install
No build step to remember โ the canvas bundle compiles on first boot if it is missing.
(bun run build:web
exists for up-front builds, and you need it after editing
web/canvas.ts
.)
One command runs everything. There is no separate canvas server to start:
bun run src/index.ts # MCP over stdio AND the canvas over HTTP, one process
That is what your MCP client launches, and it is the whole setup โ the canvas comes up with
it, at http://127.0.0.1:4321 (the resolved URL is also written to ~/.tracepaper/server.json
,
and every tool returns it as canvasUrl
).
bun run src/index.ts serve # optional: HTTP only, the canvas with no agent attached
serve
drops the MCP half. It is a convenience for keeping a canvas open in your browser across agent restarts, not a requirement. Running both is safe: a second process started against the same database detects the live canvas and joins it instead of binding another port, so agent pushes land in the tab you already have open.
| env | default | meaning |
|---|---|---|
TRACEPAPER_PORT |
||
4321 |
||
| HTTP port. If busy, the next free port is used and reported. | ||
TRACEPAPER_HOST |
||
127.0.0.1 |
||
| bind address | ||
TRACEPAPER_DB |
||
~/.tracepaper/tracepaper.db |
||
SQLite file. :memory: for throwaway runs. |
stdout belongs to the MCP stdio transport; every log line goes to stderr.
One canvas per machine, by default.~/.tracepaper/tracepaper.db
and port 4321 are global, so two projects both wired with the config below share one canvas โ project B's agent will calllist_frames
and get project A's frames, andpush_html
with noframeId
will land its work next to them. To give a project its own, setTRACEPAPER_DB
andTRACEPAPER_PORT
in that project's MCP config.
The one-liner above works in every MCP client โ Claude Code, Cursor, Windsurf, Zed, Claude
Desktop โ via .mcp.json
, ~/.cursor/mcp.json
or claude_desktop_config.json
:
{ "mcpServers": { "tracepaper": { "command": "npx", "args": ["-y", "github:caffeinum/tracepaper"] } } }
Running from a clone instead? Point at the entry file, with an absolute path โ the client sets its own working directory:
{
"mcpServers": {
"tracepaper": {
"command": "bun",
"args": ["run", "/absolute/path/to/tracepaper/src/index.ts"],
"env": {
"TRACEPAPER_PORT": "4321"
}
}
}
}
Verify it end to end without restarting your agent, using the mcpt CLI (
brew install f/mcptools/mcp
):
mcpt tools bun run src/index.ts
mcpt call push_html --params '{"html":"<h1>hello</h1>","name":"Smoke test"}' bun run src/index.ts
The second prints a canvasUrl
โ open it and the frame is there. That frame is real and stays
on your canvas: delete it from the pin menu, or
mcpt call delete_frame --params '{"frameId":"frm_โฆ"}' bun run src/index.ts
.
Click Share in the toolbar. tracepaper runs cloudflared for you and hands back a public URL anyone can open โ no account, no config, no DNS:
https://trustee-borough-contemporary-molecular.trycloudflare.com
While a share is live it becomes the canvas's address: every tool returns it as canvasUrl
,
so the agent hands out a link that works for someone who is not at your machine. Stop sharing
and it reverts to localhost. Requires cloudflared
on PATH (brew install cloudflared
); the panel says so if it is missing.
Linking to one frame. Select a frame and the address bar becomes
โฆ/#frame=frm_abc123
. Send that and it opens zoomed to that frame. It is a view hint, not a permission boundary โ the whole canvas is still there to scroll to, and anyone with the link sees all of it either way.
Read the warnings in the panel โ they are real:
anyone with the link can view every frame and post comments. There is no sign-in, and a comment is read by your agent as feedback. Only share where you would share a screen.the link dies with the server, and each share mints a new one. It is not a stable address.** updates reach visitors within a few seconds, not instantly**โ a quick tunnel does not carry the SSE stream, so the canvas falls back to its periodic refetch. Measured: 0โ5s.
| tool | what it does |
|---|---|
push_html |
|
{html, name?, frameId?, width?, height?, x?, y?} โ draws a frame. No frameId creates one, auto-placed beside the last and wrapping onto a new row so the canvas stays readable; pass x /y (world px) to place it yourself and group related work; with frameId it replaces that frame's HTML in place and bumps version , resizing it too if you pass width /height . An unknown frameId is an error, never a silent create. Returns {frameId, name, version, url, canvasUrl} . |
|
get_comments |
|
{frameId?, since?, includeResolved?, author?} โ reads the human's feedback oldest-first, resolved excluded by default. Returns {comments, cursor, frames} ; pass cursor back as since to poll for only what is new. Poll with author: "human" or your own replies come back looking like fresh feedback. since accepts an ISO timestamp too, but that matches only comments created after it โ one the human edited or re-opened never comes back, and two written in the same millisecond cannot be separated. Prefer the cursor. |
|
get_frame |
|
{frameId} โ the frame's current HTML, name, size and version. Call it before push_html on a frame you did not author this session: push_html replaces the whole document, so pushing blind discards whatever is there. |
|
list_frames |
|
{} โ every frame with size, position, version, commentCount , unresolvedCount (no HTML), plus canvasUrl . |
|
reply_to_comment |
|
{commentId, text} โ posts a threaded reply as "agent" ; it appears live in the human's open thread. |
|
resolve_comment |
|
{commentId, note?} โ closes the thread so it drops out of get_comments ; note is also posted as an agent reply. Replies are resolved with their root, so your own note does not come back as fresh feedback on the next poll. |
|
tidy_canvas |
|
{} โ re-packs every frame into clean rows, largest first, so nothing overlaps. Moves frames only; html, comments and pins are untouched. |
|
delete_frame |
|
{frameId} โ removes a frame and its comments. |
agent human
----- -----
push_html { html: "<h1>Pricing</h1>โฆ" }
โ { frameId: "frm_a1b2c3d4e5f6",
canvasUrl: "http://127.0.0.1:4321/" }
"open the canvas and tell me what's off"
opens canvasUrl, presses `c`,
clicks the frame, types
"the CTA is buried"
get_comments { frameId: "frm_a1b2c3d4e5f6" }
โ { comments: [{ id: "cmt_9f8eโฆ",
x: 612, y: 340,
text: "the CTA is buried",
author: "human" }],
cursor: "cur_7" }
reply_to_comment { commentId: "cmt_9f8eโฆ",
text: "moving it above the fold" }
sees the reply in the thread
push_html { frameId: "frm_a1b2c3d4e5f6",
html: "<h1>Pricing</h1>โฆ" } the iframe reloads in place,
โ { version: 2 } pan/zoom and pins survive
resolve_comment { commentId: "cmt_9f8eโฆ",
note: "CTA is now first" }
the pin greys out
get_comments { since: "cur_7" } โ poll with the cursor for what's new
Comments are anchored to the frame, not to the DOM, so they survive every push_html
update. Each comment records the frameVersion
it was left on.
- Two-finger scroll pans,
โ
/ctrl
- scroll zooms around the cursor, space-drag or middle-drag pans,
โ0
resets,โ1
zooms to fit. c
(or the toolbar button) arms comment mode: the next click on a frame drops a pin at that frame-local coordinate and opens a composer.esc
cancels.- Click a frame to select it (its label and outline pick up the accent).
f
orโ0
fits the selection to the viewport;โ1
fits everything. - Drag a frame's title to move it. The title is the handle, so dragging never fights with clicking into the page itself. The move is written once on release. - Click a pin to open its thread โ reply, resolve, or delete there.
- Frames land in rows, wrapping about three wide, rather than marching off to the right forever.
Placement checks every existing frame, so a new frame never lands on one โ including after a
resize or an explicit
x
/y
.tidy_canvas
re-packs a canvas that is already tangled. - Sound inside a frame works once you interact with it. Autoplay on load is blocked by the browser's policy for sandboxed frames, which is the behaviour you want โ a canvas full of frames cannot start making noise on its own.
- Double-click a frame to interact with the page inside it; entering also fits it to the
viewport, since that is what you want when you step in to use it.
esc
leaves โ including while your cursor is in a field inside the frame, which needs a small script served with each frame, because a cross-origin sandbox otherwise swallows the keypress. Clicking the canvas or the pill under the frame also leaves. - Chrome floats over a full-bleed canvas; nothing holds a permanent column.
t
(or the toolbar'sList) opens the comment list on the right, grouped by frame with unresolved first โ clicking an entry pans to its pin. It stays closed until you ask for it; the badge on the toolbar button is what tells you feedback is waiting. - Frames render in
<iframe sandbox="allow-scripts allow-forms allow-popups">
with noallow-same-origin
, so pushed HTML cannot reach the canvas app or its storage. - SSE keeps it live: pushes, comments, replies, and resolutions all arrive without a reload.
| method | path | purpose |
|---|---|---|
| GET | / |
|
| canvas app | ||
| GET | /api/frames |
|
{frames} โ frame list, no HTML |
||
| GET | /api/frames/:id |
|
| frame incl. HTML | ||
| POST | /api/frames |
|
create (201) / update in place (200) โ same body as push_html |
||
| DELETE | /api/frames/:id |
|
{ok, id} |
||
| GET | /f/:id |
|
raw frame HTML for the iframe src |
||
| GET | /api/comments?frameId=&since=&includeResolved=&author= |
|
{comments, cursor} |
||
| POST | /api/comments |
|
{frameId, x, y, text, parentId?, author?} โ 201 |
||
| PATCH | /api/comments/:id |
|
{resolved?, text?} |
||
| DELETE | /api/comments/:id |
|
{ok, id} |
||
| GET | /api/events |
|
SSE: frame.created frame.updated frame.deleted comment.created comment.updated comment.deleted |
||
| GET | /api/health |
|
{ok: true, frames, comments} |
Errors are {error: string}
with a real status code. Bad input fails with the zod message โ never a coerced default.
Two commands. The first is the whole suite; the second proves the same loop against a client that is not our code at all.
bun run typecheck # tsc --noEmit, strict
bun run build:web # web/dist/canvas.js โ the server refuses to boot without it
bun test # store units + HTTP/SSE integration + MCP e2e over a real client
bash test/mcpt-loop.sh # the same loop driven by the external `mcpt` CLI
Expect bun test
to report 90 pass / 0 fail across 3 files in ~15s, and the script to
end with OK โ the loop works through mcpt
. Both exit non-zero on any failure, and both
are safe to run repeatedly: every test gets its own temp database, its own HOME
(so your
real ~/.tracepaper/server.json
is never touched), and an ephemeral port. Nothing needs cleaning up between runs.
| file | what it covers |
|---|---|
test/store.test.ts |
|
| ids, cascades, cursor durability (a cursor must survive resolving, editing, re-opening and deleting what it covered), transactional writes, size caps | |
test/http.test.ts |
|
every route on an ephemeral port, plus SSE (one test idles 12s on purpose, to prove a stream outlives Bun's 10s default idleTimeout ) |
|
test/mcp-e2e.test.ts |
|
| the MCP surface, from outside |
test/mcp-e2e.test.ts
never calls a handler directly. Each test spawns
bun src/index.ts
as a child process against a fresh temp-file database, connects the
SDK's Client
over StdioClientTransport
, and drives the real loop: all six tools are
advertised โ get_frame
reads a frame's HTML back before an update replaces it โ push_html
creates a frame whose canvasUrl
and /f/:id
are both live โ
pushing the same frameId
bumps the version without adding a frame โ a bogus frameId
is a tool error that creates nothing โ a comment POSTed over HTTP the way the browser
does comes back through get_comments
โ the returned cursor yields only what is new โ
reply_to_comment
lands in the thread โ resolve_comment
resolves the root and posts
its note as an agent reply โ delete_frame
cascades. It also asserts the process contract: the child exits on stdin EOF alone with no signal, and two server processes can share one database file without losing writes.
Needs the mcpt CLI on
PATH
, and exits 127 with an
install hint if it is missing (brew install f/mcptools/mcp
). It is not part of
bun test
for that reason โ run it before you ship.
bash test/mcpt-loop.sh
It stands up one long-lived serve
process on a pinned port as the human's browser side,
then has mcpt
spawn a fresh stdio server per call against the same temp database
file โ so the handoff it proves is a real cross-process one. Note that mcpt
exits 0 even
when a tool returns isError
, so the script checks isError
itself on every call rather than trusting the exit code.