From d975ef619ceee427376bd38be2fee2143ed09f40 Mon Sep 17 00:00:00 2001 From: davidpanonce-nx Date: Fri, 20 Mar 2026 22:59:10 +0800 Subject: [PATCH 1/2] feat(mcp): add update_view tool for editing existing diagrams (#29) Adds a new `update_view` tool that takes a `checkpointId` and `elements`, allowing the model to edit a previously created diagram without recreating it from scratch. The server loads the base state from the checkpoint, applies deletes, merges new elements, and saves a new checkpoint. The widget now also handles the top-level `checkpointId` field during streaming so the base diagram is visible while new elements stream in. --- CLAUDE.md | 5 +- src/mcp-app.tsx | 8 +++- src/server.ts | 118 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3318e39..beeab03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Standalone MCP server that streams Excalidraw diagrams as SVG with hand-drawn an ## Architecture ``` -server.ts → 2 tools (read_me, create_view) + resource + cheat sheet +server.ts → 3 tools (read_me, create_view, update_view) + resource + cheat sheet main.ts → HTTP (Streamable) + stdio transports src/mcp-app.tsx → ExcalidrawAppCore (widget logic) + ExcalidrawApp (useApp wrapper) src/mcp-entry.tsx → Production entry point: createRoot + ExcalidrawApp @@ -26,6 +26,9 @@ Takes `elements` — a JSON string of standard Excalidraw elements. The widget p **Screenshot as model context:** After final render, the SVG is captured as a 512px-max PNG and sent via `app.updateModelContext()` so the model can see the diagram and iterate on user feedback. +### `update_view` (UI tool) +Takes `checkpointId` + `elements` — edits an existing diagram by applying changes on top of a saved checkpoint. The server loads the base state from the checkpoint, applies deletes, merges new elements, and saves a new checkpoint. The widget handles the `checkpointId` field during streaming to show the base + new elements progressively. + ## Key Design Decisions ### Standard Excalidraw JSON — no extensions diff --git a/src/mcp-app.tsx b/src/mcp-app.tsx index e0b1416..7a1f4ea 100644 --- a/src/mcp-app.tsx +++ b/src/mcp-app.tsx @@ -431,10 +431,16 @@ function DiagramView({ toolInput, isFinal, displayMode, onElements, editedElemen // Parse elements from string or array const str = typeof raw === "string" ? raw : JSON.stringify(raw); + // Support update_view: if checkpointId is provided as a top-level field, + // treat it as a restoreCheckpoint reference (the elements array won't contain one) + const externalCheckpointId = toolInput.checkpointId as string | undefined; + if (isFinal) { // Final input — parse complete JSON, render ALL elements const parsed = parsePartialElements(str); let { viewport, drawElements, restoreId, deleteIds } = extractViewportAndElements(parsed); + // Use external checkpointId if no restoreCheckpoint in elements + if (!restoreId && externalCheckpointId) restoreId = externalCheckpointId; // Load checkpoint base if restoring (async — from server) let base: any[] | undefined; @@ -475,7 +481,7 @@ function DiagramView({ toolInput, isFinal, displayMode, onElements, editedElemen const parsed = parsePartialElements(str); // Extract restoreCheckpoint and delete before dropping last (they're small, won't be incomplete) - let streamRestoreId: string | null = null; + let streamRestoreId: string | null = externalCheckpointId ?? null; const streamDeleteIds = new Set(); for (const el of parsed) { if (el.type === "restoreCheckpoint") streamRestoreId = el.id; diff --git a/src/server.ts b/src/server.ts index 5a07dab..4b1968e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,7 +21,7 @@ const DIST_DIR = import.meta.filename.endsWith(".ts") // ============================================================ const RECALL_CHEAT_SHEET = `# Excalidraw Element Format -Thanks for calling read_me! Do NOT call it again in this conversation — you will not see anything new. Now use create_view to draw. +Thanks for calling read_me! Do NOT call it again in this conversation — you will not see anything new. Now use create_view to draw. Use update_view to edit an existing diagram. ## Color Palette (use consistently across all tools) @@ -282,13 +282,17 @@ This demonstrates a UML-style sequence diagram with 4 actors (User, Agent, App i ] \`\`\` -## Checkpoints (restoring previous state) +## Editing Diagrams (update_view) -Every create_view call returns a \`checkpointId\` in its response. To continue from a previous diagram state, start your elements array with a restoreCheckpoint element: +Every create_view and update_view call returns a \`checkpointId\` in its response. To edit an existing diagram, use **update_view** with the checkpointId and only the new/changed elements: -\`[{"type":"restoreCheckpoint","id":""}, ...additional new elements...]\` +\`update_view(checkpointId: "", elements: "[...new elements...]")\` -The saved state (including any user edits made in fullscreen) is loaded from the client, and your new elements are appended on top. This saves tokens — you don't need to re-send the entire diagram. +The saved state (including any user edits made in fullscreen) is loaded from the checkpoint, and your new elements are appended on top. This saves tokens — you don't need to re-send the entire diagram. + +**When to use update_view vs create_view:** +- \`create_view\`: New diagram from scratch +- \`update_view\`: Edit/modify an existing diagram (add elements, delete elements, change camera) ## Deleting Elements @@ -497,20 +501,104 @@ Call read_me first to learn the element format.`, return { content: [{ type: "text", text: `Diagram displayed! Checkpoint id: "${checkpointId}". If user asks to create a new diagram - simply create a new one from scratch. -However, if the user wants to edit something on this diagram "${checkpointId}", take these steps: -1) read widget context (using read_widget_context tool) to check if user made any manual edits first -2) decide whether you want to make new diagram from scratch OR - use this one as starting checkpoint: - simply start from the first element [{"type":"restoreCheckpoint","id":"${checkpointId}"}, ...your new elements...] - this will use same diagram state as the user currently sees, including any manual edits they made in fullscreen, allowing you to add elements on top. - To remove elements, use: {"type":"delete","ids":","}${ratioHint}` }], +However, if the user wants to edit this diagram, use update_view with checkpointId "${checkpointId}" and only the new/changed elements. +Before editing, read widget context (using read_widget_context tool) to check if user made any manual edits first. +To remove elements in update_view, include: {"type":"delete","ids":","} in the elements array.${ratioHint}` }], structuredContent: { checkpointId }, }; }, ); // ============================================================ - // Tool 3: export_to_excalidraw (server-side proxy for CORS) - // Called by widget via app.callServerTool(), not by the model. + // Tool 3: update_view (edit existing diagram) + // ============================================================ + registerAppTool(server, + "update_view", + { + title: "Edit Diagram", + description: `Edits an existing diagram by applying changes on top of a saved checkpoint. +Use this instead of create_view when modifying a previously created diagram. +Only send the new or changed elements — the base state is loaded from the checkpoint. +Call read_me first if you haven't already.`, + inputSchema: z.object({ + checkpointId: z.string().describe( + "The checkpoint ID from a previous create_view or update_view call." + ), + elements: z.string().describe( + "JSON array string of new/changed Excalidraw elements to apply on top of the checkpoint. Include delete pseudo-elements to remove existing elements. Must be valid JSON." + ), + }), + annotations: { readOnlyHint: true }, + _meta: { ui: { resourceUri } }, + }, + async ({ checkpointId: inputCheckpointId, elements }): Promise => { + if (elements.length > MAX_INPUT_BYTES) { + return { + content: [{ type: "text", text: `Elements input exceeds ${MAX_INPUT_BYTES} byte limit.` }], + isError: true, + }; + } + + const base = await store.load(inputCheckpointId); + if (!base) { + return { + content: [{ type: "text", text: `Checkpoint "${inputCheckpointId}" not found — it may have expired or never existed. Please recreate the diagram using create_view.` }], + isError: true, + }; + } + + let parsed: any[]; + try { + parsed = JSON.parse(elements); + } catch (e) { + return { + content: [{ type: "text", text: `Invalid JSON in elements: ${(e as Error).message}. Ensure no comments, no trailing commas, and proper quoting.` }], + isError: true, + }; + } + + // Collect IDs to delete + const deleteIds = new Set(); + for (const el of parsed) { + if (el.type === "delete") { + for (const id of String(el.ids ?? el.id).split(",")) deleteIds.add(id.trim()); + } + } + + // Filter base elements and merge with new ones + const baseFiltered = base.elements.filter((el: any) => + !deleteIds.has(el.id) && !deleteIds.has(el.containerId) + ); + const newEls = parsed.filter((el: any) => + el.type !== "restoreCheckpoint" && el.type !== "delete" + ); + const resolvedElements = [...baseFiltered, ...newEls]; + + // Check camera aspect ratios + const cameras = parsed.filter((el: any) => el.type === "cameraUpdate"); + const badRatio = cameras.find((c: any) => { + if (!c.width || !c.height) return false; + const ratio = c.width / c.height; + return Math.abs(ratio - 4 / 3) > 0.15; + }); + const ratioHint = badRatio + ? `\nTip: your cameraUpdate used ${badRatio.width}x${badRatio.height} — try to stick with 4:3 aspect ratio (e.g. 400x300, 800x600) in future.` + : ""; + + const newCheckpointId = crypto.randomUUID().replace(/-/g, "").slice(0, 18); + await store.save(newCheckpointId, { elements: resolvedElements }); + return { + content: [{ type: "text", text: `Diagram updated! New checkpoint id: "${newCheckpointId}". +For further edits, use update_view with checkpointId "${newCheckpointId}". +To remove elements, include: {"type":"delete","ids":","} in the elements array.${ratioHint}` }], + structuredContent: { checkpointId: newCheckpointId }, + }; + }, + ); + + // ============================================================ + // Tool 4: export_to_excalidraw (server-side proxy for CORS) + // Called by widget via app.callServerTool(), not by the model // ============================================================ registerAppTool(server, "export_to_excalidraw", @@ -601,7 +689,7 @@ However, if the user wants to edit something on this diagram "${checkpointId}", ); // ============================================================ - // Tool 4: save_checkpoint (private — widget only, for user edits) + // Tool 5: save_checkpoint (private — widget only, for user edits) // ============================================================ registerAppTool(server, "save_checkpoint", @@ -627,7 +715,7 @@ However, if the user wants to edit something on this diagram "${checkpointId}", ); // ============================================================ - // Tool 5: read_checkpoint (private — widget only) + // Tool 6: read_checkpoint (private — widget only) // ============================================================ registerAppTool(server, "read_checkpoint", From 3b67c0c1258b34cd26c5b30f7525689909373080 Mon Sep 17 00:00:00 2001 From: davidpanonce-nx Date: Sat, 21 Mar 2026 05:14:43 +0800 Subject: [PATCH 2/2] fix: address code review feedback for update_view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix byte size check: use Buffer.byteLength instead of string length (multi-byte chars could bypass limit) — fixed in both create_view and update_view - Add Array.isArray validation after JSON.parse in both tools - Dedup elements by ID: new elements with same ID as base automatically override the base version (no delete+add needed) - Document override semantics and containerId cascade delete in cheat sheet - Add sync comments linking widget-side and server-side merge logic --- src/mcp-app.tsx | 3 +++ src/server.ts | 34 ++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/mcp-app.tsx b/src/mcp-app.tsx index 7a1f4ea..14e58fa 100644 --- a/src/mcp-app.tsx +++ b/src/mcp-app.tsx @@ -443,6 +443,8 @@ function DiagramView({ toolInput, isFinal, displayMode, onElements, editedElemen if (!restoreId && externalCheckpointId) restoreId = externalCheckpointId; // Load checkpoint base if restoring (async — from server) + // NOTE: This merge logic (delete filtering + base/new combine) mirrors + // the server-side merge in server.ts update_view handler. Keep in sync. let base: any[] | undefined; const doFinal = async () => { if (restoreId && loadCheckpoint) { @@ -495,6 +497,7 @@ function DiagramView({ toolInput, isFinal, displayMode, onElements, editedElemen const doStream = async () => { // Load checkpoint base (once per restoreId) — from server via callServerTool + // NOTE: Merge logic mirrors server.ts update_view handler. Keep in sync. let base: any[] | undefined; if (streamRestoreId) { if (!restoredRef.current || restoredRef.current.id !== streamRestoreId) { diff --git a/src/server.ts b/src/server.ts index 4b1968e..216b57e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -294,12 +294,16 @@ The saved state (including any user edits made in fullscreen) is loaded from the - \`create_view\`: New diagram from scratch - \`update_view\`: Edit/modify an existing diagram (add elements, delete elements, change camera) +**Updating existing elements:** To modify a property (e.g. color, position) of an existing element, send the full element with the same \`id\`. The new version automatically overrides the base version — no need to delete first. + ## Deleting Elements Remove elements by id using the \`delete\` pseudo-element: \`{"type":"delete","ids":"b2,a1,t3"}\` +**Container cascade:** Deleting a container (rectangle, diamond, etc.) also removes its bound text elements (those with \`containerId\` pointing to it). You don't need to delete both separately. + Works in two modes: - **With restoreCheckpoint**: restore a saved state, then surgically remove specific elements before adding new ones - **Inline (animation mode)**: draw elements, then delete and replace them later in the same array to create transformation effects @@ -438,7 +442,7 @@ Call read_me first to learn the element format.`, _meta: { ui: { resourceUri } }, }, async ({ elements }): Promise => { - if (elements.length > MAX_INPUT_BYTES) { + if (Buffer.byteLength(elements, "utf8") > MAX_INPUT_BYTES) { return { content: [{ type: "text", text: `Elements input exceeds ${MAX_INPUT_BYTES} byte limit. Reduce the number of elements or use checkpoints to build incrementally.` }], isError: true, @@ -454,6 +458,13 @@ Call read_me first to learn the element format.`, }; } + if (!Array.isArray(parsed)) { + return { + content: [{ type: "text", text: `Elements must be a JSON array, got ${typeof parsed}.` }], + isError: true, + }; + } + // Resolve restoreCheckpoint references and save fully resolved state const restoreEl = parsed.find((el: any) => el.type === "restoreCheckpoint"); let resolvedElements: any[]; @@ -532,7 +543,7 @@ Call read_me first if you haven't already.`, _meta: { ui: { resourceUri } }, }, async ({ checkpointId: inputCheckpointId, elements }): Promise => { - if (elements.length > MAX_INPUT_BYTES) { + if (Buffer.byteLength(elements, "utf8") > MAX_INPUT_BYTES) { return { content: [{ type: "text", text: `Elements input exceeds ${MAX_INPUT_BYTES} byte limit.` }], isError: true, @@ -557,6 +568,13 @@ Call read_me first if you haven't already.`, }; } + if (!Array.isArray(parsed)) { + return { + content: [{ type: "text", text: `Elements must be a JSON array, got ${typeof parsed}.` }], + isError: true, + }; + } + // Collect IDs to delete const deleteIds = new Set(); for (const el of parsed) { @@ -565,13 +583,17 @@ Call read_me first if you haven't already.`, } } - // Filter base elements and merge with new ones - const baseFiltered = base.elements.filter((el: any) => - !deleteIds.has(el.id) && !deleteIds.has(el.containerId) - ); + // New real elements (not pseudo-elements) const newEls = parsed.filter((el: any) => el.type !== "restoreCheckpoint" && el.type !== "delete" ); + // IDs of new elements — used to dedup (new version overrides base) + const newIds = new Set(newEls.map((el: any) => el.id).filter(Boolean)); + + // Filter base: remove deleted, remove overridden by new, cascade containerId deletes + const baseFiltered = base.elements.filter((el: any) => + !deleteIds.has(el.id) && !deleteIds.has(el.containerId) && !newIds.has(el.id) + ); const resolvedElements = [...baseFiltered, ...newEls]; // Check camera aspect ratios