@drawpro/mcp 0.2.1 → 0.4.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 +61 -2
- package/dist/server.js +336 -15
- 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
|
|
|
@@ -97,8 +98,16 @@ and nothing else moves — reflowing neighbours would be the wholesale rewrite
|
|
|
97
98
|
this tool exists to avoid. Any box it grows is named in the result, so overlap
|
|
98
99
|
with a neighbour is easy to spot and one drag to fix.
|
|
99
100
|
|
|
100
|
-
It cannot add elements. A correction that needs a new box
|
|
101
|
-
|
|
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.
|
|
102
111
|
|
|
103
112
|
`update_diagram` remains the right tool for sheets this package generated, where
|
|
104
113
|
regenerating the layout is the point.
|
|
@@ -112,6 +121,56 @@ Diagram specs describe *what connects to what*. Layout, sizing, text wrapping,
|
|
|
112
121
|
and arrow binding are derived by `@drawpro/diagram` — a spec never contains
|
|
113
122
|
coordinates.
|
|
114
123
|
|
|
124
|
+
## Usage log
|
|
125
|
+
|
|
126
|
+
Set `DRAWPRO_MCP_LOG` to record every tool call as JSONL:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
claude mcp add drawpro --scope user \
|
|
130
|
+
-e DRAWPRO_TOKEN="dp_live_..." \
|
|
131
|
+
-e DRAWPRO_MCP_LOG="$HOME/.drawpro/usage.jsonl" \
|
|
132
|
+
-- npx -y @drawpro/mcp
|
|
133
|
+
|
|
134
|
+
drawpro-mcp stats # or: npx -y @drawpro/mcp stats
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
5 calls 2026-08-29 .. 2026-08-29
|
|
139
|
+
|
|
140
|
+
tool calls refused failed median
|
|
141
|
+
read_sheet 2 0 0% 0 881ms
|
|
142
|
+
edit_sheet_text 1 1 100% 0 62ms
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Opt-in, and never transmitted. `logCall` does one thing — append to that file —
|
|
146
|
+
and the only outbound request this package can make is to your own DrawPro
|
|
147
|
+
account's sheets. Unset the variable and nothing is written at all; there is no
|
|
148
|
+
default path and no fallback.
|
|
149
|
+
|
|
150
|
+
### How that feeds back into the package
|
|
151
|
+
|
|
152
|
+
The raw log stays on your machine and carries workspace and sheet ids, so you
|
|
153
|
+
can correlate calls against your own account. `stats` is the aggregate: tool
|
|
154
|
+
names, counts, timings, and nothing that identifies an account, a workspace, a
|
|
155
|
+
sheet, or anything drawn on one.
|
|
156
|
+
|
|
157
|
+
That split is the point. Sharing is a decision you make, not a default the
|
|
158
|
+
package makes for you:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
drawpro-mcp stats --json # paste into an issue
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
There is no telemetry endpoint, and adding one to a product built on the server
|
|
165
|
+
never seeing your diagrams would be the wrong trade. If a tool is refusing
|
|
166
|
+
often, the aggregate says so without anyone learning what you were drawing.
|
|
167
|
+
|
|
168
|
+
The column worth watching is **refused**. A tool that frequently declines is one
|
|
169
|
+
whose description is not steering the model well, and that is cheaper to learn
|
|
170
|
+
from real use than from a synthetic eval — both `edit_sheet_text` and
|
|
171
|
+
`import_sheet` exist because real use found them missing, and no eval written
|
|
172
|
+
beforehand would have predicted either.
|
|
173
|
+
|
|
115
174
|
## Tests
|
|
116
175
|
|
|
117
176
|
```bash
|
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_fs3 = require("node:fs");
|
|
28
|
+
var import_node_path3 = 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");
|
|
@@ -189,11 +191,13 @@ async function decryptSheet(encryptedData, privateKeyBytes) {
|
|
|
189
191
|
|
|
190
192
|
// ../client/src/api.ts
|
|
191
193
|
var DrawProClient = class {
|
|
192
|
-
constructor(baseUrl, token) {
|
|
194
|
+
constructor(baseUrl, token, onRequest) {
|
|
193
195
|
this.baseUrl = baseUrl;
|
|
194
196
|
this.token = token;
|
|
197
|
+
this.onRequest = onRequest;
|
|
195
198
|
}
|
|
196
199
|
async request(path, init) {
|
|
200
|
+
const started = Date.now();
|
|
197
201
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
198
202
|
...init,
|
|
199
203
|
headers: {
|
|
@@ -202,7 +206,19 @@ var DrawProClient = class {
|
|
|
202
206
|
...init?.headers ?? {}
|
|
203
207
|
}
|
|
204
208
|
});
|
|
205
|
-
const
|
|
209
|
+
const raw = await res.text();
|
|
210
|
+
this.onRequest?.({
|
|
211
|
+
method: init?.method ?? "GET",
|
|
212
|
+
path: path.replace(/\/[a-z0-9]{20,}/gi, "/:id"),
|
|
213
|
+
status: res.status,
|
|
214
|
+
ms: Date.now() - started,
|
|
215
|
+
bytes: raw.length
|
|
216
|
+
});
|
|
217
|
+
let body = {};
|
|
218
|
+
try {
|
|
219
|
+
body = JSON.parse(raw);
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
206
222
|
if (!res.ok) {
|
|
207
223
|
throw new Error(`${path} -> ${res.status} ${body.error ?? JSON.stringify(body)}`);
|
|
208
224
|
}
|
|
@@ -338,7 +354,7 @@ function forgetKey(email) {
|
|
|
338
354
|
|
|
339
355
|
// ../client/src/prompt.ts
|
|
340
356
|
function askHidden(question) {
|
|
341
|
-
return new Promise((
|
|
357
|
+
return new Promise((resolve2) => {
|
|
342
358
|
const input = process.stdin;
|
|
343
359
|
process.stdout.write(question);
|
|
344
360
|
if (!input.isTTY) {
|
|
@@ -347,7 +363,7 @@ function askHidden(question) {
|
|
|
347
363
|
input.on("data", (chunk) => {
|
|
348
364
|
buffered += chunk;
|
|
349
365
|
});
|
|
350
|
-
input.on("end", () =>
|
|
366
|
+
input.on("end", () => resolve2(buffered.split("\n")[0].trim()));
|
|
351
367
|
return;
|
|
352
368
|
}
|
|
353
369
|
const wasRaw = input.isRaw;
|
|
@@ -363,7 +379,7 @@ function askHidden(question) {
|
|
|
363
379
|
if (result === null) {
|
|
364
380
|
process.exit(130);
|
|
365
381
|
}
|
|
366
|
-
|
|
382
|
+
resolve2(result);
|
|
367
383
|
};
|
|
368
384
|
const onData = (chunk) => {
|
|
369
385
|
for (const ch of chunk) {
|
|
@@ -385,6 +401,35 @@ function askHidden(question) {
|
|
|
385
401
|
});
|
|
386
402
|
}
|
|
387
403
|
|
|
404
|
+
// ../client/src/config.ts
|
|
405
|
+
var import_node_fs2 = require("node:fs");
|
|
406
|
+
var import_node_os2 = require("node:os");
|
|
407
|
+
var import_node_path2 = require("node:path");
|
|
408
|
+
var import_node_crypto3 = require("node:crypto");
|
|
409
|
+
var DIR = (0, import_node_path2.join)((0, import_node_os2.homedir)(), ".drawpro");
|
|
410
|
+
var FILE = (0, import_node_path2.join)(DIR, "config.json");
|
|
411
|
+
function readConfig() {
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(FILE, "utf8"));
|
|
414
|
+
} catch {
|
|
415
|
+
return {};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function writeConfig(patch) {
|
|
419
|
+
const next = { ...readConfig(), ...patch };
|
|
420
|
+
(0, import_node_fs2.mkdirSync)(DIR, { recursive: true, mode: 448 });
|
|
421
|
+
(0, import_node_fs2.writeFileSync)(FILE, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
|
|
422
|
+
return next;
|
|
423
|
+
}
|
|
424
|
+
function installId() {
|
|
425
|
+
const config = readConfig();
|
|
426
|
+
if (config.installId) return config.installId;
|
|
427
|
+
return writeConfig({ installId: (0, import_node_crypto3.randomUUID)() }).installId;
|
|
428
|
+
}
|
|
429
|
+
function telemetryEnabled() {
|
|
430
|
+
return readConfig().telemetry === "on";
|
|
431
|
+
}
|
|
432
|
+
|
|
388
433
|
// ../diagram/src/ids.ts
|
|
389
434
|
var import_fractional_indexing = require("fractional-indexing");
|
|
390
435
|
var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
|
|
@@ -1356,10 +1401,13 @@ function api() {
|
|
|
1356
1401
|
'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'
|
|
1357
1402
|
);
|
|
1358
1403
|
}
|
|
1359
|
-
clientInstance = new DrawProClient(BASE_URL, token);
|
|
1404
|
+
clientInstance = new DrawProClient(BASE_URL, token, (t) => inFlight.push(t));
|
|
1360
1405
|
}
|
|
1361
1406
|
return clientInstance;
|
|
1362
1407
|
}
|
|
1408
|
+
var SESSION_ID = Math.random().toString(36).slice(2, 10);
|
|
1409
|
+
var VERSION = "0.4.0";
|
|
1410
|
+
var inFlight = [];
|
|
1363
1411
|
var cachedUser = null;
|
|
1364
1412
|
async function currentUser() {
|
|
1365
1413
|
if (!cachedUser) cachedUser = await api().me();
|
|
@@ -1384,8 +1432,83 @@ function text(body) {
|
|
|
1384
1432
|
function sheetUrl(workspaceId, sheetId) {
|
|
1385
1433
|
return `${APP_URL}/workspace/${workspaceId}/sheet/${sheetId}`;
|
|
1386
1434
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1435
|
+
function logCall(entry) {
|
|
1436
|
+
const path = process.env.DRAWPRO_MCP_LOG;
|
|
1437
|
+
if (!path) return;
|
|
1438
|
+
try {
|
|
1439
|
+
(0, import_node_fs3.appendFileSync)(path, JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry }) + "\n");
|
|
1440
|
+
} catch {
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
var REFUSALS = [
|
|
1444
|
+
"Nothing was written",
|
|
1445
|
+
"The diagram was not created",
|
|
1446
|
+
"is locked",
|
|
1447
|
+
"Could not read",
|
|
1448
|
+
'has no "elements" array'
|
|
1449
|
+
];
|
|
1450
|
+
function summarise(args) {
|
|
1451
|
+
const spec = args.spec;
|
|
1452
|
+
const edits = args.edits;
|
|
1453
|
+
return {
|
|
1454
|
+
workspace_id: args.workspace_id,
|
|
1455
|
+
sheet_id: args.sheet_id,
|
|
1456
|
+
...spec ? { nodes: spec.nodes?.length ?? 0, edges: spec.edges?.length ?? 0 } : {},
|
|
1457
|
+
...edits ? { edits: edits.length } : {},
|
|
1458
|
+
...args.file_path ? { from_file: true } : {}
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
function instrument(name, handler) {
|
|
1462
|
+
return (async (args) => {
|
|
1463
|
+
const started = Date.now();
|
|
1464
|
+
inFlight = [];
|
|
1465
|
+
try {
|
|
1466
|
+
const result = await handler(args);
|
|
1467
|
+
const body = result.content.map((c) => c.text).join("\n");
|
|
1468
|
+
const apiMs = inFlight.reduce((sum, t) => sum + t.ms, 0);
|
|
1469
|
+
const total = Date.now() - started;
|
|
1470
|
+
logCall({
|
|
1471
|
+
session: SESSION_ID,
|
|
1472
|
+
tool: name,
|
|
1473
|
+
ok: true,
|
|
1474
|
+
refused: REFUSALS.some((r) => body.includes(r)),
|
|
1475
|
+
ms: total,
|
|
1476
|
+
api_ms: apiMs,
|
|
1477
|
+
local_ms: total - apiMs,
|
|
1478
|
+
requests: inFlight.map((t) => ({ m: t.method, p: t.path, s: t.status, ms: t.ms, bytes: t.bytes })),
|
|
1479
|
+
args: summarise(args),
|
|
1480
|
+
// Tools word this two ways: "38 elements" and "elements: 38".
|
|
1481
|
+
elements: Number(body.match(/(\d+) elements/)?.[1] ?? body.match(/elements: (\d+)/)?.[1]) || void 0
|
|
1482
|
+
});
|
|
1483
|
+
return result;
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
const apiMs = inFlight.reduce((sum, t) => sum + t.ms, 0);
|
|
1486
|
+
const total = Date.now() - started;
|
|
1487
|
+
logCall({
|
|
1488
|
+
session: SESSION_ID,
|
|
1489
|
+
tool: name,
|
|
1490
|
+
ok: false,
|
|
1491
|
+
ms: total,
|
|
1492
|
+
api_ms: apiMs,
|
|
1493
|
+
local_ms: total - apiMs,
|
|
1494
|
+
requests: inFlight.map((t) => ({ m: t.method, p: t.path, s: t.status, ms: t.ms, bytes: t.bytes })),
|
|
1495
|
+
args: summarise(args),
|
|
1496
|
+
error: err.message.slice(0, 200)
|
|
1497
|
+
});
|
|
1498
|
+
throw err;
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
var server = new import_mcp.McpServer({ name: "drawpro", version: VERSION });
|
|
1503
|
+
var tool = ((name, ...rest) => {
|
|
1504
|
+
const handler = rest.pop();
|
|
1505
|
+
return server.tool(
|
|
1506
|
+
name,
|
|
1507
|
+
...rest,
|
|
1508
|
+
instrument(name, handler)
|
|
1509
|
+
);
|
|
1510
|
+
});
|
|
1511
|
+
tool(
|
|
1389
1512
|
"list_workspaces",
|
|
1390
1513
|
"List the DrawPro workspaces this account can access. Workspace names are encrypted at rest and are only readable once the account is unlocked.",
|
|
1391
1514
|
{},
|
|
@@ -1404,7 +1527,7 @@ Names are encrypted. ${unlocked.error}` : "";
|
|
|
1404
1527
|
return text(rows.join("\n") + note);
|
|
1405
1528
|
}
|
|
1406
1529
|
);
|
|
1407
|
-
|
|
1530
|
+
tool(
|
|
1408
1531
|
"list_sheets",
|
|
1409
1532
|
"List the sheets in a DrawPro workspace, with their decrypted names.",
|
|
1410
1533
|
{ workspace_id: import_zod.z.string().describe("Workspace id, from list_workspaces") },
|
|
@@ -1423,7 +1546,7 @@ Names are encrypted. ${unlocked.error}` : "";
|
|
|
1423
1546
|
return text((rows.join("\n") || "(no sheets)") + note);
|
|
1424
1547
|
}
|
|
1425
1548
|
);
|
|
1426
|
-
|
|
1549
|
+
tool(
|
|
1427
1550
|
"read_sheet",
|
|
1428
1551
|
"Read what a DrawPro sheet contains: its shapes, and which arrows connect what. Returns a readable outline rather than raw Excalidraw JSON, which is mostly coordinates and style.",
|
|
1429
1552
|
{
|
|
@@ -1468,7 +1591,7 @@ var specShape = {
|
|
|
1468
1591
|
"What connects to what. Layout, sizing, text wrapping, and arrow binding are derived \u2014 never supply coordinates."
|
|
1469
1592
|
)
|
|
1470
1593
|
};
|
|
1471
|
-
|
|
1594
|
+
tool(
|
|
1472
1595
|
"validate_spec",
|
|
1473
1596
|
"Check a diagram spec without creating anything. Use this before writing if the diagram is large or the spec was assembled programmatically.",
|
|
1474
1597
|
specShape,
|
|
@@ -1490,7 +1613,7 @@ function buildOrExplain(spec) {
|
|
|
1490
1613
|
const warnings = issues.filter((i) => i.level === "warning");
|
|
1491
1614
|
return { ok: true, scene, warnings: warnings.map((w) => w.message) };
|
|
1492
1615
|
}
|
|
1493
|
-
|
|
1616
|
+
tool(
|
|
1494
1617
|
"create_diagram",
|
|
1495
1618
|
"Create a new sheet in DrawPro from a diagram spec. Returns a link to open it.",
|
|
1496
1619
|
{
|
|
@@ -1517,7 +1640,7 @@ ${sheetUrl(workspace_id, sheet.id)}${warned}`
|
|
|
1517
1640
|
);
|
|
1518
1641
|
}
|
|
1519
1642
|
);
|
|
1520
|
-
|
|
1643
|
+
tool(
|
|
1521
1644
|
"update_diagram",
|
|
1522
1645
|
"Replace a sheet\u2019s contents with a new diagram. This overwrites the whole sheet, so read_sheet first if you intend to preserve anything already there.",
|
|
1523
1646
|
{
|
|
@@ -1576,7 +1699,7 @@ async function login(forget) {
|
|
|
1576
1699
|
console.log(`Unlocked ${user.email}. Key stored in the ${location}.`);
|
|
1577
1700
|
console.log("Claude can now read your sheets. The passcode was not stored or sent anywhere.");
|
|
1578
1701
|
}
|
|
1579
|
-
|
|
1702
|
+
tool(
|
|
1580
1703
|
"edit_sheet_text",
|
|
1581
1704
|
"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
1705
|
{
|
|
@@ -1638,16 +1761,214 @@ ${sheetUrl(workspace_id, sheet_id)}`
|
|
|
1638
1761
|
);
|
|
1639
1762
|
}
|
|
1640
1763
|
);
|
|
1764
|
+
tool(
|
|
1765
|
+
"import_sheet",
|
|
1766
|
+
"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.",
|
|
1767
|
+
{
|
|
1768
|
+
workspace_id: import_zod.z.string(),
|
|
1769
|
+
file_path: import_zod.z.string().describe("Path to a .excalidraw file on this machine"),
|
|
1770
|
+
name: import_zod.z.string().describe("Sheet name"),
|
|
1771
|
+
sheet_id: import_zod.z.string().optional().describe("Omit to create a new sheet; supply it to replace an existing one")
|
|
1772
|
+
},
|
|
1773
|
+
async ({ workspace_id, file_path, name, sheet_id }) => {
|
|
1774
|
+
let parsed;
|
|
1775
|
+
try {
|
|
1776
|
+
parsed = JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path3.resolve)(file_path), "utf8"));
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
return text(`Could not read ${file_path}: ${err.message}`);
|
|
1779
|
+
}
|
|
1780
|
+
const elements = parsed.elements;
|
|
1781
|
+
if (!Array.isArray(elements) || elements.length === 0) {
|
|
1782
|
+
return text(`${file_path} has no "elements" array \u2014 is it an .excalidraw file?`);
|
|
1783
|
+
}
|
|
1784
|
+
const issues = validateScene(elements);
|
|
1785
|
+
const noted = issues.length ? `
|
|
1786
|
+
|
|
1787
|
+
${issues.length} thing(s) worth a look (imported anyway):
|
|
1788
|
+
` + issues.slice(0, 5).map((i) => ` ${i.level}: ${i.message}`).join("\n") : "";
|
|
1789
|
+
const user = await currentUser();
|
|
1790
|
+
const payload = {
|
|
1791
|
+
name,
|
|
1792
|
+
elements,
|
|
1793
|
+
appState: parsed.appState ?? {}
|
|
1794
|
+
};
|
|
1795
|
+
const sheet = sheet_id ? await api().updateSheet(workspace_id, sheet_id, payload, user.publicKey) : await api().createSheet(workspace_id, payload, user.publicKey);
|
|
1796
|
+
const id = sheet_id ?? sheet.id;
|
|
1797
|
+
return text(
|
|
1798
|
+
`${sheet_id ? "Replaced" : "Created"} "${name}" from ${file_path} \u2014 ${elements.length} elements, every coordinate as in the file.
|
|
1799
|
+
${sheetUrl(workspace_id, id)}${noted}`
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
);
|
|
1803
|
+
var WRITE_TOOLS = ["create_diagram", "update_diagram", "edit_sheet_text", "import_sheet"];
|
|
1804
|
+
function summariseLog(file) {
|
|
1805
|
+
let rows;
|
|
1806
|
+
try {
|
|
1807
|
+
rows = (0, import_node_fs3.readFileSync)(file, "utf8").split("\n").filter(Boolean).flatMap((line) => {
|
|
1808
|
+
try {
|
|
1809
|
+
return [JSON.parse(line)];
|
|
1810
|
+
} catch {
|
|
1811
|
+
return [];
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
} catch {
|
|
1815
|
+
return null;
|
|
1816
|
+
}
|
|
1817
|
+
if (rows.length === 0) return null;
|
|
1818
|
+
const byTool = /* @__PURE__ */ new Map();
|
|
1819
|
+
for (const r of rows) {
|
|
1820
|
+
const name = String(r.tool);
|
|
1821
|
+
const acc = byTool.get(name) ?? { n: 0, refused: 0, failed: 0, ms: [] };
|
|
1822
|
+
acc.n++;
|
|
1823
|
+
if (r.refused) acc.refused++;
|
|
1824
|
+
if (r.ok === false) acc.failed++;
|
|
1825
|
+
if (typeof r.ms === "number") acc.ms.push(r.ms);
|
|
1826
|
+
byTool.set(name, acc);
|
|
1827
|
+
}
|
|
1828
|
+
const median = (xs) => xs.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : 0;
|
|
1829
|
+
return {
|
|
1830
|
+
calls: rows.length,
|
|
1831
|
+
writes: rows.filter(
|
|
1832
|
+
(r) => WRITE_TOOLS.includes(String(r.tool)) && !r.refused && r.ok !== false
|
|
1833
|
+
).length,
|
|
1834
|
+
from: String(rows[0].ts).slice(0, 10),
|
|
1835
|
+
to: String(rows[rows.length - 1].ts).slice(0, 10),
|
|
1836
|
+
tools: [...byTool.entries()].sort((x, y) => y[1].n - x[1].n).map(([tool2, a]) => ({
|
|
1837
|
+
tool: tool2,
|
|
1838
|
+
calls: a.n,
|
|
1839
|
+
refused: a.refused,
|
|
1840
|
+
failed: a.failed,
|
|
1841
|
+
median_ms: median(a.ms)
|
|
1842
|
+
}))
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
function stats(path, asJson) {
|
|
1846
|
+
const file = path ?? process.env.DRAWPRO_MCP_LOG;
|
|
1847
|
+
if (!file) {
|
|
1848
|
+
console.error("Set DRAWPRO_MCP_LOG, or pass a path: drawpro-mcp stats <file>");
|
|
1849
|
+
process.exit(2);
|
|
1850
|
+
}
|
|
1851
|
+
const summary = summariseLog(file);
|
|
1852
|
+
if (!summary) {
|
|
1853
|
+
console.log(asJson ? '{"calls":0}' : "No calls recorded yet.");
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
if (asJson) {
|
|
1857
|
+
console.log(JSON.stringify({ version: VERSION, ...summary }, null, 2));
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
console.log(`${summary.calls} calls ${summary.from} .. ${summary.to}
|
|
1861
|
+
`);
|
|
1862
|
+
console.log(" tool calls refused failed median");
|
|
1863
|
+
for (const t of summary.tools) {
|
|
1864
|
+
const pct = t.calls ? Math.round(t.refused / t.calls * 100) : 0;
|
|
1865
|
+
console.log(
|
|
1866
|
+
` ${t.tool.padEnd(18)} ${String(t.calls).padStart(5)} ${String(t.refused).padStart(4)} ${String(pct).padStart(3)}% ${String(t.failed).padStart(6)} ${String(t.median_ms).padStart(5)}ms`
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
console.log(`
|
|
1870
|
+
${summary.writes} successful writes to sheets`);
|
|
1871
|
+
console.log(
|
|
1872
|
+
"\n No ids, account details, or diagram content above \u2014 safe to paste into a bug report."
|
|
1873
|
+
);
|
|
1874
|
+
console.log(" Add --json for a machine-readable copy.");
|
|
1875
|
+
}
|
|
1876
|
+
function buildReport() {
|
|
1877
|
+
const file = process.env.DRAWPRO_MCP_LOG;
|
|
1878
|
+
if (!file) return null;
|
|
1879
|
+
const summary = summariseLog(file);
|
|
1880
|
+
if (!summary) return null;
|
|
1881
|
+
return {
|
|
1882
|
+
installId: installId(),
|
|
1883
|
+
mcpVersion: VERSION,
|
|
1884
|
+
calls: summary.calls,
|
|
1885
|
+
writes: summary.writes,
|
|
1886
|
+
tools: summary.tools
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
async function sendReport(report) {
|
|
1890
|
+
try {
|
|
1891
|
+
const res = await fetch(`${BASE_URL}/telemetry`, {
|
|
1892
|
+
method: "POST",
|
|
1893
|
+
headers: { "Content-Type": "application/json" },
|
|
1894
|
+
body: JSON.stringify(report)
|
|
1895
|
+
});
|
|
1896
|
+
return { ok: res.ok, detail: `HTTP ${res.status}` };
|
|
1897
|
+
} catch (err) {
|
|
1898
|
+
return { ok: false, detail: err.message };
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
async function telemetry(action) {
|
|
1902
|
+
if (action === "off") {
|
|
1903
|
+
writeConfig({ telemetry: "off" });
|
|
1904
|
+
console.log("Telemetry off. Nothing will be sent.");
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
const report = buildReport();
|
|
1908
|
+
if (action === "on") {
|
|
1909
|
+
writeConfig({ telemetry: "on" });
|
|
1910
|
+
console.log("Telemetry on. Roughly once a day, this is sent:\n");
|
|
1911
|
+
console.log(report ? JSON.stringify(report, null, 2) : " (nothing yet \u2014 set DRAWPRO_MCP_LOG to record usage)");
|
|
1912
|
+
console.log("\nTurn it off any time with: drawpro-mcp telemetry off");
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
console.log(`Telemetry is ${telemetryEnabled() ? "ON" : "OFF"}.
|
|
1916
|
+
`);
|
|
1917
|
+
console.log("If enabled, this is the entire payload \u2014 tool counts and timings,");
|
|
1918
|
+
console.log("no account, no token, no workspace or sheet ids, nothing drawn:\n");
|
|
1919
|
+
console.log(report ? JSON.stringify(report, null, 2) : " (nothing recorded yet \u2014 set DRAWPRO_MCP_LOG first)");
|
|
1920
|
+
console.log("\n drawpro-mcp telemetry on share it");
|
|
1921
|
+
console.log(" drawpro-mcp telemetry off stop");
|
|
1922
|
+
console.log(" drawpro-mcp report send once now, without turning it on");
|
|
1923
|
+
}
|
|
1924
|
+
async function reportOnce() {
|
|
1925
|
+
const report = buildReport();
|
|
1926
|
+
if (!report) {
|
|
1927
|
+
console.error("Nothing to report. Set DRAWPRO_MCP_LOG to record usage first.");
|
|
1928
|
+
process.exit(2);
|
|
1929
|
+
}
|
|
1930
|
+
console.log("Sending:\n");
|
|
1931
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1932
|
+
const { ok, detail } = await sendReport(report);
|
|
1933
|
+
console.log(ok ? "\nSent. Thank you." : `
|
|
1934
|
+
Could not send (${detail}). Paste the JSON above into an issue instead.`);
|
|
1935
|
+
}
|
|
1936
|
+
function maybeSendInBackground() {
|
|
1937
|
+
if (!telemetryEnabled()) return;
|
|
1938
|
+
const last = readConfig().lastReportAt;
|
|
1939
|
+
if (last && Date.now() - Date.parse(last) < 24 * 60 * 60 * 1e3) return;
|
|
1940
|
+
const report = buildReport();
|
|
1941
|
+
if (!report) return;
|
|
1942
|
+
void sendReport(report).then(({ ok }) => {
|
|
1943
|
+
if (ok) writeConfig({ lastReportAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1641
1946
|
async function main() {
|
|
1642
1947
|
const command = process.argv[2];
|
|
1643
1948
|
if (command === "login") {
|
|
1644
1949
|
await login(process.argv.includes("--forget"));
|
|
1645
1950
|
return;
|
|
1646
1951
|
}
|
|
1952
|
+
if (command === "telemetry") {
|
|
1953
|
+
await telemetry(process.argv[3]);
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (command === "report") {
|
|
1957
|
+
await reportOnce();
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (command === "stats") {
|
|
1961
|
+
const rest = process.argv.slice(3);
|
|
1962
|
+
stats(rest.find((a) => !a.startsWith("--")), rest.includes("--json"));
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1647
1965
|
if (command && command !== "serve") {
|
|
1648
|
-
console.error(
|
|
1966
|
+
console.error(
|
|
1967
|
+
`Unknown command "${command}". Use: drawpro-mcp [serve|login|stats|telemetry|report]`
|
|
1968
|
+
);
|
|
1649
1969
|
process.exit(2);
|
|
1650
1970
|
}
|
|
1971
|
+
maybeSendInBackground();
|
|
1651
1972
|
await server.connect(new import_stdio.StdioServerTransport());
|
|
1652
1973
|
}
|
|
1653
1974
|
main().catch((err) => {
|