@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 +36 -2
- package/dist/server.js +91 -4
- package/package.json +1 -1
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/
|
|
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 (
|
|
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) =>
|
|
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") {
|