@ansuman-shukla/whiteboard-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -15
- package/dist/canvas/client.js +2 -2
- package/dist/canvas/daemon.js +30 -18
- package/dist/mcp/editor.js +81 -0
- package/dist/mcp/guide.js +2 -2
- package/dist/mcp/server.js +13 -0
- package/dist/mcp/tools.js +29 -21
- package/dist/vscode/whiteboard-mcp-0.1.0.vsix +0 -0
- package/package.json +26 -4
- package/skills/whiteboard-explainer/SKILL.md +24 -2
package/README.md
CHANGED
|
@@ -1,38 +1,52 @@
|
|
|
1
1
|
<a href="#quick-start">
|
|
2
2
|
<picture>
|
|
3
3
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/readme/hero-dark.svg">
|
|
4
|
-
<img src="docs/media/readme/hero-light.svg" width="100%" alt="
|
|
4
|
+
<img src="docs/media/readme/hero-light.svg" width="100%" alt="Whiteboard MCP: a live whiteboard your coding agent draws on. You draw back.">
|
|
5
5
|
</picture>
|
|
6
6
|
</a>
|
|
7
7
|
|
|
8
|
+
<p align="center"><b>Whiteboard MCP</b> is a free, open-source <b>Excalidraw whiteboard MCP server</b> that runs locally, even offline. Your coding agent draws diagrams on a live board beside the chat, and sees what you draw back. Works with Claude Code, Codex, Cursor, Gemini CLI, VS Code, Windsurf, Devin and any other MCP client.</p>
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
claude mcp add whiteboard --scope user -- npx -y @ansuman-shukla/whiteboard-mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
<p align="center"><sub>Needs Node.js 20+. Using Codex, Cursor, Gemini CLI, VS Code or another agent? <a href="#add-it-to-your-agent">Commands for every agent ↓</a></sub></p>
|
|
15
|
+
|
|
8
16
|
<br>
|
|
9
17
|
|
|
10
18
|
<img src="docs/media/dots-and-boxes.gif" width="100%" alt="Playing Dots and Boxes with a coding agent: the human draws a line on the board, the agent sees the move and plays back, until red wins 12 to 4">
|
|
11
19
|
|
|
12
20
|
<p align="center"><sub>Playing Dots and Boxes with an agent: you draw a line, it sees your move and plays back.</sub></p>
|
|
13
21
|
|
|
14
|
-
|
|
22
|
+
## Features
|
|
15
23
|
|
|
16
24
|
<picture>
|
|
17
25
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/readme/features-dark.svg">
|
|
18
|
-
<img src="docs/media/readme/features-light.svg" width="100%" alt="Why
|
|
26
|
+
<img src="docs/media/readme/features-light.svg" width="100%" alt="Why Whiteboard MCP: draws step by step, sees what you draw, right beside the chat, remembers boards, local and private, tiny (one runtime dependency, about 7 KB of tool schema per turn).">
|
|
19
27
|
</picture>
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
## What you can do with it
|
|
30
|
+
|
|
31
|
+
### Understand an architecture
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
*"How does a typical checkout backend work? Draw it on the whiteboard."* The agent draws it piece by piece and highlights what it's explaining. You move a box, circle the part you're unsure about and leave a note. It reads exactly what you changed and answers on the board.
|
|
24
34
|
|
|
25
35
|
<img src="docs/media/architecture.gif" width="100%" alt="An agent chat beside a live whiteboard: the agent draws a checkout architecture step by step, the human drags Postgres, circles the queue and sends a note asking what happens if the email worker is down, and the agent reads the changes and adds a dead-letter queue">
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
### Explain a flow
|
|
38
|
+
|
|
39
|
+
*"How does login work here?"* Arrows stay attached as things move, and the agent answers your notes on its next look.
|
|
28
40
|
|
|
29
41
|
<img src="docs/media/demo.gif" width="100%" alt="The agent draws a login flow step by step, highlights the parts it explains, then answers a note left by the human">
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
### Make something
|
|
44
|
+
|
|
45
|
+
*"Paint the Mona Lisa, then refine it with the pencil."* Shapes, freehand strokes and frames, all editable.
|
|
32
46
|
|
|
33
47
|
<img src="docs/media/mona-lisa.gif" width="100%" alt="An agent paints the Mona Lisa on the board, then refines the sky, hair and dress with pencil strokes">
|
|
34
48
|
|
|
35
|
-
More to try
|
|
49
|
+
### More to try
|
|
36
50
|
|
|
37
51
|
- *"Explain how a request flows through this repo, on the whiteboard."*
|
|
38
52
|
- *"Teach me how a TCP handshake works, step by step."*
|
|
@@ -40,34 +54,89 @@ More to try:
|
|
|
40
54
|
- *"Compare option A and option B side by side on the board."*
|
|
41
55
|
- *"I circled something on the board, take a look."*
|
|
42
56
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<a name="quick-start"></a>
|
|
57
|
+
## Quick start
|
|
46
58
|
|
|
47
59
|
<picture>
|
|
48
60
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/readme/steps-dark.svg">
|
|
49
61
|
<img src="docs/media/readme/steps-light.svg" width="100%" alt="Get started in a minute: 1. Add it to your agent with one npx line. 2. Start a new chat. 3. Ask for a drawing.">
|
|
50
62
|
</picture>
|
|
51
63
|
|
|
52
|
-
|
|
64
|
+
### Add it to your agent
|
|
65
|
+
|
|
66
|
+
Needs Node.js 20+. Add it, then start a new chat:
|
|
53
67
|
|
|
54
68
|
```sh
|
|
69
|
+
# Claude Code
|
|
55
70
|
claude mcp add whiteboard --scope user -- npx -y @ansuman-shukla/whiteboard-mcp
|
|
71
|
+
|
|
72
|
+
# Codex CLI
|
|
73
|
+
codex mcp add whiteboard -- npx -y @ansuman-shukla/whiteboard-mcp
|
|
74
|
+
|
|
75
|
+
# Gemini CLI
|
|
76
|
+
gemini mcp add --scope user whiteboard npx -y @ansuman-shukla/whiteboard-mcp
|
|
77
|
+
|
|
78
|
+
# Devin CLI
|
|
79
|
+
devin mcp add whiteboard --scope user -- npx -y @ansuman-shukla/whiteboard-mcp
|
|
80
|
+
|
|
81
|
+
# VS Code
|
|
82
|
+
code --add-mcp '{"name":"whiteboard","command":"npx","args":["-y","@ansuman-shukla/whiteboard-mcp"]}'
|
|
56
83
|
```
|
|
57
84
|
|
|
58
|
-
|
|
85
|
+
**Cursor, Claude Desktop, Windsurf and other JSON configs** (`~/.cursor/mcp.json`, Claude Desktop's config, …):
|
|
59
86
|
|
|
60
|
-
|
|
87
|
+
```json
|
|
88
|
+
{ "mcpServers": { "whiteboard": { "command": "npx", "args": ["-y", "@ansuman-shukla/whiteboard-mcp"] } } }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**OpenCode** (`opencode.json`):
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{ "mcp": { "whiteboard": { "type": "local", "command": ["npx", "-y", "@ansuman-shukla/whiteboard-mcp"] } } }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Agents without MCP, project-level configs and more: [client setup](docs/clients.md). For boards in a tab beside the chat, install the [IDE panel](docs/ide-panel.md). The bundled [skill](docs/clients.md#the-skill-recommended-for-every-agent) teaches the agent when a drawing helps.
|
|
98
|
+
|
|
99
|
+
## Whiteboard MCP vs Excalidraw MCP
|
|
61
100
|
|
|
62
101
|
<a href="docs/comparison.md">
|
|
63
102
|
<picture>
|
|
64
103
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/readme/compare-dark.svg">
|
|
65
|
-
<img src="docs/media/readme/compare-light.svg" width="100%" alt="
|
|
104
|
+
<img src="docs/media/readme/compare-light.svg" width="100%" alt="Whiteboard MCP vs Excalidraw MCP: Whiteboard MCP sees what you draw back as a short list of changes, screenshots its own board, edits one shape in place, keeps boards beyond the chat, works in any MCP client plus a CLI, and converts Mermaid. Excalidraw MCP has animated camera moves and zero install.">
|
|
66
105
|
</picture>
|
|
67
106
|
</a>
|
|
68
107
|
|
|
69
108
|
<p align="center"><sub>Want a quick one-off diagram inside Claude or ChatGPT on the web? Excalidraw MCP is great for that. <a href="docs/comparison.md">Full comparison →</a></sub></p>
|
|
70
109
|
|
|
110
|
+
## FAQ
|
|
111
|
+
|
|
112
|
+
### Is there an Excalidraw MCP server that runs locally and works offline?
|
|
113
|
+
|
|
114
|
+
Yes, that's Whiteboard MCP. It runs a small daemon on `127.0.0.1` and serves the Excalidraw viewer with its fonts bundled. After the first `npx` download it needs no internet, no account and no API key. Boards are plain JSON files in `~/.whiteboard-mcp`.
|
|
115
|
+
|
|
116
|
+
### How do I let Claude Code, Codex or Cursor draw diagrams?
|
|
117
|
+
|
|
118
|
+
Add the MCP server with the one-line command for your agent [above](#add-it-to-your-agent), start a new chat and ask: *"Draw how this repo handles a request on the whiteboard."* The agent draws step by step on a live Excalidraw board beside the chat.
|
|
119
|
+
|
|
120
|
+
### Can the agent see what I draw on the whiteboard?
|
|
121
|
+
|
|
122
|
+
Yes. Every time it looks, it gets a short list of what you added, moved or deleted, plus any notes you left. It can also take a PNG screenshot of the board to check its own layout.
|
|
123
|
+
|
|
124
|
+
### Which agents and editors does it work with?
|
|
125
|
+
|
|
126
|
+
Any MCP client: Claude Code, Claude Desktop, OpenAI Codex, Cursor, Gemini CLI, VS Code (Copilot agent mode), Windsurf, Devin, OpenCode and more. Agents without MCP can use the plain `whiteboard-mcp call` CLI. The [IDE panel](docs/ide-panel.md) shows boards in a tab beside the chat in VS Code, Cursor, Windsurf and Devin Desktop; elsewhere in an editor or desktop app, the agent opens them in the app's in-app browser, and in a plain terminal they open in your browser.
|
|
127
|
+
|
|
128
|
+
### Can it turn Mermaid into an Excalidraw diagram, or export it?
|
|
129
|
+
|
|
130
|
+
Yes. `canvas_mermaid` converts Mermaid (flowcharts, sequence diagrams, …) into editable hand-drawn elements, and `canvas_export` saves the board as `.excalidraw` or `.png` in your project.
|
|
131
|
+
|
|
132
|
+
### Are boards saved? Can I reopen one later?
|
|
133
|
+
|
|
134
|
+
Each chat gets its own board, saved on disk. Any later chat, even in another folder, can reopen it by name or find it by the text on it, and you can keep frozen copies in a saved library. See [boards](docs/boards.md).
|
|
135
|
+
|
|
136
|
+
### Is it free and private?
|
|
137
|
+
|
|
138
|
+
Yes. It's MIT licensed with no license key or paid tier. Everything stays on your machine: the daemon only listens on `127.0.0.1` and every API call needs a per-user token. See [security](docs/security.md).
|
|
139
|
+
|
|
71
140
|
<br>
|
|
72
141
|
|
|
73
142
|
<p align="center">
|
package/dist/canvas/client.js
CHANGED
|
@@ -130,8 +130,8 @@ export class CanvasClient {
|
|
|
130
130
|
return this.call('PUT', API.canvas(id), meta);
|
|
131
131
|
}
|
|
132
132
|
/** Asks the IDE panel host of the chat's window (`host`) or workspace to show the board. False when none qualifies. */
|
|
133
|
-
async show(id, workspace, host) {
|
|
134
|
-
const request = { workspace, ...(host ? { host } : {}) };
|
|
133
|
+
async show(id, workspace, host, wait = false) {
|
|
134
|
+
const request = { workspace, ...(host ? { host } : {}), ...(wait ? { wait } : {}) };
|
|
135
135
|
try {
|
|
136
136
|
await this.call('POST', API.show(id), request, 8000);
|
|
137
137
|
return true;
|
package/dist/canvas/daemon.js
CHANGED
|
@@ -262,14 +262,16 @@ export async function startDaemon(opts = {}) {
|
|
|
262
262
|
}
|
|
263
263
|
/**
|
|
264
264
|
* The chat's own window (hostId equals the hint) wins. Otherwise a window whose folders contain the
|
|
265
|
-
* board's workspace, focused first, then the most recent
|
|
266
|
-
*
|
|
267
|
-
*
|
|
265
|
+
* board's workspace, focused first, then the most recent. While the hinted window may still connect
|
|
266
|
+
* (`strict`), other identified windows are skipped; once it can't (a stale id after Reload Window),
|
|
267
|
+
* the folder decides. No match means no host: a board never opens in a window of another folder.
|
|
268
268
|
*/
|
|
269
|
-
function pickHost(workspace, hint) {
|
|
269
|
+
function pickHost(workspace, hint, strict) {
|
|
270
270
|
const rank = (h) => {
|
|
271
|
-
if (hint !== undefined && h.hostId
|
|
272
|
-
return
|
|
271
|
+
if (hint !== undefined && h.hostId === hint)
|
|
272
|
+
return 3;
|
|
273
|
+
if (strict && hint !== undefined && h.hostId !== undefined)
|
|
274
|
+
return 0;
|
|
273
275
|
if (!workspace || !h.folders.some((f) => contains(f, workspace)))
|
|
274
276
|
return 0;
|
|
275
277
|
return h.focused ? 2 : 1;
|
|
@@ -285,24 +287,26 @@ export async function startDaemon(opts = {}) {
|
|
|
285
287
|
}
|
|
286
288
|
/**
|
|
287
289
|
* Right after the daemon starts (the agent's first call started it), the chat's window hasn't found it
|
|
288
|
-
* yet
|
|
290
|
+
* yet: a show that names its window, or comes from an agent inside an editor (`wait`), waits briefly
|
|
291
|
+
* for a host instead of answering no_host. A hinted show waits for its own window first.
|
|
289
292
|
*/
|
|
290
|
-
function show(canvasId, workspace, hint, res) {
|
|
291
|
-
const
|
|
293
|
+
function show(canvasId, workspace, hint, wait, res) {
|
|
294
|
+
const graceLeft = startedAt + hostGraceMs - Date.now();
|
|
295
|
+
const waiting = graceLeft > 0 && (hint !== undefined || wait);
|
|
296
|
+
const host = pickHost(workspace, hint, waiting);
|
|
292
297
|
if (host)
|
|
293
298
|
return showOn(host, canvasId, res);
|
|
294
|
-
|
|
295
|
-
if (hint === undefined || graceLeft <= 0)
|
|
299
|
+
if (!waiting)
|
|
296
300
|
return Promise.resolve(sendJson(res, 409, { error: 'no_host' }));
|
|
297
301
|
return new Promise((done) => {
|
|
298
302
|
const end = () => {
|
|
299
|
-
hostWaits.delete(
|
|
303
|
+
hostWaits.delete(waiter);
|
|
300
304
|
clearTimeout(timer);
|
|
301
305
|
res.off('close', onClientGone);
|
|
302
306
|
};
|
|
303
|
-
const
|
|
307
|
+
const waiter = {
|
|
304
308
|
retry: () => {
|
|
305
|
-
const found = pickHost(workspace, hint);
|
|
309
|
+
const found = pickHost(workspace, hint, true);
|
|
306
310
|
if (!found)
|
|
307
311
|
return;
|
|
308
312
|
end();
|
|
@@ -318,9 +322,15 @@ export async function startDaemon(opts = {}) {
|
|
|
318
322
|
end();
|
|
319
323
|
done();
|
|
320
324
|
};
|
|
321
|
-
const timer = setTimeout(() =>
|
|
325
|
+
const timer = setTimeout(() => {
|
|
326
|
+
const fallback = pickHost(workspace, hint, false);
|
|
327
|
+
if (!fallback)
|
|
328
|
+
return waiter.finish(409, { error: 'no_host' });
|
|
329
|
+
end();
|
|
330
|
+
void showOn(fallback, canvasId, res).then(done);
|
|
331
|
+
}, graceLeft);
|
|
322
332
|
res.once('close', onClientGone);
|
|
323
|
-
hostWaits.add(
|
|
333
|
+
hostWaits.add(waiter);
|
|
324
334
|
});
|
|
325
335
|
}
|
|
326
336
|
function showOn(host, canvasId, res) {
|
|
@@ -369,13 +379,15 @@ export async function startDaemon(opts = {}) {
|
|
|
369
379
|
return sendJson(res, 200, { ...store.ensure(id, { title, workspace, chat }), viewers: viewerCount(id) });
|
|
370
380
|
}
|
|
371
381
|
case 'POST show': {
|
|
372
|
-
const { workspace, host: hint } = await readJson(req);
|
|
382
|
+
const { workspace, host: hint, wait } = await readJson(req);
|
|
373
383
|
if (workspace !== undefined && typeof workspace !== 'string')
|
|
374
384
|
throw new HttpError(400, 'workspace must be a string');
|
|
385
|
+
if (wait !== undefined && typeof wait !== 'boolean')
|
|
386
|
+
throw new HttpError(400, 'wait must be a boolean');
|
|
375
387
|
if (hint !== undefined && (typeof hint !== 'string' || !hint || hint.length > MAX_HOST_ID))
|
|
376
388
|
throw new HttpError(400, 'host must be a non-empty string');
|
|
377
389
|
requireCanvas();
|
|
378
|
-
return show(id, workspace, hint, res);
|
|
390
|
+
return show(id, workspace, hint, wait === true, res);
|
|
379
391
|
}
|
|
380
392
|
case 'DELETE ':
|
|
381
393
|
if (!store.remove(id))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
const IDE_ENV = ['WINDSURF_EXT_HOST_PID', 'VSCODE_PID', 'VSCODE_IPC_HOOK_CLI', 'CURSOR_TRACE_ID'];
|
|
4
|
+
const HOST_ENV = ['WHITEBOARD_HOST_ID', 'WINDSURF_EXT_HOST_PID'];
|
|
5
|
+
const WANTED = new Set([...IDE_ENV, ...HOST_ENV, 'TERM_PROGRAM']);
|
|
6
|
+
const MAX_DEPTH = 12;
|
|
7
|
+
const PS_TIMEOUT_MS = 2000;
|
|
8
|
+
/**
|
|
9
|
+
* The editor window this agent runs in, as the `hostId` its panel host announces, so the board opens
|
|
10
|
+
* beside this chat and not in another window: `WHITEBOARD_HOST_ID` when a host sets it explicitly, else
|
|
11
|
+
* Windsurf/Devin Desktop's `WINDSURF_EXT_HOST_PID` (the extension host that started the chat, which is
|
|
12
|
+
* the process a panel extension runs in).
|
|
13
|
+
*/
|
|
14
|
+
export function hostHint(env = process.env) {
|
|
15
|
+
return env.WHITEBOARD_HOST_ID?.trim() || env.WINDSURF_EXT_HOST_PID?.trim() || undefined;
|
|
16
|
+
}
|
|
17
|
+
/** Agents running inside an editor should get the board in a panel, never a separate browser window. */
|
|
18
|
+
export function insideIde(env = process.env) {
|
|
19
|
+
return IDE_ENV.some((k) => env[k]) || env.TERM_PROGRAM === 'vscode';
|
|
20
|
+
}
|
|
21
|
+
/** `ps -E` output: the parent pid, the command line, then the environment as KEY=value words. */
|
|
22
|
+
export function parsePs(output) {
|
|
23
|
+
const words = output.trim().split(/\s+/);
|
|
24
|
+
if (!/^\d+$/.test(words[0] ?? ''))
|
|
25
|
+
return undefined;
|
|
26
|
+
const ppid = Number(words[0]);
|
|
27
|
+
const env = {};
|
|
28
|
+
for (const word of words.slice(1)) {
|
|
29
|
+
const eq = word.indexOf('=');
|
|
30
|
+
const key = word.slice(0, eq);
|
|
31
|
+
if (eq > 0 && WANTED.has(key))
|
|
32
|
+
env[key] = word.slice(eq + 1);
|
|
33
|
+
}
|
|
34
|
+
return { ppid, env };
|
|
35
|
+
}
|
|
36
|
+
/** A process's parent and the environment it started with; undefined where that can't be read (Windows, other users). */
|
|
37
|
+
export async function readProc(pid, platform = process.platform) {
|
|
38
|
+
try {
|
|
39
|
+
if (platform === 'linux') {
|
|
40
|
+
const ppid = Number(/^PPid:\s*(\d+)/m.exec(await readFile(`/proc/${pid}/status`, 'utf8'))?.[1]);
|
|
41
|
+
const env = {};
|
|
42
|
+
for (const entry of (await readFile(`/proc/${pid}/environ`, 'utf8')).split('\0')) {
|
|
43
|
+
const eq = entry.indexOf('=');
|
|
44
|
+
if (eq > 0 && WANTED.has(entry.slice(0, eq)))
|
|
45
|
+
env[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
46
|
+
}
|
|
47
|
+
return Number.isInteger(ppid) ? { ppid, env } : undefined;
|
|
48
|
+
}
|
|
49
|
+
if (platform !== 'darwin')
|
|
50
|
+
return undefined;
|
|
51
|
+
const output = await new Promise((done, fail) => execFile('ps', ['-E', '-ww', '-o', 'ppid=,command=', '-p', String(pid)], { timeout: PS_TIMEOUT_MS }, (err, stdout) => (err ? fail(err) : done(stdout))));
|
|
52
|
+
return parsePs(output);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The agent's own environment, else its ancestors': some agents (Codex) start MCP servers with only a few
|
|
60
|
+
* variables, so the window id their editor gave them, or the terminal they run in, is found up the
|
|
61
|
+
* process tree. `WHITEBOARD_HOST_ID` set but empty means "no window": nothing is looked up.
|
|
62
|
+
*/
|
|
63
|
+
export async function editorWindow(env = process.env, start = process.ppid, read = readProc) {
|
|
64
|
+
const host = hostHint(env);
|
|
65
|
+
let ide = insideIde(env);
|
|
66
|
+
if (host || env.WHITEBOARD_HOST_ID !== undefined)
|
|
67
|
+
return { ...(host ? { host } : {}), ide: ide || !!host };
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (let pid = start; pid > 1 && !seen.has(pid) && seen.size < MAX_DEPTH;) {
|
|
70
|
+
seen.add(pid);
|
|
71
|
+
const proc = await read(pid);
|
|
72
|
+
if (!proc)
|
|
73
|
+
break;
|
|
74
|
+
const found = hostHint(proc.env);
|
|
75
|
+
if (found)
|
|
76
|
+
return { host: found, ide: true };
|
|
77
|
+
ide ||= insideIde(proc.env);
|
|
78
|
+
pid = proc.ppid;
|
|
79
|
+
}
|
|
80
|
+
return { ide };
|
|
81
|
+
}
|
package/dist/mcp/guide.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export const INSTRUCTIONS = `Live whiteboard shown to the human beside the chat: you draw, they watch and can draw back or leave notes. Use it whenever a picture explains better than text (architecture, flows, sequences, state machines, data structures, comparisons). Each chat has its own board; call canvas_guide once, then canvas_open with a topic title, and draw step by step while narrating briefly. The tools show the board
|
|
1
|
+
export const INSTRUCTIONS = `Live whiteboard shown to the human beside the chat: you draw, they watch and can draw back or leave notes. Use it whenever a picture explains better than text (architecture, flows, sequences, state machines, data structures, comparisons). Each chat has its own board; call canvas_guide once, then canvas_open with a topic title, and draw step by step while narrating briefly. The tools show the board themselves; open board links they give you in your in-app browser or preview tool, never an external browser. To build on another chat's drawing (any folder), canvas_open {board}; canvas_search finds boards and saved copies (canvas_save). When the human mentions the board, call canvas_read.`;
|
|
2
2
|
export const GUIDE = `# Explaining on the live canvas
|
|
3
3
|
|
|
4
4
|
## Loop
|
|
5
|
-
1. canvas_open {title:"<topic>"} once: it shows this chat's board beside the chat.
|
|
5
|
+
1. canvas_open {title:"<topic>"} once: it shows this chat's board beside the chat. Board links go in your in-app browser or preview tool. Plan: 3-8 boxes beat 30.
|
|
6
6
|
2. Draw in steps: one canvas_draw per idea (components, then flow, then notes), each followed by a sentence or two in chat that points at the canvas ("the blue boxes are services"). Keep chat short; the canvas carries the detail.
|
|
7
7
|
3. canvas_focus {ids, highlight:true} to point at what you are talking about.
|
|
8
8
|
4. canvas_screenshot once after the main layout to check overlaps and cut-off text.
|
package/dist/mcp/server.js
CHANGED
|
@@ -6,6 +6,17 @@ import { ToolContext, callTool, getTool, toolList } from './tools.js';
|
|
|
6
6
|
export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
7
7
|
const ROOTS_TIMEOUT_MS = 3000;
|
|
8
8
|
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
9
|
+
const debug = (msg) => {
|
|
10
|
+
if (process.env.WHITEBOARD_DEBUG === '1')
|
|
11
|
+
process.stderr.write(`[whiteboard-mcp] ${msg}\n`);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A client that renders UI (it announces the `io.modelcontextprotocol/ui` extension) is a chat app with
|
|
15
|
+
* its own window, not a terminal: its agent opens boards in the app's in-app browser, never the system browser.
|
|
16
|
+
*/
|
|
17
|
+
export function clientIsApp(capabilities) {
|
|
18
|
+
return isObject(capabilities) && isObject(capabilities.extensions) && isObject(capabilities.extensions['io.modelcontextprotocol/ui']);
|
|
19
|
+
}
|
|
9
20
|
/** MCP over stdio. Never writes anything but JSON-RPC frames to `output`. */
|
|
10
21
|
export function startMcpServer(opts = {}) {
|
|
11
22
|
const session = opts.session ?? new Session();
|
|
@@ -29,6 +40,8 @@ export function startMcpServer(opts = {}) {
|
|
|
29
40
|
case 'initialize': {
|
|
30
41
|
const p = isObject(params) ? params : {};
|
|
31
42
|
clientWantsRoots = isObject(p.capabilities) && isObject(p.capabilities.roots);
|
|
43
|
+
ctx.app = clientIsApp(p.capabilities);
|
|
44
|
+
debug(`initialize: ${isObject(p.clientInfo) ? String(p.clientInfo.name) : 'unknown client'}${ctx.app ? ', chat app' : ''}`);
|
|
32
45
|
const requested = typeof p.protocolVersion === 'string' ? p.protocolVersion : '';
|
|
33
46
|
return {
|
|
34
47
|
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[0],
|
package/dist/mcp/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
|
4
4
|
import { CanvasClient, CanvasError } from '../canvas/client.js';
|
|
5
5
|
import { describeChanges, describeScene } from '../shared/describe.js';
|
|
6
6
|
import { ELEMENT_SPEC_FIELDS, buildElements, deleteElements } from '../shared/elements.js';
|
|
7
|
+
import { editorWindow } from './editor.js';
|
|
7
8
|
import { closestBoards, findBoard, whereFrom } from './find.js';
|
|
8
9
|
import { GUIDE } from './guide.js';
|
|
9
10
|
import { openViewer } from './open.js';
|
|
@@ -21,10 +22,17 @@ export class ToolContext {
|
|
|
21
22
|
client;
|
|
22
23
|
/** Boards already created this process, with their current titles. */
|
|
23
24
|
titles = new Map();
|
|
25
|
+
/** The client is a chat app with its own window (not a terminal): boards go in its in-app browser. */
|
|
26
|
+
app = false;
|
|
27
|
+
window;
|
|
24
28
|
constructor(session, connectOpts = {}) {
|
|
25
29
|
this.session = session;
|
|
26
30
|
this.connectOpts = connectOpts;
|
|
27
31
|
}
|
|
32
|
+
/** The editor window the agent runs in, looked up once (it may take a walk up the process tree). */
|
|
33
|
+
editor() {
|
|
34
|
+
return (this.window ??= editorWindow());
|
|
35
|
+
}
|
|
28
36
|
connect() {
|
|
29
37
|
this.client ??= CanvasClient.connect({ autoStart: true, ...this.connectOpts }).catch((err) => {
|
|
30
38
|
this.client = undefined;
|
|
@@ -41,18 +49,9 @@ export class ToolContext {
|
|
|
41
49
|
return new ChatContext(this, await this.session.resolveChat(meta));
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
/**
|
|
48
|
-
* The editor window this agent runs in, as the `hostId` its panel host announces, so the board opens
|
|
49
|
-
* beside this chat and not in another window: `WHITEBOARD_HOST_ID` when a host sets it explicitly, else
|
|
50
|
-
* Windsurf/Devin Desktop's `WINDSURF_EXT_HOST_PID` (the extension host that started the chat, which is
|
|
51
|
-
* the process a panel extension runs in).
|
|
52
|
-
*/
|
|
53
|
-
export function hostHint(env = process.env) {
|
|
54
|
-
return env.WHITEBOARD_HOST_ID?.trim() || env.WINDSURF_EXT_HOST_PID?.trim() || undefined;
|
|
55
|
-
}
|
|
52
|
+
/** Opening the board in the app's own browser keeps it beside the chat; an external browser takes the human away. */
|
|
53
|
+
const IN_APP_BROWSER = 'your in-app browser or preview tool (not an external browser)';
|
|
54
|
+
const SHOWN_BESIDE = 'Shown to the human beside the chat.';
|
|
56
55
|
const SHOW_WAIT_MS = 8000;
|
|
57
56
|
export class ChatContext {
|
|
58
57
|
base;
|
|
@@ -81,24 +80,32 @@ export class ChatContext {
|
|
|
81
80
|
return { client, ref: { id: ref.id, title: this.base.titles.get(ref.id) ?? '' } };
|
|
82
81
|
}
|
|
83
82
|
/**
|
|
84
|
-
* Puts the board in front of the human when nobody is watching it: an IDE panel host first;
|
|
85
|
-
*
|
|
83
|
+
* Puts the board in front of the human when nobody is watching it: an IDE panel host first; in an
|
|
84
|
+
* editor without one, or a chat app, the agent opens it in the app's in-app browser; else a browser.
|
|
86
85
|
*/
|
|
87
86
|
async reveal(client, ref, viewers) {
|
|
88
87
|
return viewers > 0 ? '' : (await this.show(client, ref)).note;
|
|
89
88
|
}
|
|
89
|
+
/** The IDE panel host of this chat's window, else of its folder, shows the board. */
|
|
90
|
+
async panel(client, ref) {
|
|
91
|
+
const { host, ide } = await this.base.editor();
|
|
92
|
+
return client.show(ref.id, this.session.workspace, host, ide).catch(() => false);
|
|
93
|
+
}
|
|
90
94
|
async show(client, ref) {
|
|
91
|
-
if (await
|
|
92
|
-
return { launched: true, note:
|
|
95
|
+
if (await this.panel(client, ref))
|
|
96
|
+
return { launched: true, note: SHOWN_BESIDE };
|
|
93
97
|
const url = client.viewerUrl(ref.id);
|
|
94
98
|
const mode = process.env.WHITEBOARD_OPEN?.trim();
|
|
95
99
|
if (mode === 'none')
|
|
96
100
|
return { launched: false, note: `Nobody is watching the board; the human can open ${url}.` };
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
const explicit = !!mode && mode !== 'auto';
|
|
102
|
+
// In an editor without a panel, or a chat app, the agent shows it in the app's own browser.
|
|
103
|
+
if (!explicit && (this.base.app || (await this.base.editor()).ide)) {
|
|
104
|
+
const where = this.base.app ? '' : 'No whiteboard panel is connected in this editor. ';
|
|
105
|
+
return { launched: false, note: `${where}Open the board in ${IN_APP_BROWSER}: ${url}` };
|
|
100
106
|
}
|
|
101
|
-
|
|
107
|
+
const { opened, note } = await openViewer(url);
|
|
108
|
+
return opened ? { launched: true, note: `${note}.` } : { launched: false, note: `${note}, so the human can't see the board yet; ask them to open ${url}.` };
|
|
102
109
|
}
|
|
103
110
|
/** Shows the board and waits until a viewer is connected; false if none arrives. */
|
|
104
111
|
async revealAndWait(client, ref) {
|
|
@@ -356,7 +363,8 @@ export const TOOLS = [
|
|
|
356
363
|
const shown = args.open === false ? '' : await ctx.reveal(client, ref, snap.viewers);
|
|
357
364
|
const others = boards.filter((b) => b.id !== ref.id).sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 10);
|
|
358
365
|
const mine = ref.id === own.id;
|
|
359
|
-
|
|
366
|
+
const summary = text(`${mine ? "This chat's board" : `Board from another chat${elsewhere ? ` (${elsewhere})` : ''}`}: ${boardName(ref)} [${ref.id}]. ${snap.viewers ? 'The human is watching it.' : shown}`, loaded && `Loaded a copy of saved board "${loaded.name}" [${loaded.id}]; the saved copy stays unchanged.`, snap.elements.length ? describeScene(snap.elements, { maxElements: 12 }) : 'The board is empty.', others.length > 0 && `Other boards in this workspace (open one with board): ${others.map((b) => `${b.title === b.id ? '(untitled)' : b.title} [${b.id}, ${b.elementCount} el]`).join('; ')}`, !mine && 'Drawing now changes that board; canvas_open {board:"own"} switches back to this chat\'s board.');
|
|
367
|
+
return summary;
|
|
360
368
|
},
|
|
361
369
|
},
|
|
362
370
|
{
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansuman-shukla/whiteboard-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"mcpName": "io.github.ansuman-shukla/whiteboard-mcp",
|
|
5
|
+
"description": "Local, offline Excalidraw whiteboard MCP server: your coding agent (Claude Code, Codex, Cursor, Gemini CLI, VS Code) draws diagrams live beside the chat and sees what you draw back. Also a CLI.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
|
7
8
|
"whiteboard-mcp": "dist/cli.js"
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
16
|
-
"build": "npm run build:frontend && npm run build:server",
|
|
17
|
+
"build": "npm run build:frontend && npm run build:server && node scripts/build-vsix.mjs dist/vscode",
|
|
17
18
|
"build:frontend": "vite build",
|
|
18
19
|
"build:server": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js',0o755)\"",
|
|
19
20
|
"build:vscode": "node scripts/build-vsix.mjs",
|
|
@@ -51,13 +52,34 @@
|
|
|
51
52
|
"keywords": [
|
|
52
53
|
"mcp",
|
|
53
54
|
"mcp-server",
|
|
55
|
+
"model-context-protocol",
|
|
54
56
|
"excalidraw",
|
|
57
|
+
"excalidraw-mcp",
|
|
55
58
|
"whiteboard",
|
|
59
|
+
"whiteboard-mcp",
|
|
60
|
+
"canvas",
|
|
61
|
+
"diagram",
|
|
56
62
|
"diagrams",
|
|
63
|
+
"architecture-diagram",
|
|
64
|
+
"flowchart",
|
|
65
|
+
"mermaid",
|
|
66
|
+
"drawing",
|
|
67
|
+
"visualization",
|
|
68
|
+
"offline",
|
|
69
|
+
"local-first",
|
|
57
70
|
"coding-agent",
|
|
71
|
+
"ai-agent",
|
|
72
|
+
"llm",
|
|
73
|
+
"claude",
|
|
58
74
|
"claude-code",
|
|
75
|
+
"claude-desktop",
|
|
59
76
|
"codex",
|
|
60
|
-
"
|
|
77
|
+
"cursor",
|
|
78
|
+
"gemini-cli",
|
|
79
|
+
"vscode",
|
|
80
|
+
"windsurf",
|
|
81
|
+
"opencode",
|
|
82
|
+
"devin"
|
|
61
83
|
],
|
|
62
84
|
"license": "MIT",
|
|
63
85
|
"publishConfig": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: whiteboard-explainer
|
|
3
|
-
description: Explain code, architecture, data flows, algorithms, plans or trade-offs visually on a live whiteboard shown to the user beside the chat (they can draw on it or annotate). Use when a diagram would explain better than prose, when the user asks to "draw", "sketch", "diagram", "visualise" or "show" something, or when they mention the canvas, the whiteboard or something they drew.
|
|
3
|
+
description: Explain code, architecture, data flows, algorithms, plans or trade-offs visually on a live Excalidraw whiteboard (Whiteboard MCP) shown to the user beside the chat (they can draw on it or annotate). Use when a diagram would explain better than prose, when the user asks to "draw", "sketch", "diagram", "visualise" or "show" something, or when they mention the canvas, the whiteboard or something they drew.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Excalidraw explainer
|
|
@@ -14,7 +14,7 @@ A live whiteboard shared with the user. You draw through the `whiteboard-mcp` MC
|
|
|
14
14
|
|
|
15
15
|
## The live explain loop
|
|
16
16
|
|
|
17
|
-
1. **Open**: `canvas_open {"title":"<topic>"}` once per topic. It shows this chat's board to the user and tells you what is already on it
|
|
17
|
+
1. **Open**: `canvas_open {"title":"<topic>"}` once per topic. It shows this chat's board to the user and tells you what is already on it, and where it showed it (see below). Call `canvas_guide` once if you haven't read it yet (layout, sizes, colours).
|
|
18
18
|
2. **Draw step by step**: one `canvas_draw` per idea (the components, then the flow between them, then annotations). After each call, write one or two short sentences in chat that point at the canvas ("the blue boxes are the services"). The canvas carries the detail, keep the chat brief.
|
|
19
19
|
3. **Point**: `canvas_focus {"ids":["api","db"],"highlight":true}` when you talk about specific parts.
|
|
20
20
|
4. **Check once**: after the main layout, `canvas_screenshot` one time to catch overlaps or cut-off labels, then fix them with `canvas_draw` on the same ids.
|
|
@@ -22,6 +22,28 @@ A live whiteboard shared with the user. You draw through the `whiteboard-mcp` MC
|
|
|
22
22
|
6. **Update, don't redraw**: re-send an existing id with only the fields that change (`{"id":"api","x":400}`, `{"id":"db","label":"Postgres"}`). Use `canvas_delete` for removals. Start over only with `canvas_open {"fresh":true}`.
|
|
23
23
|
7. **Keep it**: when a drawing is worth reusing or the user asks to save it, `canvas_save {"name":"<topic>"}` stores a frozen copy in the user's library (the user can also save from the board's menu or with Cmd/Ctrl+S). In a later chat, in any workspace, `canvas_search {"query":"<words>"}` finds other chats' live boards and saved copies: `canvas_open {"board":"<id>"}` opens a live one, `canvas_open {"saved":"<id>"}` loads a copy into this chat's board (add `"fresh":true` if the board already has a drawing). When the user names a board loosely and `canvas_open {board}` doesn't find it, it lists the closest boards: pick the one they meant by id.
|
|
24
24
|
|
|
25
|
+
## Editing an existing drawing
|
|
26
|
+
|
|
27
|
+
When the user asks to change a drawing that's already there ("put a cache between the API and the DB"):
|
|
28
|
+
|
|
29
|
+
1. **Read first**: `canvas_read` (or the `canvas_open` result) lists every element with its id, label, position, size and what each arrow connects. Work from those ids and coordinates, never from memory of what you drew earlier: the user may have moved or edited things.
|
|
30
|
+
2. **Edit by id**: re-send only the fields that change, in one `canvas_draw`. Make room by moving existing shapes (`{"id":"db","x":520}`), then add the new ones in the freed space. Shapes the user drew have generated ids; use them as listed.
|
|
31
|
+
3. **Re-point arrows**: change an existing arrow's ends (`{"id":"q","to":"cache"}`) rather than deleting and redrawing it. Arrows follow the shapes they're bound to when those move.
|
|
32
|
+
4. **Delete**: `canvas_delete {"ids":["old"]}` for what goes away. Arrows bound to a deleted shape are detached, not deleted: re-point or delete them too.
|
|
33
|
+
5. **Focus**: `canvas_focus {"ids":["cache"],"highlight":true}` on what changed, and say in a sentence what changed and why.
|
|
34
|
+
6. **Check**: one `canvas_screenshot` after bigger edits; fix overlaps or cut-off labels with `canvas_draw` on the same ids.
|
|
35
|
+
|
|
36
|
+
## Where the board shows
|
|
37
|
+
|
|
38
|
+
The tools put the board in front of the user themselves; read the `canvas_open` result to know where:
|
|
39
|
+
|
|
40
|
+
- **"Shown to the human beside the chat."**: an editor tab beside the chat (VS Code, Cursor, Windsurf and other VS Code-based editors, with the Whiteboard MCP extension). Nothing to do.
|
|
41
|
+
- **"Open the board in your in-app browser or preview tool"**: no editor tab can show it here (an editor without the extension, or a desktop chat app). Open the link the result gives in your in-app browser or preview tool right away, so the board shows beside the chat.
|
|
42
|
+
- **"opened with open"**: outside an editor (a plain terminal), the board opened in the browser. That's the right place there.
|
|
43
|
+
- **"Nobody is watching the board"**: the user closed it; call `canvas_open` again when you want them to see it.
|
|
44
|
+
|
|
45
|
+
Board links go in your in-app browser or preview tool (the editor's or app's built-in browser beside the chat), never an external browser: that would take the user out of the app.
|
|
46
|
+
|
|
25
47
|
## Element cheatsheet
|
|
26
48
|
|
|
27
49
|
- Shapes: `{"id":"api","type":"rectangle","x":0,"y":0,"label":"API","backgroundColor":"blue"}`. ellipse = start/end/actor, diamond = decision, frame = zone.
|