@drawpro/mcp 0.2.0 → 0.3.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
@@ -76,6 +76,7 @@ passcode itself.
76
76
  | `create_diagram` | no | returns a link to the new sheet |
77
77
  | `update_diagram` | no | replaces the sheet wholesale — regenerates layout |
78
78
  | `edit_sheet_text` | yes | rewrites named text in place, preserving every other element |
79
+ | `import_sheet` | no | writes a local .excalidraw file into a sheet, coordinates verbatim |
79
80
 
80
81
  ### Editing a sheet a person drew
81
82
 
@@ -85,10 +86,28 @@ containers, and unbound annotation arrows all disappear, and the sheet comes
85
86
  back as an auto-laid-out graph. Correct content, different diagram.
86
87
 
87
88
  Use `edit_sheet_text` for those sheets. It reads the scene, rewrites only the
88
- strings you name, and writes every other element back byte for byte — so
89
- coordinates, groupings, and annotations are untouched. Edits apply
90
- all-or-nothing: if any `find` matches nothing, the sheet is left alone rather
91
- than half-updated.
89
+ strings you name, and writes every other element back — so coordinates,
90
+ groupings, and annotations are untouched. Edits apply all-or-nothing: if any
91
+ `find` matches nothing, the sheet is left alone rather than half-updated.
92
+
93
+ The one geometry change it makes is growing a box whose text no longer fits.
94
+ Excalidraw regrows a container around text *bound* to it, but hand-drawn
95
+ diagrams usually have text merely sitting on top of a shape, so longer text
96
+ would otherwise spill out the bottom. Boxes are only ever grown, never shrunk,
97
+ and nothing else moves — reflowing neighbours would be the wholesale rewrite
98
+ this tool exists to avoid. Any box it grows is named in the result, so overlap
99
+ with a neighbour is easy to spot and one drag to fix.
100
+
101
+ It cannot add elements or move anything. A correction that needs a new box, a
102
+ repositioned column, or a re-anchored arrow is a geometry change, and no spec or
103
+ text edit can express one.
104
+
105
+ `import_sheet` covers that case. Point it at a local `.excalidraw` file and it
106
+ writes the scene in verbatim — every coordinate exactly as in the file. Because
107
+ the server runs on your machine it can simply read the file, so a geometry pass
108
+ can be done with a real editor, or by a model editing the file directly, and
109
+ then pushed without a clipboard round trip. The file's contents go into the
110
+ encrypted blob, never into the model's context.
92
111
 
93
112
  `update_diagram` remains the right tool for sheets this package generated, where
94
113
  regenerating the layout is the point.
package/dist/server.js CHANGED
@@ -24,6 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/server.ts
27
+ var import_node_fs2 = require("node:fs");
28
+ var import_node_path2 = require("node:path");
27
29
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
28
30
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
29
31
  var import_zod = require("zod");
@@ -338,7 +340,7 @@ function forgetKey(email) {
338
340
 
339
341
  // ../client/src/prompt.ts
340
342
  function askHidden(question) {
341
- return new Promise((resolve) => {
343
+ return new Promise((resolve2) => {
342
344
  const input = process.stdin;
343
345
  process.stdout.write(question);
344
346
  if (!input.isTTY) {
@@ -347,7 +349,7 @@ function askHidden(question) {
347
349
  input.on("data", (chunk) => {
348
350
  buffered += chunk;
349
351
  });
350
- input.on("end", () => resolve(buffered.split("\n")[0].trim()));
352
+ input.on("end", () => resolve2(buffered.split("\n")[0].trim()));
351
353
  return;
352
354
  }
353
355
  const wasRaw = input.isRaw;
@@ -363,7 +365,7 @@ function askHidden(question) {
363
365
  if (result === null) {
364
366
  process.exit(130);
365
367
  }
366
- resolve(result);
368
+ resolve2(result);
367
369
  };
368
370
  const onData = (chunk) => {
369
371
  for (const ch of chunk) {
@@ -1084,8 +1086,33 @@ function validateScene(elements) {
1084
1086
  return issues;
1085
1087
  }
1086
1088
 
1087
- // ../diagram/src/describe.ts
1089
+ // ../diagram/src/fit.ts
1088
1090
  var SHAPES = /* @__PURE__ */ new Set(["rectangle", "ellipse", "diamond"]);
1091
+ var PADDING = 16;
1092
+ function growBoxesToFitText(elements, changedTextIds) {
1093
+ const shapes = elements.filter((el) => SHAPES.has(el.type));
1094
+ const resizes = [];
1095
+ for (const text2 of elements) {
1096
+ if (text2.type !== "text" || !changedTextIds.has(text2.id)) continue;
1097
+ if (text2.containerId) continue;
1098
+ const cx = text2.x + text2.width / 2;
1099
+ const cy = text2.y + text2.height / 2;
1100
+ const box = shapes.filter((sh) => cx >= sh.x && cx <= sh.x + sh.width && cy >= sh.y && cy <= sh.y + sh.height).sort((a, b) => a.width * a.height - b.width * b.height)[0];
1101
+ if (!box) continue;
1102
+ const needed = Math.ceil(text2.y - box.y + text2.height + PADDING);
1103
+ if (needed > box.height) {
1104
+ resizes.push({ shapeId: box.id, shapeType: box.type, from: box.height, to: needed });
1105
+ box.height = needed;
1106
+ box.version = (box.version ?? 1) + 1;
1107
+ box.versionNonce = Math.floor(Math.random() * 2 ** 31);
1108
+ box.updated = Date.now();
1109
+ }
1110
+ }
1111
+ return resizes;
1112
+ }
1113
+
1114
+ // ../diagram/src/describe.ts
1115
+ var SHAPES2 = /* @__PURE__ */ new Set(["rectangle", "ellipse", "diamond"]);
1089
1116
  var ARROW_LABEL_RADIUS = 60;
1090
1117
  function centre(b) {
1091
1118
  return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
@@ -1126,7 +1153,7 @@ function describeScene(elements) {
1126
1153
  if (bound) return bound;
1127
1154
  const owner = byId.get(id);
1128
1155
  if (!owner) return "";
1129
- if (SHAPES.has(owner.type)) {
1156
+ if (SHAPES2.has(owner.type)) {
1130
1157
  const hit = floating.find((t) => !adopted.has(t.id) && contains(owner, centre(t)));
1131
1158
  if (hit) {
1132
1159
  adopted.add(hit.id);
@@ -1157,7 +1184,7 @@ function describeScene(elements) {
1157
1184
  };
1158
1185
  const counts = {};
1159
1186
  for (const el of elements) counts[el.type] = (counts[el.type] ?? 0) + 1;
1160
- const shapes = elements.filter((el) => SHAPES.has(el.type)).map((el) => ({ id: el.id, type: el.type, label: labelFor(el.id) }));
1187
+ const shapes = elements.filter((el) => SHAPES2.has(el.type)).map((el) => ({ id: el.id, type: el.type, label: labelFor(el.id) }));
1161
1188
  const shapeLabel = new Map(shapes.map((s) => [s.id, s.label]));
1162
1189
  const edges = elements.filter((el) => el.type === "arrow").map((el) => {
1163
1190
  const start = el.startBinding;
@@ -1570,6 +1597,7 @@ server.tool(
1570
1597
  const scene = await api().readSheet(workspace_id, sheet_id, unlocked.key);
1571
1598
  const elements = scene.elements;
1572
1599
  const counts = /* @__PURE__ */ new Map();
1600
+ const changedTextIds = /* @__PURE__ */ new Set();
1573
1601
  for (const el of elements) {
1574
1602
  if (el.type !== "text") continue;
1575
1603
  const current = (el.originalText ?? el.text)?.trim();
@@ -1578,14 +1606,18 @@ server.tool(
1578
1606
  if (!edit) continue;
1579
1607
  el.text = edit.replace;
1580
1608
  el.originalText = edit.replace;
1581
- const metrics = measureText(edit.replace, el.fontSize ?? 20, 1e3);
1609
+ const fontSize = el.fontSize ?? 20;
1610
+ const wrapWidth = el.containerId ? el.width : 1e3;
1611
+ const metrics = measureText(edit.replace, fontSize, wrapWidth);
1582
1612
  el.width = metrics.width;
1583
1613
  el.height = metrics.height;
1614
+ changedTextIds.add(el.id);
1584
1615
  el.version = (el.version ?? 1) + 1;
1585
1616
  el.versionNonce = Math.floor(Math.random() * 2 ** 31);
1586
1617
  el.updated = Date.now();
1587
1618
  counts.set(edit.find, (counts.get(edit.find) ?? 0) + 1);
1588
1619
  }
1620
+ const resizes = growBoxesToFitText(elements, changedTextIds);
1589
1621
  const missed = edits.filter((e) => !counts.has(e.find));
1590
1622
  if (missed.length > 0) {
1591
1623
  return text(
@@ -1600,13 +1632,53 @@ server.tool(
1600
1632
  user.publicKey
1601
1633
  );
1602
1634
  const applied = [...counts.entries()].map(([find, n]) => ` ${JSON.stringify(find)} -> ${n} element${n === 1 ? "" : "s"}`).join("\n");
1635
+ const grewNote = resizes.length ? "\n\nBoxes grown so the new text fits (nothing else moved, so check for overlap):\n" + resizes.map((r) => ` a ${r.shapeType} grew ${Math.round(r.from)}px -> ${Math.round(r.to)}px`).join("\n") : "";
1603
1636
  return text(
1604
1637
  `Updated "${scene.name}". ${elements.length} elements preserved; only the text below changed.
1605
- ${applied}
1638
+ ${applied}${grewNote}
1606
1639
  ${sheetUrl(workspace_id, sheet_id)}`
1607
1640
  );
1608
1641
  }
1609
1642
  );
1643
+ server.tool(
1644
+ "import_sheet",
1645
+ "Write a local .excalidraw file into a sheet, exactly as it is. Use this when geometry has to change \u2014 repositioning, resizing, re-anchoring arrows \u2014 which no spec can express and edit_sheet_text will not touch. Every coordinate in the file is preserved verbatim.",
1646
+ {
1647
+ workspace_id: import_zod.z.string(),
1648
+ file_path: import_zod.z.string().describe("Path to a .excalidraw file on this machine"),
1649
+ name: import_zod.z.string().describe("Sheet name"),
1650
+ sheet_id: import_zod.z.string().optional().describe("Omit to create a new sheet; supply it to replace an existing one")
1651
+ },
1652
+ async ({ workspace_id, file_path, name, sheet_id }) => {
1653
+ let parsed;
1654
+ try {
1655
+ parsed = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path2.resolve)(file_path), "utf8"));
1656
+ } catch (err) {
1657
+ return text(`Could not read ${file_path}: ${err.message}`);
1658
+ }
1659
+ const elements = parsed.elements;
1660
+ if (!Array.isArray(elements) || elements.length === 0) {
1661
+ return text(`${file_path} has no "elements" array \u2014 is it an .excalidraw file?`);
1662
+ }
1663
+ const issues = validateScene(elements);
1664
+ const noted = issues.length ? `
1665
+
1666
+ ${issues.length} thing(s) worth a look (imported anyway):
1667
+ ` + issues.slice(0, 5).map((i) => ` ${i.level}: ${i.message}`).join("\n") : "";
1668
+ const user = await currentUser();
1669
+ const payload = {
1670
+ name,
1671
+ elements,
1672
+ appState: parsed.appState ?? {}
1673
+ };
1674
+ const sheet = sheet_id ? await api().updateSheet(workspace_id, sheet_id, payload, user.publicKey) : await api().createSheet(workspace_id, payload, user.publicKey);
1675
+ const id = sheet_id ?? sheet.id;
1676
+ return text(
1677
+ `${sheet_id ? "Replaced" : "Created"} "${name}" from ${file_path} \u2014 ${elements.length} elements, every coordinate as in the file.
1678
+ ${sheetUrl(workspace_id, id)}${noted}`
1679
+ );
1680
+ }
1681
+ );
1610
1682
  async function main() {
1611
1683
  const command = process.argv[2];
1612
1684
  if (command === "login") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawpro/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for DrawPro \u2014 read and create end-to-end encrypted Excalidraw diagrams from Claude",
5
5
  "license": "MIT",
6
6
  "repository": {