@drawpro/mcp 0.3.0 → 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.
Files changed (3) hide show
  1. package/README.md +50 -0
  2. package/dist/server.js +296 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -121,6 +121,56 @@ Diagram specs describe *what connects to what*. Layout, sizing, text wrapping,
121
121
  and arrow binding are derived by `@drawpro/diagram` — a spec never contains
122
122
  coordinates.
123
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
+
124
174
  ## Tests
125
175
 
126
176
  ```bash
package/dist/server.js CHANGED
@@ -24,8 +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
+ var import_node_fs3 = require("node:fs");
28
+ var import_node_path3 = require("node:path");
29
29
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
30
30
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
31
31
  var import_zod = require("zod");
@@ -191,11 +191,13 @@ async function decryptSheet(encryptedData, privateKeyBytes) {
191
191
 
192
192
  // ../client/src/api.ts
193
193
  var DrawProClient = class {
194
- constructor(baseUrl, token) {
194
+ constructor(baseUrl, token, onRequest) {
195
195
  this.baseUrl = baseUrl;
196
196
  this.token = token;
197
+ this.onRequest = onRequest;
197
198
  }
198
199
  async request(path, init) {
200
+ const started = Date.now();
199
201
  const res = await fetch(`${this.baseUrl}${path}`, {
200
202
  ...init,
201
203
  headers: {
@@ -204,7 +206,19 @@ var DrawProClient = class {
204
206
  ...init?.headers ?? {}
205
207
  }
206
208
  });
207
- const body = await res.json().catch(() => ({}));
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
+ }
208
222
  if (!res.ok) {
209
223
  throw new Error(`${path} -> ${res.status} ${body.error ?? JSON.stringify(body)}`);
210
224
  }
@@ -387,6 +401,35 @@ function askHidden(question) {
387
401
  });
388
402
  }
389
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
+
390
433
  // ../diagram/src/ids.ts
391
434
  var import_fractional_indexing = require("fractional-indexing");
392
435
  var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
@@ -1358,10 +1401,13 @@ function api() {
1358
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'
1359
1402
  );
1360
1403
  }
1361
- clientInstance = new DrawProClient(BASE_URL, token);
1404
+ clientInstance = new DrawProClient(BASE_URL, token, (t) => inFlight.push(t));
1362
1405
  }
1363
1406
  return clientInstance;
1364
1407
  }
1408
+ var SESSION_ID = Math.random().toString(36).slice(2, 10);
1409
+ var VERSION = "0.4.0";
1410
+ var inFlight = [];
1365
1411
  var cachedUser = null;
1366
1412
  async function currentUser() {
1367
1413
  if (!cachedUser) cachedUser = await api().me();
@@ -1386,8 +1432,83 @@ function text(body) {
1386
1432
  function sheetUrl(workspaceId, sheetId) {
1387
1433
  return `${APP_URL}/workspace/${workspaceId}/sheet/${sheetId}`;
1388
1434
  }
1389
- var server = new import_mcp.McpServer({ name: "drawpro", version: "0.0.1" });
1390
- server.tool(
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(
1391
1512
  "list_workspaces",
1392
1513
  "List the DrawPro workspaces this account can access. Workspace names are encrypted at rest and are only readable once the account is unlocked.",
1393
1514
  {},
@@ -1406,7 +1527,7 @@ Names are encrypted. ${unlocked.error}` : "";
1406
1527
  return text(rows.join("\n") + note);
1407
1528
  }
1408
1529
  );
1409
- server.tool(
1530
+ tool(
1410
1531
  "list_sheets",
1411
1532
  "List the sheets in a DrawPro workspace, with their decrypted names.",
1412
1533
  { workspace_id: import_zod.z.string().describe("Workspace id, from list_workspaces") },
@@ -1425,7 +1546,7 @@ Names are encrypted. ${unlocked.error}` : "";
1425
1546
  return text((rows.join("\n") || "(no sheets)") + note);
1426
1547
  }
1427
1548
  );
1428
- server.tool(
1549
+ tool(
1429
1550
  "read_sheet",
1430
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.",
1431
1552
  {
@@ -1470,7 +1591,7 @@ var specShape = {
1470
1591
  "What connects to what. Layout, sizing, text wrapping, and arrow binding are derived \u2014 never supply coordinates."
1471
1592
  )
1472
1593
  };
1473
- server.tool(
1594
+ tool(
1474
1595
  "validate_spec",
1475
1596
  "Check a diagram spec without creating anything. Use this before writing if the diagram is large or the spec was assembled programmatically.",
1476
1597
  specShape,
@@ -1492,7 +1613,7 @@ function buildOrExplain(spec) {
1492
1613
  const warnings = issues.filter((i) => i.level === "warning");
1493
1614
  return { ok: true, scene, warnings: warnings.map((w) => w.message) };
1494
1615
  }
1495
- server.tool(
1616
+ tool(
1496
1617
  "create_diagram",
1497
1618
  "Create a new sheet in DrawPro from a diagram spec. Returns a link to open it.",
1498
1619
  {
@@ -1519,7 +1640,7 @@ ${sheetUrl(workspace_id, sheet.id)}${warned}`
1519
1640
  );
1520
1641
  }
1521
1642
  );
1522
- server.tool(
1643
+ tool(
1523
1644
  "update_diagram",
1524
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.",
1525
1646
  {
@@ -1578,7 +1699,7 @@ async function login(forget) {
1578
1699
  console.log(`Unlocked ${user.email}. Key stored in the ${location}.`);
1579
1700
  console.log("Claude can now read your sheets. The passcode was not stored or sent anywhere.");
1580
1701
  }
1581
- server.tool(
1702
+ tool(
1582
1703
  "edit_sheet_text",
1583
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.",
1584
1705
  {
@@ -1640,7 +1761,7 @@ ${sheetUrl(workspace_id, sheet_id)}`
1640
1761
  );
1641
1762
  }
1642
1763
  );
1643
- server.tool(
1764
+ tool(
1644
1765
  "import_sheet",
1645
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.",
1646
1767
  {
@@ -1652,7 +1773,7 @@ server.tool(
1652
1773
  async ({ workspace_id, file_path, name, sheet_id }) => {
1653
1774
  let parsed;
1654
1775
  try {
1655
- parsed = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path2.resolve)(file_path), "utf8"));
1776
+ parsed = JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path3.resolve)(file_path), "utf8"));
1656
1777
  } catch (err) {
1657
1778
  return text(`Could not read ${file_path}: ${err.message}`);
1658
1779
  }
@@ -1679,16 +1800,175 @@ ${sheetUrl(workspace_id, id)}${noted}`
1679
1800
  );
1680
1801
  }
1681
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
+ }
1682
1946
  async function main() {
1683
1947
  const command = process.argv[2];
1684
1948
  if (command === "login") {
1685
1949
  await login(process.argv.includes("--forget"));
1686
1950
  return;
1687
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
+ }
1688
1965
  if (command && command !== "serve") {
1689
- console.error(`Unknown command "${command}". Use: drawpro-mcp [serve|login] [--forget]`);
1966
+ console.error(
1967
+ `Unknown command "${command}". Use: drawpro-mcp [serve|login|stats|telemetry|report]`
1968
+ );
1690
1969
  process.exit(2);
1691
1970
  }
1971
+ maybeSendInBackground();
1692
1972
  await server.connect(new import_stdio.StdioServerTransport());
1693
1973
  }
1694
1974
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawpro/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": {