@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 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="whiteboard-mcp: a live whiteboard your coding agent draws on. You draw back.">
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
- <br>
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 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).">
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
- <br><br>
29
+ ## What you can do with it
30
+
31
+ ### Understand an architecture
22
32
 
23
- **Understand an architecture.** *"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.
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
- **Explain a flow.** *"How does login work here?"* Arrows stay attached as things move, and the agent answers your notes on its next look.
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
- **Make something.** *"Paint the Mona Lisa, then refine it with the pencil."* Shapes, freehand strokes and frames, all editable.
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
- <br>
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
- Requires Node.js 20+.
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
- Codex, Cursor, Gemini CLI, OpenCode, Devin, VS Code, Claude Desktop and agents without MCP: see [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.
85
+ **Cursor, Claude Desktop, Windsurf and other JSON configs** (`~/.cursor/mcp.json`, Claude Desktop's config, …):
59
86
 
60
- <br>
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="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.">
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">
@@ -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;
@@ -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; a window known to be another one than the
266
- * hinted one is never used. No match means no host: showing a chat's board in an unrelated window
267
- * would hide that window's own board.
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 !== undefined)
272
- return h.hostId === hint ? 3 : 0;
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; a show that names its window waits briefly for that window's host instead of answering no_host.
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 host = pickHost(workspace, hint);
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
- const graceLeft = startedAt + hostGraceMs - Date.now();
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(wait);
303
+ hostWaits.delete(waiter);
300
304
  clearTimeout(timer);
301
305
  res.off('close', onClientGone);
302
306
  };
303
- const wait = {
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(() => wait.finish(409, { error: 'no_host' }), graceLeft);
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(wait);
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 to the human themselves: never open board links in a browser or preview tool. 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.`;
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. Never open board links in a browser or preview tool. Plan: 3-8 boxes beat 30.
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.
@@ -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
- const IDE_ENV = ['WINDSURF_EXT_HOST_PID', 'VSCODE_PID', 'VSCODE_IPC_HOOK_CLI', 'CURSOR_TRACE_ID'];
45
- /** Agents running inside an editor should get the board in a panel, never a separate browser window. */
46
- const insideIde = () => IDE_ENV.some((k) => process.env[k]) || process.env.TERM_PROGRAM === 'vscode';
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
- * otherwise a browser, except inside an IDE where a stray browser window is never wanted.
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 client.show(ref.id, this.session.workspace, hostHint()).catch(() => false))
92
- return { launched: true, note: 'Shown to the human beside the chat.' };
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
- if ((mode && mode !== 'auto') || !insideIde()) {
98
- const { opened, note } = await openViewer(url);
99
- return opened ? { launched: true, note: `${note}.` } : { launched: false, note: `${note}, so the human can't see the board yet; ask them to open ${url}.` };
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
- return { launched: false, note: `No whiteboard panel is connected in the editor, so the human can't see the board yet. Ask them to open the whiteboard panel (or this link themselves: ${url}). Do not open board links in a browser or preview tool yourself.` };
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
- return 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.');
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
  {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@ansuman-shukla/whiteboard-mcp",
3
- "version": "0.1.0",
4
- "description": "A live whiteboard your coding agent draws on to explain things, side by side with you. MCP server and CLI with a hand-drawn Excalidraw canvas.",
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
- "opencode"
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. Never open board links in a browser or preview tool yourself. Call `canvas_guide` once if you haven't read it yet (layout, sizes, colours).
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.