@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 +23 -4
- package/dist/server.js +80 -8
- package/package.json +1 -1
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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((
|
|
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", () =>
|
|
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
|
-
|
|
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/
|
|
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 (
|
|
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) =>
|
|
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
|
|
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") {
|