@ansuman-shukla/whiteboard-mcp 0.1.1 → 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,11 +1,11 @@
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>
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
9
 
10
10
  ```sh
11
11
  claude mcp add whiteboard --scope user -- npx -y @ansuman-shukla/whiteboard-mcp
@@ -23,7 +23,7 @@ claude mcp add whiteboard --scope user -- npx -y @ansuman-shukla/whiteboard-mcp
23
23
 
24
24
  <picture>
25
25
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/readme/features-dark.svg">
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).">
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).">
27
27
  </picture>
28
28
 
29
29
  ## What you can do with it
@@ -96,12 +96,12 @@ code --add-mcp '{"name":"whiteboard","command":"npx","args":["-y","@ansuman-shuk
96
96
 
97
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
98
 
99
- ## whiteboard-mcp vs Excalidraw MCP
99
+ ## Whiteboard MCP vs Excalidraw MCP
100
100
 
101
101
  <a href="docs/comparison.md">
102
102
  <picture>
103
103
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/readme/compare-dark.svg">
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.">
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.">
105
105
  </picture>
106
106
  </a>
107
107
 
@@ -111,7 +111,7 @@ Agents without MCP, project-level configs and more: [client setup](docs/clients.
111
111
 
112
112
  ### Is there an Excalidraw MCP server that runs locally and works offline?
113
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`.
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
115
 
116
116
  ### How do I let Claude Code, Codex or Cursor draw diagrams?
117
117
 
@@ -123,7 +123,7 @@ Yes. Every time it looks, it gets a short list of what you added, moved or delet
123
123
 
124
124
  ### Which agents and editors does it work with?
125
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; anywhere else they open in your browser.
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
127
 
128
128
  ### Can it turn Mermaid into an Excalidraw diagram, or export it?
129
129
 
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansuman-shukla/whiteboard-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "mcpName": "io.github.ansuman-shukla/whiteboard-mcp",
5
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.",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "LICENSE"
15
15
  ],
16
16
  "scripts": {
17
- "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",
18
18
  "build:frontend": "vite build",
19
19
  "build:server": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js',0o755)\"",
20
20
  "build:vscode": "node scripts/build-vsix.mjs",
@@ -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 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.
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.