@drawpro/mcp 0.1.1 → 0.2.1

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
@@ -6,11 +6,18 @@ account from Claude Code, Claude Desktop, or any MCP client.
6
6
  ## Setup
7
7
 
8
8
  ```bash
9
- claude mcp add drawpro -e DRAWPRO_TOKEN="dp_live_..." -- npx -y @drawpro/mcp
9
+ claude mcp add drawpro --scope user -e DRAWPRO_TOKEN="dp_live_..." -- npx -y @drawpro/mcp
10
10
  ```
11
11
 
12
12
  Mint the token in DrawPro under **Connect to Claude Code**.
13
13
 
14
+ `--scope user` registers it for every project on your machine. The scope
15
+ otherwise defaults to `local`, which loads the server only in the directory the
16
+ command was run in — diagramming is not a property of one repo, so that default
17
+ is almost never what you want here. Avoid `--scope project`: it writes
18
+ `.mcp.json` into whichever repo you are standing in, token included, where it
19
+ invites being committed.
20
+
14
21
  To read existing sheets, unlock the account once:
15
22
 
16
23
  ```bash
@@ -67,7 +74,34 @@ passcode itself.
67
74
  | `read_sheet` | yes | returns a readable outline, not raw scene JSON |
68
75
  | `validate_spec` | no | no side effects; check before writing |
69
76
  | `create_diagram` | no | returns a link to the new sheet |
70
- | `update_diagram` | no | replaces the sheet wholesale |
77
+ | `update_diagram` | no | replaces the sheet wholesale — regenerates layout |
78
+ | `edit_sheet_text` | yes | rewrites named text in place, preserving every other element |
79
+
80
+ ### Editing a sheet a person drew
81
+
82
+ `update_diagram` builds the scene from a spec, so layout is derived and
83
+ hand-placed content cannot survive it: text elements positioned by hand, region
84
+ containers, and unbound annotation arrows all disappear, and the sheet comes
85
+ back as an auto-laid-out graph. Correct content, different diagram.
86
+
87
+ Use `edit_sheet_text` for those sheets. It reads the scene, rewrites only the
88
+ strings you name, and writes every other element back — so coordinates,
89
+ groupings, and annotations are untouched. Edits apply all-or-nothing: if any
90
+ `find` matches nothing, the sheet is left alone rather than half-updated.
91
+
92
+ The one geometry change it makes is growing a box whose text no longer fits.
93
+ Excalidraw regrows a container around text *bound* to it, but hand-drawn
94
+ diagrams usually have text merely sitting on top of a shape, so longer text
95
+ would otherwise spill out the bottom. Boxes are only ever grown, never shrunk,
96
+ and nothing else moves — reflowing neighbours would be the wholesale rewrite
97
+ this tool exists to avoid. Any box it grows is named in the result, so overlap
98
+ with a neighbour is easy to spot and one drag to fix.
99
+
100
+ It cannot add elements. A correction that needs a new box or a new annotation
101
+ line is still a manual edit.
102
+
103
+ `update_diagram` remains the right tool for sheets this package generated, where
104
+ regenerating the layout is the point.
71
105
 
72
106
  `read_sheet` deliberately returns shapes and edges rather than Excalidraw JSON.
73
107
  A real sheet's raw scene runs to tens of thousands of characters of coordinates,
package/dist/server.js CHANGED
@@ -1084,8 +1084,33 @@ function validateScene(elements) {
1084
1084
  return issues;
1085
1085
  }
1086
1086
 
1087
- // ../diagram/src/describe.ts
1087
+ // ../diagram/src/fit.ts
1088
1088
  var SHAPES = /* @__PURE__ */ new Set(["rectangle", "ellipse", "diamond"]);
1089
+ var PADDING = 16;
1090
+ function growBoxesToFitText(elements, changedTextIds) {
1091
+ const shapes = elements.filter((el) => SHAPES.has(el.type));
1092
+ const resizes = [];
1093
+ for (const text2 of elements) {
1094
+ if (text2.type !== "text" || !changedTextIds.has(text2.id)) continue;
1095
+ if (text2.containerId) continue;
1096
+ const cx = text2.x + text2.width / 2;
1097
+ const cy = text2.y + text2.height / 2;
1098
+ 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];
1099
+ if (!box) continue;
1100
+ const needed = Math.ceil(text2.y - box.y + text2.height + PADDING);
1101
+ if (needed > box.height) {
1102
+ resizes.push({ shapeId: box.id, shapeType: box.type, from: box.height, to: needed });
1103
+ box.height = needed;
1104
+ box.version = (box.version ?? 1) + 1;
1105
+ box.versionNonce = Math.floor(Math.random() * 2 ** 31);
1106
+ box.updated = Date.now();
1107
+ }
1108
+ }
1109
+ return resizes;
1110
+ }
1111
+
1112
+ // ../diagram/src/describe.ts
1113
+ var SHAPES2 = /* @__PURE__ */ new Set(["rectangle", "ellipse", "diamond"]);
1089
1114
  var ARROW_LABEL_RADIUS = 60;
1090
1115
  function centre(b) {
1091
1116
  return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
@@ -1126,7 +1151,7 @@ function describeScene(elements) {
1126
1151
  if (bound) return bound;
1127
1152
  const owner = byId.get(id);
1128
1153
  if (!owner) return "";
1129
- if (SHAPES.has(owner.type)) {
1154
+ if (SHAPES2.has(owner.type)) {
1130
1155
  const hit = floating.find((t) => !adopted.has(t.id) && contains(owner, centre(t)));
1131
1156
  if (hit) {
1132
1157
  adopted.add(hit.id);
@@ -1157,7 +1182,7 @@ function describeScene(elements) {
1157
1182
  };
1158
1183
  const counts = {};
1159
1184
  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) }));
1185
+ const shapes = elements.filter((el) => SHAPES2.has(el.type)).map((el) => ({ id: el.id, type: el.type, label: labelFor(el.id) }));
1161
1186
  const shapeLabel = new Map(shapes.map((s) => [s.id, s.label]));
1162
1187
  const edges = elements.filter((el) => el.type === "arrow").map((el) => {
1163
1188
  const start = el.startBinding;
@@ -1328,7 +1353,7 @@ function api() {
1328
1353
  const token = process.env.DRAWPRO_TOKEN;
1329
1354
  if (!token) {
1330
1355
  throw new ConfigError(
1331
- 'DRAWPRO_TOKEN is not set. Create a token in DrawPro under "Connect to Claude Code", then:\n claude mcp add drawpro -e DRAWPRO_TOKEN="dp_live_..." -- npx -y @drawpro/mcp'
1356
+ 'DRAWPRO_TOKEN is not set. Create a token in DrawPro under "Connect to Claude Code", then:\n claude mcp add drawpro --scope user -e DRAWPRO_TOKEN="dp_live_..." -- npx -y @drawpro/mcp'
1332
1357
  );
1333
1358
  }
1334
1359
  clientInstance = new DrawProClient(BASE_URL, token);
@@ -1551,6 +1576,68 @@ async function login(forget) {
1551
1576
  console.log(`Unlocked ${user.email}. Key stored in the ${location}.`);
1552
1577
  console.log("Claude can now read your sheets. The passcode was not stored or sent anywhere.");
1553
1578
  }
1579
+ server.tool(
1580
+ "edit_sheet_text",
1581
+ "Correct the wording on an existing sheet without redrawing it. Use this for any sheet a person drew: update_diagram regenerates layout from a spec, so it discards hand-placed elements, region containers, and unbound annotations. This rewrites only the text you name and leaves every other element, and every coordinate, exactly as it was.",
1582
+ {
1583
+ workspace_id: import_zod.z.string(),
1584
+ sheet_id: import_zod.z.string(),
1585
+ edits: import_zod.z.array(
1586
+ import_zod.z.object({
1587
+ find: import_zod.z.string().describe("The element's exact current text, as read_sheet reports it"),
1588
+ replace: import_zod.z.string()
1589
+ })
1590
+ ).describe("Applied all-or-nothing: if any find has no match, nothing is written.")
1591
+ },
1592
+ async ({ workspace_id, sheet_id, edits }) => {
1593
+ const unlocked = await unlockedKey();
1594
+ if ("error" in unlocked) return text(unlocked.error);
1595
+ const scene = await api().readSheet(workspace_id, sheet_id, unlocked.key);
1596
+ const elements = scene.elements;
1597
+ const counts = /* @__PURE__ */ new Map();
1598
+ const changedTextIds = /* @__PURE__ */ new Set();
1599
+ for (const el of elements) {
1600
+ if (el.type !== "text") continue;
1601
+ const current = (el.originalText ?? el.text)?.trim();
1602
+ if (current === void 0) continue;
1603
+ const edit = edits.find((e) => e.find.trim() === current);
1604
+ if (!edit) continue;
1605
+ el.text = edit.replace;
1606
+ el.originalText = edit.replace;
1607
+ const fontSize = el.fontSize ?? 20;
1608
+ const wrapWidth = el.containerId ? el.width : 1e3;
1609
+ const metrics = measureText(edit.replace, fontSize, wrapWidth);
1610
+ el.width = metrics.width;
1611
+ el.height = metrics.height;
1612
+ changedTextIds.add(el.id);
1613
+ el.version = (el.version ?? 1) + 1;
1614
+ el.versionNonce = Math.floor(Math.random() * 2 ** 31);
1615
+ el.updated = Date.now();
1616
+ counts.set(edit.find, (counts.get(edit.find) ?? 0) + 1);
1617
+ }
1618
+ const resizes = growBoxesToFitText(elements, changedTextIds);
1619
+ const missed = edits.filter((e) => !counts.has(e.find));
1620
+ if (missed.length > 0) {
1621
+ return text(
1622
+ "Nothing was written. These strings matched no text element:\n" + missed.map((e) => ` ${JSON.stringify(e.find)}`).join("\n") + "\n\nRun read_sheet and copy the text exactly as it appears there."
1623
+ );
1624
+ }
1625
+ const user = await currentUser();
1626
+ await api().updateSheet(
1627
+ workspace_id,
1628
+ sheet_id,
1629
+ { name: scene.name, elements, appState: scene.appState },
1630
+ user.publicKey
1631
+ );
1632
+ const applied = [...counts.entries()].map(([find, n]) => ` ${JSON.stringify(find)} -> ${n} element${n === 1 ? "" : "s"}`).join("\n");
1633
+ 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") : "";
1634
+ return text(
1635
+ `Updated "${scene.name}". ${elements.length} elements preserved; only the text below changed.
1636
+ ${applied}${grewNote}
1637
+ ${sheetUrl(workspace_id, sheet_id)}`
1638
+ );
1639
+ }
1640
+ );
1554
1641
  async function main() {
1555
1642
  const command = process.argv[2];
1556
1643
  if (command === "login") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawpro/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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": {