@sanjibdevnath/mcp-excalidraw-local 1.0.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 (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +458 -0
  3. package/dist/db.d.ts +58 -0
  4. package/dist/db.d.ts.map +1 -0
  5. package/dist/db.js +379 -0
  6. package/dist/db.js.map +1 -0
  7. package/dist/frontend/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
  8. package/dist/frontend/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
  9. package/dist/frontend/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
  10. package/dist/frontend/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
  11. package/dist/frontend/assets/Tableau10-B-NsZVaP.js +1 -0
  12. package/dist/frontend/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
  13. package/dist/frontend/assets/advancedFormat-BvOvfnfC.js +1 -0
  14. package/dist/frontend/assets/ar-SA-G6X2FPQ2-75HMOOy8.js +10 -0
  15. package/dist/frontend/assets/arc-D-322MQz.js +1 -0
  16. package/dist/frontend/assets/array-BKyUJesY.js +1 -0
  17. package/dist/frontend/assets/az-AZ-76LH7QW2-DPDwkDvh.js +1 -0
  18. package/dist/frontend/assets/band-dPffDWoQ.js +1 -0
  19. package/dist/frontend/assets/bg-BG-XCXSNQG7-DrFYc9eo.js +5 -0
  20. package/dist/frontend/assets/blockDiagram-38ab4fdb-Ch8bwO7g.js +118 -0
  21. package/dist/frontend/assets/blockDiagram-68f4deed-BVqzkDiu.js +118 -0
  22. package/dist/frontend/assets/bn-BD-2XOGV67Q-B1Y75Cvj.js +5 -0
  23. package/dist/frontend/assets/c4Diagram-15b5d702-D5U2mSdf.js +10 -0
  24. package/dist/frontend/assets/c4Diagram-3d4e48cf-eT2EEN_c.js +10 -0
  25. package/dist/frontend/assets/ca-ES-6MX7JW3Y-00BTiK3Z.js +8 -0
  26. package/dist/frontend/assets/channel-CudwHHli.js +1 -0
  27. package/dist/frontend/assets/classDiagram-70f12bd4-CcNOdQHv.js +2 -0
  28. package/dist/frontend/assets/classDiagram-d40c83e7-nRIgRTMT.js +2 -0
  29. package/dist/frontend/assets/classDiagram-v2-d5a6b087-Cfbvao44.js +2 -0
  30. package/dist/frontend/assets/classDiagram-v2-f2320105-1Sjp5Uqh.js +2 -0
  31. package/dist/frontend/assets/clone-D_tGm99B.js +1 -0
  32. package/dist/frontend/assets/createText-2e5e7dd3-Bpmkp1eZ.js +5 -0
  33. package/dist/frontend/assets/createText-d213de94-3MLB4fd8.js +5 -0
  34. package/dist/frontend/assets/cs-CZ-2BRQDIVT-R7SCWLLF.js +11 -0
  35. package/dist/frontend/assets/cytoscape-cose-bilkent-CoIxD6ON.js +331 -0
  36. package/dist/frontend/assets/da-DK-5WZEPLOC-Db1yebad.js +5 -0
  37. package/dist/frontend/assets/de-DE-XR44H4JA-HRE-6fuh.js +8 -0
  38. package/dist/frontend/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
  39. package/dist/frontend/assets/directory-open-4ed118d0-BzWybGaI.js +1 -0
  40. package/dist/frontend/assets/edges-332bd1c7-DZAOA9uP.js +4 -0
  41. package/dist/frontend/assets/edges-e0da2a9e-CP-XTLb4.js +4 -0
  42. package/dist/frontend/assets/el-GR-BZB4AONW-CfNczSdx.js +10 -0
  43. package/dist/frontend/assets/elk.bundled-BZDcWavb.js +26 -0
  44. package/dist/frontend/assets/erDiagram-880f2ed8-Bk96tDga.js +51 -0
  45. package/dist/frontend/assets/erDiagram-9861fffd-BvkEkcRK.js +51 -0
  46. package/dist/frontend/assets/es-ES-U4NZUMDT-BBJZ1_wD.js +9 -0
  47. package/dist/frontend/assets/eu-ES-A7QVB2H4-CCLNmdnk.js +11 -0
  48. package/dist/frontend/assets/fa-IR-HGAKTJCU-BtKS5FOW.js +8 -0
  49. package/dist/frontend/assets/fi-FI-Z5N7JZ37-DEQi6vbL.js +6 -0
  50. package/dist/frontend/assets/file-open-002ab408-DIuFHtCF.js +1 -0
  51. package/dist/frontend/assets/file-open-7c801643-684qeFg4.js +1 -0
  52. package/dist/frontend/assets/file-save-3189631c-x92wctJd.js +1 -0
  53. package/dist/frontend/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
  54. package/dist/frontend/assets/flowDb-7c981674-JJMg1ttK.js +10 -0
  55. package/dist/frontend/assets/flowDb-956e92f1-CVVUllPW.js +10 -0
  56. package/dist/frontend/assets/flowDiagram-66a62f08-wGFuUp6y.js +4 -0
  57. package/dist/frontend/assets/flowDiagram-cbd28bf7-CXKT_tHC.js +4 -0
  58. package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-CN4ht1EM.js +1 -0
  59. package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-CFiBItzu.js +1 -0
  60. package/dist/frontend/assets/flowchart-elk-definition-36e2d292-Cam5JBwn.js +114 -0
  61. package/dist/frontend/assets/flowchart-elk-definition-4a651766-BoyD4myW.js +114 -0
  62. package/dist/frontend/assets/fr-FR-RHASNOE6-_AQjPuKS.js +9 -0
  63. package/dist/frontend/assets/ganttDiagram-04f9e578-DrDI9_oS.js +257 -0
  64. package/dist/frontend/assets/ganttDiagram-c361ad54-CKSyNc2k.js +257 -0
  65. package/dist/frontend/assets/gitGraphDiagram-21fc4d3e-BHBdnwSb.js +70 -0
  66. package/dist/frontend/assets/gitGraphDiagram-72cf32ee-BHN9qiXg.js +70 -0
  67. package/dist/frontend/assets/gl-ES-HMX3MZ6V-Bp2h6sBC.js +10 -0
  68. package/dist/frontend/assets/graph-CRb9j7zI.js +1 -0
  69. package/dist/frontend/assets/graph-EK5j_nPe.js +1 -0
  70. package/dist/frontend/assets/he-IL-6SHJWFNN-hsaAKZ5K.js +10 -0
  71. package/dist/frontend/assets/hi-IN-IWLTKZ5I-sgYSNzoz.js +4 -0
  72. package/dist/frontend/assets/hu-HU-A5ZG7DT2-DxYZr0yq.js +7 -0
  73. package/dist/frontend/assets/id-ID-SAP4L64H-z0RzSKPQ.js +10 -0
  74. package/dist/frontend/assets/image-blob-reduce.esm-B6b2_-a4.js +7 -0
  75. package/dist/frontend/assets/index-3862675e-CQPsxwvk.js +1 -0
  76. package/dist/frontend/assets/index-6079d271-pTR-OMc-.js +1 -0
  77. package/dist/frontend/assets/index-B9Rh8YyQ.css +1 -0
  78. package/dist/frontend/assets/index-BcHA28Dx.js +87 -0
  79. package/dist/frontend/assets/index-DGmpr33w.js +3 -0
  80. package/dist/frontend/assets/index-DPgZw9ew.js +349 -0
  81. package/dist/frontend/assets/infoDiagram-4a4f5b27-OIxyK2_N.js +7 -0
  82. package/dist/frontend/assets/infoDiagram-f8f76790-BTkoanKB.js +7 -0
  83. package/dist/frontend/assets/init-Gi6I4Gst.js +1 -0
  84. package/dist/frontend/assets/it-IT-JPQ66NNP-Cu6RM7DP.js +11 -0
  85. package/dist/frontend/assets/ja-JP-DBVTYXUO-lD7U4Zkf.js +8 -0
  86. package/dist/frontend/assets/journeyDiagram-29694f62-BS4Xl0A-.js +139 -0
  87. package/dist/frontend/assets/journeyDiagram-49397b02-BbBAwEfu.js +139 -0
  88. package/dist/frontend/assets/kaa-6HZHGXH3-DM9LwXUP.js +1 -0
  89. package/dist/frontend/assets/kab-KAB-ZGHBKWFO-BAojmp2_.js +8 -0
  90. package/dist/frontend/assets/katex-ChWnQ-fc.js +261 -0
  91. package/dist/frontend/assets/kk-KZ-P5N5QNE5-Dp0K1W81.js +1 -0
  92. package/dist/frontend/assets/km-KH-HSX4SM5Z-BzYGKbAg.js +11 -0
  93. package/dist/frontend/assets/ko-KR-MTYHY66A-DOvEMk4H.js +9 -0
  94. package/dist/frontend/assets/ku-TR-6OUDTVRD-B6l-ghqp.js +9 -0
  95. package/dist/frontend/assets/layout-CGydnLJa.js +1 -0
  96. package/dist/frontend/assets/layout-DbdMIGYe.js +1 -0
  97. package/dist/frontend/assets/line-CbImtxDK.js +1 -0
  98. package/dist/frontend/assets/linear-DvIsU3aM.js +1 -0
  99. package/dist/frontend/assets/lt-LT-XHIRWOB4-BYcRk8Uj.js +3 -0
  100. package/dist/frontend/assets/lv-LV-5QDEKY6T-DS3krNIe.js +7 -0
  101. package/dist/frontend/assets/mindmap-definition-ac74a2e8-C0Sp7ICZ.js +95 -0
  102. package/dist/frontend/assets/mindmap-definition-fc14e90a-BZrjRbkr.js +95 -0
  103. package/dist/frontend/assets/mr-IN-CRQNXWMA-BfxQL7Vh.js +13 -0
  104. package/dist/frontend/assets/my-MM-5M5IBNSE-C3EfnOvD.js +1 -0
  105. package/dist/frontend/assets/nb-NO-T6EIAALU-BIbPZokm.js +10 -0
  106. package/dist/frontend/assets/nl-NL-IS3SIHDZ-BqQloGBT.js +8 -0
  107. package/dist/frontend/assets/nn-NO-6E72VCQL-zGR8NYQf.js +8 -0
  108. package/dist/frontend/assets/oc-FR-POXYY2M6-B8-HsJFE.js +8 -0
  109. package/dist/frontend/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/dist/frontend/assets/pa-IN-N4M65BXN-B2Ta58Tu.js +4 -0
  111. package/dist/frontend/assets/path-CbwjOpE9.js +1 -0
  112. package/dist/frontend/assets/pica-DSD-O3at.js +7 -0
  113. package/dist/frontend/assets/pie-Dk_pQnuO.js +1 -0
  114. package/dist/frontend/assets/pieDiagram-421022e6-9oAq5fk_.js +35 -0
  115. package/dist/frontend/assets/pieDiagram-8a3498a8-B5SMrdDh.js +35 -0
  116. package/dist/frontend/assets/pl-PL-T2D74RX3-rZKvQ0zQ.js +9 -0
  117. package/dist/frontend/assets/pt-BR-5N22H2LF-ij6wtU6I.js +9 -0
  118. package/dist/frontend/assets/pt-PT-UZXXM6DQ-BIgtUnbW.js +9 -0
  119. package/dist/frontend/assets/quadrantDiagram-0957ecba-Cr3mj6c1.js +7 -0
  120. package/dist/frontend/assets/quadrantDiagram-120e2f19-CQnc4s0f.js +7 -0
  121. package/dist/frontend/assets/requirementDiagram-23d650b8-Bs7pP1vJ.js +52 -0
  122. package/dist/frontend/assets/requirementDiagram-deff3bca-G5e-Qxao.js +52 -0
  123. package/dist/frontend/assets/ro-RO-JPDTUUEW-DPj_79nt.js +11 -0
  124. package/dist/frontend/assets/roundRect-0PYZxl1G.js +1 -0
  125. package/dist/frontend/assets/ru-RU-B4JR7IUQ-fdYiaqbX.js +9 -0
  126. package/dist/frontend/assets/sankeyDiagram-04a897e0-CJogadkF.js +8 -0
  127. package/dist/frontend/assets/sankeyDiagram-23345273-DKUWMCrX.js +8 -0
  128. package/dist/frontend/assets/sankeyLinkHorizontal-DgqkLiUE.js +1 -0
  129. package/dist/frontend/assets/selectAll-tNeSnQY6.js +1 -0
  130. package/dist/frontend/assets/sequenceDiagram-17ac3bff-DCw9xUbw.js +122 -0
  131. package/dist/frontend/assets/sequenceDiagram-704730f1-BgClSrOI.js +122 -0
  132. package/dist/frontend/assets/si-LK-N5RQ5JYF-DfPBk-rU.js +1 -0
  133. package/dist/frontend/assets/sk-SK-C5VTKIMK-Cbj4yoD_.js +6 -0
  134. package/dist/frontend/assets/sl-SI-NN7IZMDC-C_rL7eDE.js +6 -0
  135. package/dist/frontend/assets/stateDiagram-587899a1-DuFGG-SI.js +1 -0
  136. package/dist/frontend/assets/stateDiagram-9c5f0230-Bwj38hfH.js +1 -0
  137. package/dist/frontend/assets/stateDiagram-v2-51a3dcff-3c0yKNdL.js +1 -0
  138. package/dist/frontend/assets/stateDiagram-v2-d93cdb3a-CAaqB4wm.js +1 -0
  139. package/dist/frontend/assets/styles-2ab5d517-Dxg7wKah.js +116 -0
  140. package/dist/frontend/assets/styles-5f03d8d2-DD32XMGL.js +160 -0
  141. package/dist/frontend/assets/styles-6aaf32cf-B5DxK_RW.js +207 -0
  142. package/dist/frontend/assets/styles-9a916d00-C6L6Mj2P.js +160 -0
  143. package/dist/frontend/assets/styles-c10674c1-BPM_bB3H.js +116 -0
  144. package/dist/frontend/assets/styles-edf9a4b0-CbQDxrwP.js +207 -0
  145. package/dist/frontend/assets/subset-shared.chunk-B_DQsaBC.js +84 -0
  146. package/dist/frontend/assets/subset-worker.chunk-DL6tLP7M.js +1 -0
  147. package/dist/frontend/assets/sv-SE-XGPEYMSR-BmmcOaVK.js +10 -0
  148. package/dist/frontend/assets/svgDrawCommon-08f97a94-aUx8qfJx.js +1 -0
  149. package/dist/frontend/assets/svgDrawCommon-3ba9043b-1JM8RiLc.js +1 -0
  150. package/dist/frontend/assets/ta-IN-2NMHFXQM-Kxnb_Mwk.js +9 -0
  151. package/dist/frontend/assets/th-TH-HPSO5L25-BqTLgxJz.js +2 -0
  152. package/dist/frontend/assets/timeline-definition-7e6b55e7-BbFhIPTl.js +61 -0
  153. package/dist/frontend/assets/timeline-definition-85554ec2-C1G9H6m5.js +61 -0
  154. package/dist/frontend/assets/tr-TR-DEFEU3FU-DhlYP6tL.js +7 -0
  155. package/dist/frontend/assets/uk-UA-QMV73CPH-pMrN1qBS.js +6 -0
  156. package/dist/frontend/assets/union-Cu1rbD_D.js +1 -0
  157. package/dist/frontend/assets/vi-VN-M7AON7JQ-BPMcH84R.js +5 -0
  158. package/dist/frontend/assets/xml-BOsq7VnW.js +1 -0
  159. package/dist/frontend/assets/xychartDiagram-b6496bcd-BDm9pYtk.js +7 -0
  160. package/dist/frontend/assets/xychartDiagram-e933f94c-BlrTBDHC.js +7 -0
  161. package/dist/frontend/assets/zh-CN-LNUGB5OW-B8kYYibM.js +10 -0
  162. package/dist/frontend/assets/zh-HK-E62DVLB3-CaI0gehP.js +1 -0
  163. package/dist/frontend/assets/zh-TW-RAJ6MFWO-DKCVg17j.js +9 -0
  164. package/dist/frontend/assets/zipObject-iRVIFf6r.js +1 -0
  165. package/dist/frontend/index.html +420 -0
  166. package/dist/index.d.ts +4 -0
  167. package/dist/index.d.ts.map +1 -0
  168. package/dist/index.js +2241 -0
  169. package/dist/index.js.map +1 -0
  170. package/dist/server.d.ts +6 -0
  171. package/dist/server.d.ts.map +1 -0
  172. package/dist/server.js +980 -0
  173. package/dist/server.js.map +1 -0
  174. package/dist/types.d.ts +225 -0
  175. package/dist/types.d.ts.map +1 -0
  176. package/dist/types.js +30 -0
  177. package/dist/types.js.map +1 -0
  178. package/dist/utils/logger.d.ts +4 -0
  179. package/dist/utils/logger.d.ts.map +1 -0
  180. package/dist/utils/logger.js +23 -0
  181. package/dist/utils/logger.js.map +1 -0
  182. package/package.json +108 -0
  183. package/skills/excalidraw-skill/SKILL.md +370 -0
  184. package/skills/excalidraw-skill/references/cheatsheet.md +195 -0
  185. package/skills/excalidraw-skill/scripts/clear-canvas.cjs +38 -0
  186. package/skills/excalidraw-skill/scripts/create-element.cjs +68 -0
  187. package/skills/excalidraw-skill/scripts/delete-element.cjs +48 -0
  188. package/skills/excalidraw-skill/scripts/export-elements.cjs +53 -0
  189. package/skills/excalidraw-skill/scripts/healthcheck.cjs +35 -0
  190. package/skills/excalidraw-skill/scripts/import-elements.cjs +81 -0
  191. package/skills/excalidraw-skill/scripts/update-element.cjs +70 -0
package/dist/index.js ADDED
@@ -0,0 +1,2241 @@
1
+ #!/usr/bin/env node
2
+ // Disable colors to prevent ANSI color codes from breaking JSON parsing
3
+ process.env.NODE_DISABLE_COLORS = '1';
4
+ process.env.NO_COLOR = '1';
5
+ import { fileURLToPath } from "url";
6
+ import { deflateSync } from 'zlib';
7
+ import { webcrypto, createHash } from 'crypto';
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
+ import { z } from 'zod';
12
+ import dotenv from 'dotenv';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import logger from './utils/logger.js';
16
+ import { generateId, EXCALIDRAW_ELEMENT_TYPES } from './types.js';
17
+ import fetch from 'node-fetch';
18
+ import { startCanvasServer, stopCanvasServer } from './server.js';
19
+ import { initDb, closeDb, searchElements as dbSearchElements, listProjects as dbListProjects, createProject as dbCreateProject, setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject, getElementHistory as dbGetElementHistory, getProjectHistory as dbGetProjectHistory, ensureTenant as dbEnsureTenant, setActiveTenant as dbSetActiveTenant, getActiveTenant as dbGetActiveTenant, getActiveTenantId as dbGetActiveTenantId, listTenants as dbListTenants } from './db.js';
20
+ // Load environment variables
21
+ dotenv.config();
22
+ // Safe file path validation to prevent path traversal attacks
23
+ const ALLOWED_EXPORT_DIR = process.env.EXCALIDRAW_EXPORT_DIR || process.cwd();
24
+ function sanitizeFilePath(filePath) {
25
+ const resolved = path.resolve(filePath);
26
+ const allowedDir = path.resolve(ALLOWED_EXPORT_DIR);
27
+ if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) {
28
+ throw new Error(`Path traversal blocked: "${filePath}" resolves outside the allowed directory "${allowedDir}". ` +
29
+ `Set EXCALIDRAW_EXPORT_DIR to change the allowed base directory.`);
30
+ }
31
+ return resolved;
32
+ }
33
+ // Express server configuration — derive URL from CANVAS_PORT
34
+ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
35
+ const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
36
+ const ENABLE_CANVAS_SYNC = true;
37
+ function canvasHeaders(extra) {
38
+ return {
39
+ 'Content-Type': 'application/json',
40
+ 'X-Tenant-Id': dbGetActiveTenantId(),
41
+ ...extra
42
+ };
43
+ }
44
+ // Helper functions to sync with Express server (canvas)
45
+ async function syncToCanvas(operation, data) {
46
+ if (!ENABLE_CANVAS_SYNC) {
47
+ logger.debug('Canvas sync disabled, skipping');
48
+ return null;
49
+ }
50
+ try {
51
+ let url;
52
+ let options;
53
+ switch (operation) {
54
+ case 'create':
55
+ url = `${EXPRESS_SERVER_URL}/api/elements`;
56
+ options = {
57
+ method: 'POST',
58
+ headers: canvasHeaders(),
59
+ body: JSON.stringify(data)
60
+ };
61
+ break;
62
+ case 'update':
63
+ url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
64
+ options = {
65
+ method: 'PUT',
66
+ headers: canvasHeaders(),
67
+ body: JSON.stringify(data)
68
+ };
69
+ break;
70
+ case 'delete':
71
+ url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
72
+ options = { method: 'DELETE', headers: canvasHeaders() };
73
+ break;
74
+ case 'batch_create':
75
+ url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
76
+ options = {
77
+ method: 'POST',
78
+ headers: canvasHeaders(),
79
+ body: JSON.stringify({ elements: data })
80
+ };
81
+ break;
82
+ default:
83
+ logger.warn(`Unknown sync operation: ${operation}`);
84
+ return null;
85
+ }
86
+ logger.debug(`Syncing to canvas: ${operation}`, { url, data });
87
+ const response = await fetch(url, options);
88
+ // Parse JSON response regardless of HTTP status
89
+ const result = await response.json();
90
+ if (!response.ok) {
91
+ logger.warn(`Canvas sync returned error status: ${response.status}`, result);
92
+ throw new Error(result.error || `Canvas sync failed: ${response.status} ${response.statusText}`);
93
+ }
94
+ logger.debug(`Canvas sync successful: ${operation}`, result);
95
+ return result;
96
+ }
97
+ catch (error) {
98
+ logger.warn(`Canvas sync failed for ${operation}:`, error.message);
99
+ // Don't throw - we want MCP operations to work even if canvas is unavailable
100
+ return null;
101
+ }
102
+ }
103
+ // Helper to sync element creation to canvas
104
+ async function createElementOnCanvas(elementData) {
105
+ const result = await syncToCanvas('create', elementData);
106
+ return result?.element || elementData;
107
+ }
108
+ // Helper to sync element update to canvas
109
+ async function updateElementOnCanvas(elementData) {
110
+ const result = await syncToCanvas('update', elementData);
111
+ return result?.element || null;
112
+ }
113
+ // Helper to sync element deletion to canvas
114
+ async function deleteElementOnCanvas(elementId) {
115
+ const result = await syncToCanvas('delete', { id: elementId });
116
+ return result;
117
+ }
118
+ // Helper to sync batch creation to canvas
119
+ async function batchCreateElementsOnCanvas(elementsData) {
120
+ const result = await syncToCanvas('batch_create', elementsData);
121
+ return result?.elements || elementsData;
122
+ }
123
+ // Helper to fetch element from canvas
124
+ async function getElementFromCanvas(elementId) {
125
+ if (!ENABLE_CANVAS_SYNC) {
126
+ logger.debug('Canvas sync disabled, skipping fetch');
127
+ return null;
128
+ }
129
+ try {
130
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`, {
131
+ headers: canvasHeaders()
132
+ });
133
+ if (!response.ok) {
134
+ logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
135
+ return null;
136
+ }
137
+ const data = await response.json();
138
+ return data.element || null;
139
+ }
140
+ catch (error) {
141
+ logger.error('Error fetching element from canvas:', error);
142
+ return null;
143
+ }
144
+ }
145
+ const sceneState = {
146
+ theme: 'light',
147
+ viewport: { x: 0, y: 0, zoom: 1 },
148
+ selectedElements: new Set(),
149
+ groups: new Map()
150
+ };
151
+ // Points schema: accept both {x, y} objects and [x, y] tuples
152
+ const PointObjectSchema = z.object({ x: z.number(), y: z.number() });
153
+ const PointTupleSchema = z.tuple([z.number(), z.number()]);
154
+ const PointSchema = z.union([PointObjectSchema, PointTupleSchema]);
155
+ // Normalize points to [x, y] tuple format that Excalidraw expects
156
+ function normalizePoints(points) {
157
+ return points.map(p => {
158
+ if (Array.isArray(p))
159
+ return p;
160
+ return [p.x, p.y];
161
+ });
162
+ }
163
+ // Schema definitions using zod
164
+ const ElementSchema = z.object({
165
+ id: z.string().optional(),
166
+ type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)),
167
+ x: z.number(),
168
+ y: z.number(),
169
+ width: z.number().optional(),
170
+ height: z.number().optional(),
171
+ points: z.array(PointSchema).optional(),
172
+ backgroundColor: z.string().optional(),
173
+ strokeColor: z.string().optional(),
174
+ strokeWidth: z.number().optional(),
175
+ roughness: z.number().optional(),
176
+ opacity: z.number().optional(),
177
+ text: z.string().optional(),
178
+ fontSize: z.number().optional(),
179
+ fontFamily: z.string().optional(),
180
+ groupIds: z.array(z.string()).optional(),
181
+ locked: z.boolean().optional(),
182
+ strokeStyle: z.string().optional(),
183
+ roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
184
+ fillStyle: z.string().optional(),
185
+ elbowed: z.boolean().optional(),
186
+ startElementId: z.string().optional(),
187
+ endElementId: z.string().optional(),
188
+ endArrowhead: z.string().optional(),
189
+ startArrowhead: z.string().optional(),
190
+ });
191
+ const ElementIdSchema = z.object({
192
+ id: z.string()
193
+ });
194
+ const ElementIdsSchema = z.object({
195
+ elementIds: z.array(z.string())
196
+ });
197
+ const GroupIdSchema = z.object({
198
+ groupId: z.string()
199
+ });
200
+ const AlignElementsSchema = z.object({
201
+ elementIds: z.array(z.string()),
202
+ alignment: z.enum(['left', 'center', 'right', 'top', 'middle', 'bottom'])
203
+ });
204
+ const DistributeElementsSchema = z.object({
205
+ elementIds: z.array(z.string()),
206
+ direction: z.enum(['horizontal', 'vertical'])
207
+ });
208
+ const QuerySchema = z.object({
209
+ type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(),
210
+ filter: z.record(z.any()).optional()
211
+ });
212
+ const ResourceSchema = z.object({
213
+ resource: z.enum(['scene', 'library', 'theme', 'elements'])
214
+ });
215
+ // Diagram design guide — injected into LLM context via read_diagram_guide tool
216
+ const DIAGRAM_DESIGN_GUIDE = `# Excalidraw Diagram Design Guide
217
+
218
+ ## Color Palette
219
+
220
+ ### Stroke Colors (use for borders & text)
221
+ | Name | Hex | Use for |
222
+ |---------|-----------|-----------------------------|
223
+ | Black | #1e1e1e | Default text & borders |
224
+ | Red | #e03131 | Errors, warnings, critical |
225
+ | Green | #2f9e44 | Success, approved, healthy |
226
+ | Blue | #1971c2 | Primary actions, links |
227
+ | Purple | #9c36b5 | Services, middleware |
228
+ | Orange | #e8590c | Async, queues, events |
229
+ | Cyan | #0c8599 | Data stores, databases |
230
+ | Gray | #868e96 | Annotations, secondary |
231
+
232
+ ### Fill Colors (use for backgroundColor — pastel fills)
233
+ | Name | Hex | Pairs with stroke |
234
+ |--------------|-----------|-------------------|
235
+ | Light Red | #ffc9c9 | #e03131 |
236
+ | Light Green | #b2f2bb | #2f9e44 |
237
+ | Light Blue | #a5d8ff | #1971c2 |
238
+ | Light Purple | #eebefa | #9c36b5 |
239
+ | Light Orange | #ffd8a8 | #e8590c |
240
+ | Light Cyan | #99e9f2 | #0c8599 |
241
+ | Light Gray | #e9ecef | #868e96 |
242
+ | White | #ffffff | #1e1e1e |
243
+
244
+ ## Sizing Rules
245
+
246
+ - **Minimum shape size**: width >= 120px, height >= 60px
247
+ - **Font sizes**: body text >= 16, titles/headers >= 20, small labels >= 14
248
+ - **Padding**: leave at least 20px inside shapes for text breathing room
249
+ - **Arrow length**: minimum 80px between connected shapes
250
+ - **Consistent sizing**: keep same-role shapes identical dimensions
251
+
252
+ ## Layout Patterns
253
+
254
+ - **Grid snap**: align to 20px grid for clean layouts
255
+ - **Spacing**: 40–80px gap between adjacent shapes
256
+ - **Flow direction**: top-to-bottom (vertical) or left-to-right (horizontal)
257
+ - **Hierarchy**: important nodes larger or higher; left-to-right = temporal order
258
+ - **Grouping**: cluster related elements visually; use background rectangles as zones
259
+
260
+ ## Arrow Binding Best Practices
261
+
262
+ - **Always bind**: use \`startElementId\` / \`endElementId\` to connect arrows to shapes
263
+ - **Dashed arrows**: use \`strokeStyle: "dashed"\` for async, optional, or event flows
264
+ - **Dotted arrows**: use \`strokeStyle: "dotted"\` for weak dependencies or annotations
265
+ - **Arrowheads**: default "arrow" for directed flow; "dot" for data stores; null for lines
266
+ - **Label arrows**: set \`text\` on arrows to describe the relationship (e.g., "HTTP", "publishes")
267
+
268
+ ## Diagram Type Templates
269
+
270
+ ### Architecture Diagram
271
+ - Shapes: 160×80 rectangles for services, 120×60 for small components
272
+ - Colors: different fill per layer (frontend=blue, backend=purple, data=cyan)
273
+ - Arrows: solid for sync calls, dashed for async/events
274
+ - Zones: large light-gray background rectangles with 20px fontSize labels
275
+
276
+ ### Flowchart
277
+ - Shapes: 140×70 rectangles for steps, 100×100 diamonds for decisions
278
+ - Flow: top-to-bottom, 60px vertical spacing
279
+ - Colors: green start, red end, blue for process steps
280
+ - Arrows: solid, with "Yes"/"No" labels from diamonds
281
+
282
+ ### ER Diagram
283
+ - Shapes: 180×40 per entity (wider for attribute lists)
284
+ - Layout: 80px between entities
285
+ - Arrows: use start/end arrowheads to show cardinality
286
+ - Colors: light-blue fill for entities, no fill for junction tables
287
+
288
+ ## Anti-Patterns to Avoid
289
+
290
+ 1. **Overlapping elements** — always leave gaps; use distribute_elements
291
+ 2. **Cramped spacing** — minimum 40px between shapes
292
+ 3. **Tiny fonts** — never below 14px; prefer 16+
293
+ 4. **Manual arrow coordinates** — always use startElementId/endElementId binding
294
+ 5. **Too many colors** — limit to 3–4 fill colors per diagram
295
+ 6. **Inconsistent sizes** — same-role shapes should be same width/height
296
+ 7. **No labels** — every shape and meaningful arrow should have text
297
+ 8. **Flat layouts** — use zones/groups to create visual hierarchy
298
+
299
+ ## Drawing Order (Recommended)
300
+
301
+ 1. **Background zones** — large rectangles with light fill, low opacity
302
+ 2. **Primary shapes** — services, entities, steps (with labels via \`text\`)
303
+ 3. **Arrows** — connect shapes using binding IDs
304
+ 4. **Annotations** — standalone text elements for notes, titles
305
+ 5. **Refinement** — align, distribute, adjust spacing, screenshot to verify
306
+ `;
307
+ // Tool definitions
308
+ const tools = [
309
+ {
310
+ name: 'create_element',
311
+ description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges).',
312
+ inputSchema: {
313
+ type: 'object',
314
+ properties: {
315
+ id: { type: 'string', description: 'Custom element ID (optional, auto-generated if omitted). Use with startElementId/endElementId in batch_create_elements.' },
316
+ type: {
317
+ type: 'string',
318
+ enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
319
+ },
320
+ x: { type: 'number' },
321
+ y: { type: 'number' },
322
+ width: { type: 'number' },
323
+ height: { type: 'number' },
324
+ backgroundColor: { type: 'string' },
325
+ strokeColor: { type: 'string' },
326
+ strokeWidth: { type: 'number' },
327
+ strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' },
328
+ roughness: { type: 'number' },
329
+ opacity: { type: 'number' },
330
+ text: { type: 'string' },
331
+ fontSize: { type: 'number' },
332
+ fontFamily: { type: 'string' },
333
+ startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
334
+ endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
335
+ endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
336
+ startArrowhead: { type: 'string', description: 'Arrowhead style at start: arrow, bar, dot, triangle, or null' }
337
+ },
338
+ required: ['type', 'x', 'y']
339
+ }
340
+ },
341
+ {
342
+ name: 'update_element',
343
+ description: 'Update an existing Excalidraw element',
344
+ inputSchema: {
345
+ type: 'object',
346
+ properties: {
347
+ id: { type: 'string' },
348
+ type: {
349
+ type: 'string',
350
+ enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
351
+ },
352
+ x: { type: 'number' },
353
+ y: { type: 'number' },
354
+ width: { type: 'number' },
355
+ height: { type: 'number' },
356
+ backgroundColor: { type: 'string' },
357
+ strokeColor: { type: 'string' },
358
+ strokeWidth: { type: 'number' },
359
+ strokeStyle: { type: 'string' },
360
+ roughness: { type: 'number' },
361
+ opacity: { type: 'number' },
362
+ text: { type: 'string' },
363
+ fontSize: { type: 'number' },
364
+ fontFamily: { type: 'string' }
365
+ },
366
+ required: ['id']
367
+ }
368
+ },
369
+ {
370
+ name: 'delete_element',
371
+ description: 'Delete an Excalidraw element',
372
+ inputSchema: {
373
+ type: 'object',
374
+ properties: {
375
+ id: { type: 'string' }
376
+ },
377
+ required: ['id']
378
+ }
379
+ },
380
+ {
381
+ name: 'query_elements',
382
+ description: 'Query Excalidraw elements with optional filters',
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ type: {
387
+ type: 'string',
388
+ enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
389
+ },
390
+ filter: {
391
+ type: 'object',
392
+ additionalProperties: true
393
+ }
394
+ }
395
+ }
396
+ },
397
+ {
398
+ name: 'get_resource',
399
+ description: 'Get an Excalidraw resource',
400
+ inputSchema: {
401
+ type: 'object',
402
+ properties: {
403
+ resource: {
404
+ type: 'string',
405
+ enum: ['scene', 'library', 'theme', 'elements']
406
+ }
407
+ },
408
+ required: ['resource']
409
+ }
410
+ },
411
+ {
412
+ name: 'group_elements',
413
+ description: 'Group multiple elements together',
414
+ inputSchema: {
415
+ type: 'object',
416
+ properties: {
417
+ elementIds: {
418
+ type: 'array',
419
+ items: { type: 'string' }
420
+ }
421
+ },
422
+ required: ['elementIds']
423
+ }
424
+ },
425
+ {
426
+ name: 'ungroup_elements',
427
+ description: 'Ungroup a group of elements',
428
+ inputSchema: {
429
+ type: 'object',
430
+ properties: {
431
+ groupId: { type: 'string' }
432
+ },
433
+ required: ['groupId']
434
+ }
435
+ },
436
+ {
437
+ name: 'align_elements',
438
+ description: 'Align elements to a specific position',
439
+ inputSchema: {
440
+ type: 'object',
441
+ properties: {
442
+ elementIds: {
443
+ type: 'array',
444
+ items: { type: 'string' }
445
+ },
446
+ alignment: {
447
+ type: 'string',
448
+ enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
449
+ }
450
+ },
451
+ required: ['elementIds', 'alignment']
452
+ }
453
+ },
454
+ {
455
+ name: 'distribute_elements',
456
+ description: 'Distribute elements evenly',
457
+ inputSchema: {
458
+ type: 'object',
459
+ properties: {
460
+ elementIds: {
461
+ type: 'array',
462
+ items: { type: 'string' }
463
+ },
464
+ direction: {
465
+ type: 'string',
466
+ enum: ['horizontal', 'vertical']
467
+ }
468
+ },
469
+ required: ['elementIds', 'direction']
470
+ }
471
+ },
472
+ {
473
+ name: 'lock_elements',
474
+ description: 'Lock elements to prevent modification',
475
+ inputSchema: {
476
+ type: 'object',
477
+ properties: {
478
+ elementIds: {
479
+ type: 'array',
480
+ items: { type: 'string' }
481
+ }
482
+ },
483
+ required: ['elementIds']
484
+ }
485
+ },
486
+ {
487
+ name: 'unlock_elements',
488
+ description: 'Unlock elements to allow modification',
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ elementIds: {
493
+ type: 'array',
494
+ items: { type: 'string' }
495
+ }
496
+ },
497
+ required: ['elementIds']
498
+ }
499
+ },
500
+ {
501
+ name: 'create_from_mermaid',
502
+ description: 'Convert a Mermaid diagram to Excalidraw elements and render them on the canvas',
503
+ inputSchema: {
504
+ type: 'object',
505
+ properties: {
506
+ mermaidDiagram: {
507
+ type: 'string',
508
+ description: 'The Mermaid diagram definition (e.g., "graph TD; A-->B; B-->C;")'
509
+ },
510
+ config: {
511
+ type: 'object',
512
+ description: 'Optional Mermaid configuration',
513
+ properties: {
514
+ startOnLoad: { type: 'boolean' },
515
+ flowchart: {
516
+ type: 'object',
517
+ properties: {
518
+ curve: { type: 'string', enum: ['linear', 'basis'] }
519
+ }
520
+ },
521
+ themeVariables: {
522
+ type: 'object',
523
+ properties: {
524
+ fontSize: { type: 'string' }
525
+ }
526
+ },
527
+ maxEdges: { type: 'number' },
528
+ maxTextSize: { type: 'number' }
529
+ }
530
+ }
531
+ },
532
+ required: ['mermaidDiagram']
533
+ }
534
+ },
535
+ {
536
+ name: 'batch_create_elements',
537
+ description: 'Create multiple Excalidraw elements at once. For arrows, use startElementId/endElementId to bind arrows to shapes — Excalidraw auto-routes to element edges. Assign custom id to shapes so arrows can reference them.',
538
+ inputSchema: {
539
+ type: 'object',
540
+ properties: {
541
+ elements: {
542
+ type: 'array',
543
+ items: {
544
+ type: 'object',
545
+ properties: {
546
+ id: { type: 'string', description: 'Custom element ID. Arrows can reference this via startElementId/endElementId.' },
547
+ type: {
548
+ type: 'string',
549
+ enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
550
+ },
551
+ x: { type: 'number' },
552
+ y: { type: 'number' },
553
+ width: { type: 'number' },
554
+ height: { type: 'number' },
555
+ backgroundColor: { type: 'string' },
556
+ strokeColor: { type: 'string' },
557
+ strokeWidth: { type: 'number' },
558
+ strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' },
559
+ roughness: { type: 'number' },
560
+ opacity: { type: 'number' },
561
+ text: { type: 'string' },
562
+ fontSize: { type: 'number' },
563
+ fontFamily: { type: 'string' },
564
+ startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
565
+ endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
566
+ endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
567
+ startArrowhead: { type: 'string', description: 'Arrowhead style at start: arrow, bar, dot, triangle, or null' }
568
+ },
569
+ required: ['type', 'x', 'y']
570
+ }
571
+ }
572
+ },
573
+ required: ['elements']
574
+ }
575
+ },
576
+ {
577
+ name: 'get_element',
578
+ description: 'Get a single Excalidraw element by ID',
579
+ inputSchema: {
580
+ type: 'object',
581
+ properties: {
582
+ id: { type: 'string', description: 'The element ID' }
583
+ },
584
+ required: ['id']
585
+ }
586
+ },
587
+ {
588
+ name: 'clear_canvas',
589
+ description: 'Clear all elements from the canvas',
590
+ inputSchema: {
591
+ type: 'object',
592
+ properties: {}
593
+ }
594
+ },
595
+ {
596
+ name: 'export_scene',
597
+ description: 'Export the current canvas to .excalidraw JSON format. Optionally write to a file.',
598
+ inputSchema: {
599
+ type: 'object',
600
+ properties: {
601
+ filePath: {
602
+ type: 'string',
603
+ description: 'Optional file path to write the .excalidraw JSON file'
604
+ }
605
+ }
606
+ }
607
+ },
608
+ {
609
+ name: 'import_scene',
610
+ description: 'Import elements from a .excalidraw JSON file or raw JSON data',
611
+ inputSchema: {
612
+ type: 'object',
613
+ properties: {
614
+ filePath: {
615
+ type: 'string',
616
+ description: 'Path to a .excalidraw JSON file'
617
+ },
618
+ data: {
619
+ type: 'string',
620
+ description: 'Raw .excalidraw JSON string (alternative to filePath)'
621
+ },
622
+ mode: {
623
+ type: 'string',
624
+ enum: ['replace', 'merge'],
625
+ description: '"replace" clears canvas first, "merge" appends to existing elements'
626
+ }
627
+ },
628
+ required: ['mode']
629
+ }
630
+ },
631
+ {
632
+ name: 'export_to_image',
633
+ description: 'Export the current canvas to PNG or SVG image. Requires the canvas frontend to be open in a browser.',
634
+ inputSchema: {
635
+ type: 'object',
636
+ properties: {
637
+ format: {
638
+ type: 'string',
639
+ enum: ['png', 'svg'],
640
+ description: 'Image format'
641
+ },
642
+ filePath: {
643
+ type: 'string',
644
+ description: 'Optional file path to save the image'
645
+ },
646
+ background: {
647
+ type: 'boolean',
648
+ description: 'Include background in export (default: true)'
649
+ }
650
+ },
651
+ required: ['format']
652
+ }
653
+ },
654
+ {
655
+ name: 'duplicate_elements',
656
+ description: 'Duplicate elements with a configurable offset',
657
+ inputSchema: {
658
+ type: 'object',
659
+ properties: {
660
+ elementIds: {
661
+ type: 'array',
662
+ items: { type: 'string' },
663
+ description: 'IDs of elements to duplicate'
664
+ },
665
+ offsetX: { type: 'number', description: 'Horizontal offset (default: 20)' },
666
+ offsetY: { type: 'number', description: 'Vertical offset (default: 20)' }
667
+ },
668
+ required: ['elementIds']
669
+ }
670
+ },
671
+ {
672
+ name: 'snapshot_scene',
673
+ description: 'Save a named snapshot of the current canvas state for later restoration',
674
+ inputSchema: {
675
+ type: 'object',
676
+ properties: {
677
+ name: {
678
+ type: 'string',
679
+ description: 'Name for this snapshot'
680
+ }
681
+ },
682
+ required: ['name']
683
+ }
684
+ },
685
+ {
686
+ name: 'restore_snapshot',
687
+ description: 'Restore the canvas from a previously saved named snapshot',
688
+ inputSchema: {
689
+ type: 'object',
690
+ properties: {
691
+ name: {
692
+ type: 'string',
693
+ description: 'Name of the snapshot to restore'
694
+ }
695
+ },
696
+ required: ['name']
697
+ }
698
+ },
699
+ {
700
+ name: 'describe_scene',
701
+ description: 'Get an AI-readable description of the current canvas: element types, positions, connections, labels, spatial layout, and bounding box. Use this to understand what is on the canvas before making changes.',
702
+ inputSchema: {
703
+ type: 'object',
704
+ properties: {}
705
+ }
706
+ },
707
+ {
708
+ name: 'get_canvas_screenshot',
709
+ description: 'Take a screenshot of the current canvas and return it as an image. Requires the canvas frontend to be open in a browser. Use this to visually verify what the diagram looks like.',
710
+ inputSchema: {
711
+ type: 'object',
712
+ properties: {
713
+ background: {
714
+ type: 'boolean',
715
+ description: 'Include background in screenshot (default: true)'
716
+ }
717
+ }
718
+ }
719
+ },
720
+ {
721
+ name: 'read_diagram_guide',
722
+ description: 'Returns a comprehensive design guide for creating beautiful Excalidraw diagrams: color palette, sizing rules, layout patterns, arrow binding best practices, diagram templates, and anti-patterns. Call this before creating diagrams to produce professional results.',
723
+ inputSchema: {
724
+ type: 'object',
725
+ properties: {}
726
+ }
727
+ },
728
+ {
729
+ name: 'export_to_excalidraw_url',
730
+ description: 'Export the current canvas to a shareable excalidraw.com URL. The diagram is encrypted and uploaded; anyone with the URL can view it. Returns the shareable link.',
731
+ inputSchema: {
732
+ type: 'object',
733
+ properties: {}
734
+ }
735
+ },
736
+ {
737
+ name: 'set_viewport',
738
+ description: 'Control the canvas viewport (camera). Auto-fit all elements, center on a specific element, or set zoom/scroll directly. Requires the canvas frontend open in a browser.',
739
+ inputSchema: {
740
+ type: 'object',
741
+ properties: {
742
+ scrollToContent: {
743
+ type: 'boolean',
744
+ description: 'Auto-fit all elements in view (zoom-to-fit)'
745
+ },
746
+ scrollToElementId: {
747
+ type: 'string',
748
+ description: 'Center the view on a specific element by ID'
749
+ },
750
+ zoom: {
751
+ type: 'number',
752
+ description: 'Zoom level (0.1–10, where 1 = 100%)'
753
+ },
754
+ offsetX: {
755
+ type: 'number',
756
+ description: 'Horizontal scroll offset'
757
+ },
758
+ offsetY: {
759
+ type: 'number',
760
+ description: 'Vertical scroll offset'
761
+ }
762
+ }
763
+ }
764
+ },
765
+ {
766
+ name: 'search_elements',
767
+ description: 'Full-text search across element labels and text content. Returns elements matching the query.',
768
+ inputSchema: {
769
+ type: 'object',
770
+ properties: {
771
+ query: {
772
+ type: 'string',
773
+ description: 'Search query for FTS (matches against element labels and text)'
774
+ }
775
+ },
776
+ required: ['query']
777
+ }
778
+ },
779
+ {
780
+ name: 'list_projects',
781
+ description: 'List all diagram projects. Projects organize diagrams into separate workspaces.',
782
+ inputSchema: {
783
+ type: 'object',
784
+ properties: {}
785
+ }
786
+ },
787
+ {
788
+ name: 'switch_project',
789
+ description: 'Switch the active project or create a new one. All element operations apply to the active project.',
790
+ inputSchema: {
791
+ type: 'object',
792
+ properties: {
793
+ projectId: {
794
+ type: 'string',
795
+ description: 'ID of existing project to switch to'
796
+ },
797
+ createName: {
798
+ type: 'string',
799
+ description: 'Name for a new project (creates and switches to it)'
800
+ },
801
+ createDescription: {
802
+ type: 'string',
803
+ description: 'Optional description for the new project'
804
+ }
805
+ }
806
+ }
807
+ },
808
+ {
809
+ name: 'element_history',
810
+ description: 'View the version history of a specific element or the entire active project. Shows create, update, and delete operations.',
811
+ inputSchema: {
812
+ type: 'object',
813
+ properties: {
814
+ elementId: {
815
+ type: 'string',
816
+ description: 'Element ID to view history for (omit for project-wide history)'
817
+ },
818
+ limit: {
819
+ type: 'number',
820
+ description: 'Maximum number of history entries to return (default: 50)'
821
+ }
822
+ }
823
+ }
824
+ },
825
+ {
826
+ name: 'list_tenants',
827
+ description: 'List all tenants (workspaces). Each tenant corresponds to a Cursor workspace and has isolated diagrams.',
828
+ inputSchema: {
829
+ type: 'object',
830
+ properties: {}
831
+ }
832
+ },
833
+ {
834
+ name: 'switch_tenant',
835
+ description: 'Switch the active tenant (workspace). All subsequent operations will use the selected tenant\'s projects and elements.',
836
+ inputSchema: {
837
+ type: 'object',
838
+ properties: {
839
+ tenantId: {
840
+ type: 'string',
841
+ description: 'ID of the tenant to switch to'
842
+ }
843
+ },
844
+ required: ['tenantId']
845
+ }
846
+ }
847
+ ];
848
+ // Initialize MCP server
849
+ const server = new Server({
850
+ name: "mcp-excalidraw-server",
851
+ version: "2.0.0",
852
+ description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
853
+ }, {
854
+ capabilities: {
855
+ tools: Object.fromEntries(tools.map(tool => [tool.name, {
856
+ description: tool.description,
857
+ inputSchema: tool.inputSchema
858
+ }]))
859
+ }
860
+ });
861
+ // Helper function to convert text property to label format for Excalidraw
862
+ function convertTextToLabel(element) {
863
+ const { text, ...rest } = element;
864
+ if (text) {
865
+ // For standalone text elements, keep text as direct property
866
+ if (element.type === 'text') {
867
+ return element; // Keep text as direct property
868
+ }
869
+ // For other elements (rectangle, ellipse, diamond), convert to label format
870
+ return {
871
+ ...rest,
872
+ label: { text }
873
+ };
874
+ }
875
+ return element;
876
+ }
877
+ // Set up request handler for tool calls
878
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
879
+ try {
880
+ const { name, arguments: args } = request.params;
881
+ logger.info(`Handling tool call: ${name}`);
882
+ switch (name) {
883
+ case 'create_element': {
884
+ const params = ElementSchema.parse(args);
885
+ logger.info('Creating element via MCP', { type: params.type });
886
+ const { startElementId, endElementId, id: customId, ...elementProps } = params;
887
+ const id = customId || generateId();
888
+ const element = {
889
+ id,
890
+ ...elementProps,
891
+ points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
892
+ // Convert binding IDs to Excalidraw's start/end format
893
+ ...(startElementId ? { start: { id: startElementId } } : {}),
894
+ ...(endElementId ? { end: { id: endElementId } } : {}),
895
+ createdAt: new Date().toISOString(),
896
+ updatedAt: new Date().toISOString(),
897
+ version: 1
898
+ };
899
+ // For bound arrows without explicit points, set a default
900
+ if ((startElementId || endElementId) && !elementProps.points) {
901
+ element.points = [[0, 0], [100, 0]];
902
+ }
903
+ // Convert text to label format for Excalidraw
904
+ const excalidrawElement = convertTextToLabel(element);
905
+ // Create element directly on HTTP server (no local storage)
906
+ const canvasElement = await createElementOnCanvas(excalidrawElement);
907
+ if (!canvasElement) {
908
+ throw new Error('Failed to create element: HTTP server unavailable');
909
+ }
910
+ logger.info('Element created via MCP and synced to canvas', {
911
+ id: excalidrawElement.id,
912
+ type: excalidrawElement.type,
913
+ synced: !!canvasElement
914
+ });
915
+ return {
916
+ content: [{
917
+ type: 'text',
918
+ text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
919
+ }]
920
+ };
921
+ }
922
+ case 'update_element': {
923
+ const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args);
924
+ const { id, points: rawPoints, ...updates } = params;
925
+ if (!id)
926
+ throw new Error('Element ID is required');
927
+ // Build update payload with timestamp and version increment
928
+ const updatePayload = {
929
+ id,
930
+ ...updates,
931
+ points: rawPoints ? normalizePoints(rawPoints) : undefined,
932
+ updatedAt: new Date().toISOString()
933
+ };
934
+ // Convert text to label format for Excalidraw
935
+ const excalidrawElement = convertTextToLabel(updatePayload);
936
+ // Update element directly on HTTP server (no local storage)
937
+ const canvasElement = await updateElementOnCanvas(excalidrawElement);
938
+ if (!canvasElement) {
939
+ throw new Error('Failed to update element: HTTP server unavailable or element not found');
940
+ }
941
+ logger.info('Element updated via MCP and synced to canvas', {
942
+ id: excalidrawElement.id,
943
+ synced: !!canvasElement
944
+ });
945
+ return {
946
+ content: [{
947
+ type: 'text',
948
+ text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
949
+ }]
950
+ };
951
+ }
952
+ case 'delete_element': {
953
+ const params = ElementIdSchema.parse(args);
954
+ const { id } = params;
955
+ // Delete element directly on HTTP server (no local storage)
956
+ const canvasResult = await deleteElementOnCanvas(id);
957
+ if (!canvasResult || !canvasResult.success) {
958
+ throw new Error('Failed to delete element: HTTP server unavailable or element not found');
959
+ }
960
+ const result = { id, deleted: true, syncedToCanvas: true };
961
+ logger.info('Element deleted via MCP and synced to canvas', result);
962
+ return {
963
+ content: [{
964
+ type: 'text',
965
+ text: `Element deleted successfully!\n\n${JSON.stringify(result, null, 2)}\n\n✅ Synced to canvas`
966
+ }]
967
+ };
968
+ }
969
+ case 'query_elements': {
970
+ const params = QuerySchema.parse(args || {});
971
+ const { type, filter } = params;
972
+ try {
973
+ // Build query parameters
974
+ const queryParams = new URLSearchParams();
975
+ if (type)
976
+ queryParams.set('type', type);
977
+ if (filter) {
978
+ Object.entries(filter).forEach(([key, value]) => {
979
+ queryParams.set(key, String(value));
980
+ });
981
+ }
982
+ const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`;
983
+ const response = await fetch(url, { headers: canvasHeaders() });
984
+ if (!response.ok) {
985
+ throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
986
+ }
987
+ const data = await response.json();
988
+ const results = data.elements || [];
989
+ return {
990
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
991
+ };
992
+ }
993
+ catch (error) {
994
+ throw new Error(`Failed to query elements: ${error.message}`);
995
+ }
996
+ }
997
+ case 'get_resource': {
998
+ const params = ResourceSchema.parse(args);
999
+ const { resource } = params;
1000
+ logger.info('Getting resource', { resource });
1001
+ let result;
1002
+ switch (resource) {
1003
+ case 'scene':
1004
+ result = {
1005
+ theme: sceneState.theme,
1006
+ viewport: sceneState.viewport,
1007
+ selectedElements: Array.from(sceneState.selectedElements)
1008
+ };
1009
+ break;
1010
+ case 'library':
1011
+ case 'elements':
1012
+ try {
1013
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
1014
+ headers: canvasHeaders()
1015
+ });
1016
+ if (!response.ok) {
1017
+ throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
1018
+ }
1019
+ const data = await response.json();
1020
+ result = {
1021
+ elements: data.elements || []
1022
+ };
1023
+ }
1024
+ catch (error) {
1025
+ throw new Error(`Failed to get elements: ${error.message}`);
1026
+ }
1027
+ break;
1028
+ case 'theme':
1029
+ result = {
1030
+ theme: sceneState.theme
1031
+ };
1032
+ break;
1033
+ default:
1034
+ throw new Error(`Unknown resource: ${resource}`);
1035
+ }
1036
+ return {
1037
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1038
+ };
1039
+ }
1040
+ case 'group_elements': {
1041
+ const params = ElementIdsSchema.parse(args);
1042
+ const { elementIds } = params;
1043
+ try {
1044
+ const groupId = generateId();
1045
+ sceneState.groups.set(groupId, elementIds);
1046
+ // Update elements on canvas with proper error handling
1047
+ // Fetch existing groups and append new groupId to preserve multi-group membership
1048
+ const updatePromises = elementIds.map(async (id) => {
1049
+ const element = await getElementFromCanvas(id);
1050
+ const existingGroups = element?.groupIds || [];
1051
+ const updatedGroupIds = [...existingGroups, groupId];
1052
+ return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
1053
+ });
1054
+ const results = await Promise.all(updatePromises);
1055
+ const successCount = results.filter(result => result).length;
1056
+ if (successCount === 0) {
1057
+ sceneState.groups.delete(groupId); // Rollback local state
1058
+ throw new Error('Failed to group any elements: HTTP server unavailable');
1059
+ }
1060
+ logger.info('Grouping elements', { elementIds, groupId, successCount });
1061
+ const result = { groupId, elementIds, successCount };
1062
+ return {
1063
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1064
+ };
1065
+ }
1066
+ catch (error) {
1067
+ throw new Error(`Failed to group elements: ${error.message}`);
1068
+ }
1069
+ }
1070
+ case 'ungroup_elements': {
1071
+ const params = GroupIdSchema.parse(args);
1072
+ const { groupId } = params;
1073
+ if (!sceneState.groups.has(groupId)) {
1074
+ throw new Error(`Group ${groupId} not found`);
1075
+ }
1076
+ try {
1077
+ const elementIds = sceneState.groups.get(groupId);
1078
+ sceneState.groups.delete(groupId);
1079
+ // Update elements on canvas, removing only this specific groupId
1080
+ const updatePromises = (elementIds ?? []).map(async (id) => {
1081
+ // Fetch current element to get existing groupIds
1082
+ const element = await getElementFromCanvas(id);
1083
+ if (!element) {
1084
+ logger.warn(`Element ${id} not found on canvas, skipping ungroup`);
1085
+ return null;
1086
+ }
1087
+ // Remove only the specific groupId, preserve others
1088
+ const updatedGroupIds = (element.groupIds || []).filter(gid => gid !== groupId);
1089
+ return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
1090
+ });
1091
+ const results = await Promise.all(updatePromises);
1092
+ const successCount = results.filter(result => result !== null).length;
1093
+ if (successCount === 0) {
1094
+ throw new Error('Failed to ungroup: no elements were updated (elements may not exist on canvas)');
1095
+ }
1096
+ logger.info('Ungrouping elements', { groupId, elementIds, successCount });
1097
+ const result = { groupId, ungrouped: true, elementIds, successCount };
1098
+ return {
1099
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1100
+ };
1101
+ }
1102
+ catch (error) {
1103
+ throw new Error(`Failed to ungroup elements: ${error.message}`);
1104
+ }
1105
+ }
1106
+ case 'align_elements': {
1107
+ const params = AlignElementsSchema.parse(args);
1108
+ const { elementIds, alignment } = params;
1109
+ logger.info('Aligning elements', { elementIds, alignment });
1110
+ // Fetch all elements
1111
+ const elementsToAlign = [];
1112
+ for (const id of elementIds) {
1113
+ const el = await getElementFromCanvas(id);
1114
+ if (el)
1115
+ elementsToAlign.push(el);
1116
+ }
1117
+ if (elementsToAlign.length < 2) {
1118
+ throw new Error('Need at least 2 elements to align');
1119
+ }
1120
+ // Calculate alignment target
1121
+ let updateFn;
1122
+ switch (alignment) {
1123
+ case 'left': {
1124
+ const minX = Math.min(...elementsToAlign.map(el => el.x));
1125
+ updateFn = () => ({ x: minX });
1126
+ break;
1127
+ }
1128
+ case 'right': {
1129
+ const maxRight = Math.max(...elementsToAlign.map(el => el.x + (el.width || 0)));
1130
+ updateFn = (el) => ({ x: maxRight - (el.width || 0) });
1131
+ break;
1132
+ }
1133
+ case 'center': {
1134
+ const centers = elementsToAlign.map(el => el.x + (el.width || 0) / 2);
1135
+ const avgCenter = centers.reduce((a, b) => a + b, 0) / centers.length;
1136
+ updateFn = (el) => ({ x: avgCenter - (el.width || 0) / 2 });
1137
+ break;
1138
+ }
1139
+ case 'top': {
1140
+ const minY = Math.min(...elementsToAlign.map(el => el.y));
1141
+ updateFn = () => ({ y: minY });
1142
+ break;
1143
+ }
1144
+ case 'bottom': {
1145
+ const maxBottom = Math.max(...elementsToAlign.map(el => el.y + (el.height || 0)));
1146
+ updateFn = (el) => ({ y: maxBottom - (el.height || 0) });
1147
+ break;
1148
+ }
1149
+ case 'middle': {
1150
+ const middles = elementsToAlign.map(el => el.y + (el.height || 0) / 2);
1151
+ const avgMiddle = middles.reduce((a, b) => a + b, 0) / middles.length;
1152
+ updateFn = (el) => ({ y: avgMiddle - (el.height || 0) / 2 });
1153
+ break;
1154
+ }
1155
+ }
1156
+ // Apply updates
1157
+ const updatePromises = elementsToAlign.map(async (el) => {
1158
+ const coords = updateFn(el);
1159
+ return await updateElementOnCanvas({ id: el.id, ...coords });
1160
+ });
1161
+ const results = await Promise.all(updatePromises);
1162
+ const successCount = results.filter(r => r).length;
1163
+ if (successCount === 0) {
1164
+ throw new Error('Failed to align any elements: HTTP server unavailable');
1165
+ }
1166
+ const result = { aligned: true, elementIds, alignment, successCount };
1167
+ return {
1168
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1169
+ };
1170
+ }
1171
+ case 'distribute_elements': {
1172
+ const params = DistributeElementsSchema.parse(args);
1173
+ const { elementIds, direction } = params;
1174
+ logger.info('Distributing elements', { elementIds, direction });
1175
+ // Fetch all elements
1176
+ const elementsToDist = [];
1177
+ for (const id of elementIds) {
1178
+ const el = await getElementFromCanvas(id);
1179
+ if (el)
1180
+ elementsToDist.push(el);
1181
+ }
1182
+ if (elementsToDist.length < 3) {
1183
+ throw new Error('Need at least 3 elements to distribute');
1184
+ }
1185
+ if (direction === 'horizontal') {
1186
+ // Sort by x position
1187
+ elementsToDist.sort((a, b) => a.x - b.x);
1188
+ const first = elementsToDist[0];
1189
+ const last = elementsToDist[elementsToDist.length - 1];
1190
+ const totalSpan = (last.x + (last.width || 0)) - first.x;
1191
+ const totalElementWidth = elementsToDist.reduce((sum, el) => sum + (el.width || 0), 0);
1192
+ const gap = (totalSpan - totalElementWidth) / (elementsToDist.length - 1);
1193
+ let currentX = first.x;
1194
+ for (const el of elementsToDist) {
1195
+ await updateElementOnCanvas({ id: el.id, x: currentX });
1196
+ currentX += (el.width || 0) + gap;
1197
+ }
1198
+ }
1199
+ else {
1200
+ // Sort by y position
1201
+ elementsToDist.sort((a, b) => a.y - b.y);
1202
+ const first = elementsToDist[0];
1203
+ const last = elementsToDist[elementsToDist.length - 1];
1204
+ const totalSpan = (last.y + (last.height || 0)) - first.y;
1205
+ const totalElementHeight = elementsToDist.reduce((sum, el) => sum + (el.height || 0), 0);
1206
+ const gap = (totalSpan - totalElementHeight) / (elementsToDist.length - 1);
1207
+ let currentY = first.y;
1208
+ for (const el of elementsToDist) {
1209
+ await updateElementOnCanvas({ id: el.id, y: currentY });
1210
+ currentY += (el.height || 0) + gap;
1211
+ }
1212
+ }
1213
+ const result = { distributed: true, elementIds, direction, count: elementsToDist.length };
1214
+ return {
1215
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1216
+ };
1217
+ }
1218
+ case 'lock_elements': {
1219
+ const params = ElementIdsSchema.parse(args);
1220
+ const { elementIds } = params;
1221
+ try {
1222
+ // Lock elements through HTTP API updates
1223
+ const updatePromises = elementIds.map(async (id) => {
1224
+ return await updateElementOnCanvas({ id, locked: true });
1225
+ });
1226
+ const results = await Promise.all(updatePromises);
1227
+ const successCount = results.filter(result => result).length;
1228
+ if (successCount === 0) {
1229
+ throw new Error('Failed to lock any elements: HTTP server unavailable');
1230
+ }
1231
+ const result = { locked: true, elementIds, successCount };
1232
+ return {
1233
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1234
+ };
1235
+ }
1236
+ catch (error) {
1237
+ throw new Error(`Failed to lock elements: ${error.message}`);
1238
+ }
1239
+ }
1240
+ case 'unlock_elements': {
1241
+ const params = ElementIdsSchema.parse(args);
1242
+ const { elementIds } = params;
1243
+ try {
1244
+ // Unlock elements through HTTP API updates
1245
+ const updatePromises = elementIds.map(async (id) => {
1246
+ return await updateElementOnCanvas({ id, locked: false });
1247
+ });
1248
+ const results = await Promise.all(updatePromises);
1249
+ const successCount = results.filter(result => result).length;
1250
+ if (successCount === 0) {
1251
+ throw new Error('Failed to unlock any elements: HTTP server unavailable');
1252
+ }
1253
+ const result = { unlocked: true, elementIds, successCount };
1254
+ return {
1255
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1256
+ };
1257
+ }
1258
+ catch (error) {
1259
+ throw new Error(`Failed to unlock elements: ${error.message}`);
1260
+ }
1261
+ }
1262
+ case 'create_from_mermaid': {
1263
+ const params = z.object({
1264
+ mermaidDiagram: z.string(),
1265
+ config: z.object({
1266
+ startOnLoad: z.boolean().optional(),
1267
+ flowchart: z.object({
1268
+ curve: z.enum(['linear', 'basis']).optional()
1269
+ }).optional(),
1270
+ themeVariables: z.object({
1271
+ fontSize: z.string().optional()
1272
+ }).optional(),
1273
+ maxEdges: z.number().optional(),
1274
+ maxTextSize: z.number().optional()
1275
+ }).optional()
1276
+ }).parse(args);
1277
+ logger.info('Creating Excalidraw elements from Mermaid diagram via MCP', {
1278
+ diagramLength: params.mermaidDiagram.length,
1279
+ hasConfig: !!params.config
1280
+ });
1281
+ try {
1282
+ // Send the Mermaid diagram to the frontend via the API
1283
+ // The frontend will use mermaid-to-excalidraw to convert it
1284
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
1285
+ method: 'POST',
1286
+ headers: canvasHeaders(),
1287
+ body: JSON.stringify({
1288
+ mermaidDiagram: params.mermaidDiagram,
1289
+ config: params.config
1290
+ })
1291
+ });
1292
+ if (!response.ok) {
1293
+ throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
1294
+ }
1295
+ const result = await response.json();
1296
+ logger.info('Mermaid diagram sent to frontend for conversion', {
1297
+ success: result.success
1298
+ });
1299
+ return {
1300
+ content: [{
1301
+ type: 'text',
1302
+ text: `Mermaid diagram sent for conversion!\n\n${JSON.stringify(result, null, 2)}\n\n⚠️ Note: The actual conversion happens in the frontend canvas with DOM access. Open the canvas at ${EXPRESS_SERVER_URL} to see the diagram rendered.`
1303
+ }]
1304
+ };
1305
+ }
1306
+ catch (error) {
1307
+ throw new Error(`Failed to process Mermaid diagram: ${error.message}`);
1308
+ }
1309
+ }
1310
+ case 'batch_create_elements': {
1311
+ const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
1312
+ logger.info('Batch creating elements via MCP', { count: params.elements.length });
1313
+ const createdElements = [];
1314
+ for (const elementData of params.elements) {
1315
+ const { startElementId, endElementId, id: customId, ...elementProps } = elementData;
1316
+ const id = customId || generateId();
1317
+ const element = {
1318
+ id,
1319
+ ...elementProps,
1320
+ points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
1321
+ // Convert binding IDs to Excalidraw's start/end format
1322
+ ...(startElementId ? { start: { id: startElementId } } : {}),
1323
+ ...(endElementId ? { end: { id: endElementId } } : {}),
1324
+ createdAt: new Date().toISOString(),
1325
+ updatedAt: new Date().toISOString(),
1326
+ version: 1
1327
+ };
1328
+ // For bound arrows without explicit points, set a default
1329
+ if ((startElementId || endElementId) && !elementProps.points) {
1330
+ element.points = [[0, 0], [100, 0]];
1331
+ }
1332
+ const excalidrawElement = convertTextToLabel(element);
1333
+ createdElements.push(excalidrawElement);
1334
+ }
1335
+ const canvasElements = await batchCreateElementsOnCanvas(createdElements);
1336
+ if (!canvasElements) {
1337
+ throw new Error('Failed to batch create elements: HTTP server unavailable');
1338
+ }
1339
+ const result = {
1340
+ success: true,
1341
+ elements: canvasElements,
1342
+ count: canvasElements.length,
1343
+ syncedToCanvas: true
1344
+ };
1345
+ logger.info('Batch elements created via MCP and synced to canvas', {
1346
+ count: result.count,
1347
+ synced: result.syncedToCanvas
1348
+ });
1349
+ return {
1350
+ content: [{
1351
+ type: 'text',
1352
+ text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}`
1353
+ }]
1354
+ };
1355
+ }
1356
+ case 'get_element': {
1357
+ const params = ElementIdSchema.parse(args);
1358
+ const { id } = params;
1359
+ const element = await getElementFromCanvas(id);
1360
+ if (!element) {
1361
+ throw new Error(`Element ${id} not found`);
1362
+ }
1363
+ return {
1364
+ content: [{ type: 'text', text: JSON.stringify(element, null, 2) }]
1365
+ };
1366
+ }
1367
+ case 'clear_canvas': {
1368
+ logger.info('Clearing canvas via MCP');
1369
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
1370
+ method: 'DELETE',
1371
+ headers: canvasHeaders()
1372
+ });
1373
+ if (!response.ok) {
1374
+ throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`);
1375
+ }
1376
+ const data = await response.json();
1377
+ return {
1378
+ content: [{
1379
+ type: 'text',
1380
+ text: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}`
1381
+ }]
1382
+ };
1383
+ }
1384
+ case 'export_scene': {
1385
+ const params = z.object({
1386
+ filePath: z.string().optional()
1387
+ }).parse(args || {});
1388
+ logger.info('Exporting scene via MCP');
1389
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
1390
+ headers: canvasHeaders()
1391
+ });
1392
+ if (!response.ok) {
1393
+ throw new Error(`Failed to fetch elements: ${response.status} ${response.statusText}`);
1394
+ }
1395
+ const data = await response.json();
1396
+ const sceneElements = data.elements || [];
1397
+ const excalidrawScene = {
1398
+ type: 'excalidraw',
1399
+ version: 2,
1400
+ source: 'mcp-excalidraw-server',
1401
+ elements: sceneElements,
1402
+ appState: {
1403
+ viewBackgroundColor: '#ffffff',
1404
+ gridSize: null
1405
+ }
1406
+ };
1407
+ const jsonString = JSON.stringify(excalidrawScene, null, 2);
1408
+ if (params.filePath) {
1409
+ const safePath = sanitizeFilePath(params.filePath);
1410
+ fs.writeFileSync(safePath, jsonString, 'utf-8');
1411
+ return {
1412
+ content: [{
1413
+ type: 'text',
1414
+ text: `Scene exported to ${safePath} (${sceneElements.length} elements)`
1415
+ }]
1416
+ };
1417
+ }
1418
+ return {
1419
+ content: [{
1420
+ type: 'text',
1421
+ text: jsonString
1422
+ }]
1423
+ };
1424
+ }
1425
+ case 'import_scene': {
1426
+ const params = z.object({
1427
+ filePath: z.string().optional(),
1428
+ data: z.string().optional(),
1429
+ mode: z.enum(['replace', 'merge'])
1430
+ }).parse(args);
1431
+ logger.info('Importing scene via MCP', { mode: params.mode });
1432
+ let sceneData;
1433
+ if (params.filePath) {
1434
+ const safeImportPath = sanitizeFilePath(params.filePath);
1435
+ const fileContent = fs.readFileSync(safeImportPath, 'utf-8');
1436
+ sceneData = JSON.parse(fileContent);
1437
+ }
1438
+ else if (params.data) {
1439
+ sceneData = JSON.parse(params.data);
1440
+ }
1441
+ else {
1442
+ throw new Error('Either filePath or data must be provided');
1443
+ }
1444
+ // Extract elements from .excalidraw format or raw array
1445
+ const importElements = Array.isArray(sceneData)
1446
+ ? sceneData
1447
+ : (sceneData.elements || []);
1448
+ if (importElements.length === 0) {
1449
+ throw new Error('No elements found in the import data');
1450
+ }
1451
+ if (params.mode === 'replace') {
1452
+ await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
1453
+ }
1454
+ // Batch create the imported elements
1455
+ const elementsToCreate = importElements.map(el => ({
1456
+ ...el,
1457
+ id: el.id || generateId(),
1458
+ createdAt: new Date().toISOString(),
1459
+ updatedAt: new Date().toISOString(),
1460
+ version: 1
1461
+ }));
1462
+ const canvasElements = await batchCreateElementsOnCanvas(elementsToCreate);
1463
+ return {
1464
+ content: [{
1465
+ type: 'text',
1466
+ text: `Imported ${elementsToCreate.length} elements (mode: ${params.mode})\n\n✅ Synced to canvas`
1467
+ }]
1468
+ };
1469
+ }
1470
+ case 'export_to_image': {
1471
+ const params = z.object({
1472
+ format: z.enum(['png', 'svg']),
1473
+ filePath: z.string().optional(),
1474
+ background: z.boolean().optional()
1475
+ }).parse(args);
1476
+ logger.info('Exporting to image via MCP', { format: params.format });
1477
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
1478
+ method: 'POST',
1479
+ headers: canvasHeaders(),
1480
+ body: JSON.stringify({
1481
+ format: params.format,
1482
+ background: params.background ?? true
1483
+ })
1484
+ });
1485
+ if (!response.ok) {
1486
+ const errorData = await response.json();
1487
+ throw new Error(errorData.error || `Export failed: ${response.status}`);
1488
+ }
1489
+ const result = await response.json();
1490
+ if (params.filePath) {
1491
+ const safeImagePath = sanitizeFilePath(params.filePath);
1492
+ if (params.format === 'svg') {
1493
+ fs.writeFileSync(safeImagePath, result.data, 'utf-8');
1494
+ }
1495
+ else {
1496
+ fs.writeFileSync(safeImagePath, Buffer.from(result.data, 'base64'));
1497
+ }
1498
+ return {
1499
+ content: [{
1500
+ type: 'text',
1501
+ text: `Image exported to ${safeImagePath} (format: ${params.format})`
1502
+ }]
1503
+ };
1504
+ }
1505
+ return {
1506
+ content: [{
1507
+ type: 'text',
1508
+ text: params.format === 'svg'
1509
+ ? result.data
1510
+ : `Base64 ${params.format} data (${result.data.length} chars). Use filePath to save to disk.`
1511
+ }]
1512
+ };
1513
+ }
1514
+ case 'duplicate_elements': {
1515
+ const params = z.object({
1516
+ elementIds: z.array(z.string()),
1517
+ offsetX: z.number().optional(),
1518
+ offsetY: z.number().optional()
1519
+ }).parse(args);
1520
+ const offsetX = params.offsetX ?? 20;
1521
+ const offsetY = params.offsetY ?? 20;
1522
+ logger.info('Duplicating elements via MCP', { count: params.elementIds.length });
1523
+ const duplicates = [];
1524
+ for (const id of params.elementIds) {
1525
+ const original = await getElementFromCanvas(id);
1526
+ if (!original) {
1527
+ logger.warn(`Element ${id} not found, skipping duplicate`);
1528
+ continue;
1529
+ }
1530
+ const { createdAt, updatedAt, version, syncedAt, source, syncTimestamp, ...rest } = original;
1531
+ const duplicate = {
1532
+ ...rest,
1533
+ id: generateId(),
1534
+ x: original.x + offsetX,
1535
+ y: original.y + offsetY,
1536
+ createdAt: new Date().toISOString(),
1537
+ updatedAt: new Date().toISOString(),
1538
+ version: 1
1539
+ };
1540
+ duplicates.push(duplicate);
1541
+ }
1542
+ if (duplicates.length === 0) {
1543
+ throw new Error('No elements could be duplicated (none found)');
1544
+ }
1545
+ const canvasElements = await batchCreateElementsOnCanvas(duplicates);
1546
+ return {
1547
+ content: [{
1548
+ type: 'text',
1549
+ text: `Duplicated ${duplicates.length} elements (offset: ${offsetX}, ${offsetY})\n\n${JSON.stringify(canvasElements, null, 2)}\n\n✅ Synced to canvas`
1550
+ }]
1551
+ };
1552
+ }
1553
+ case 'snapshot_scene': {
1554
+ const params = z.object({ name: z.string() }).parse(args);
1555
+ logger.info('Saving snapshot via MCP', { name: params.name });
1556
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots`, {
1557
+ method: 'POST',
1558
+ headers: canvasHeaders(),
1559
+ body: JSON.stringify({ name: params.name })
1560
+ });
1561
+ if (!response.ok) {
1562
+ throw new Error(`Failed to save snapshot: ${response.status} ${response.statusText}`);
1563
+ }
1564
+ const result = await response.json();
1565
+ return {
1566
+ content: [{
1567
+ type: 'text',
1568
+ text: `Snapshot "${params.name}" saved (${result.elementCount} elements)\n\n${JSON.stringify(result, null, 2)}`
1569
+ }]
1570
+ };
1571
+ }
1572
+ case 'restore_snapshot': {
1573
+ const params = z.object({ name: z.string() }).parse(args);
1574
+ logger.info('Restoring snapshot via MCP', { name: params.name });
1575
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`, {
1576
+ headers: canvasHeaders()
1577
+ });
1578
+ if (!response.ok) {
1579
+ throw new Error(`Snapshot "${params.name}" not found`);
1580
+ }
1581
+ const data = await response.json();
1582
+ await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
1583
+ // Restore elements
1584
+ const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
1585
+ return {
1586
+ content: [{
1587
+ type: 'text',
1588
+ text: `Snapshot "${params.name}" restored (${data.snapshot.elements.length} elements)\n\n✅ Canvas updated`
1589
+ }]
1590
+ };
1591
+ }
1592
+ case 'describe_scene': {
1593
+ logger.info('Describing scene via MCP');
1594
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
1595
+ headers: canvasHeaders()
1596
+ });
1597
+ if (!response.ok) {
1598
+ throw new Error(`Failed to fetch elements: ${response.status}`);
1599
+ }
1600
+ const data = await response.json();
1601
+ const allElements = data.elements || [];
1602
+ if (allElements.length === 0) {
1603
+ return {
1604
+ content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }]
1605
+ };
1606
+ }
1607
+ // Count by type
1608
+ const typeCounts = {};
1609
+ for (const el of allElements) {
1610
+ typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
1611
+ }
1612
+ // Bounding box
1613
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1614
+ for (const el of allElements) {
1615
+ minX = Math.min(minX, el.x);
1616
+ minY = Math.min(minY, el.y);
1617
+ maxX = Math.max(maxX, el.x + (el.width || 0));
1618
+ maxY = Math.max(maxY, el.y + (el.height || 0));
1619
+ }
1620
+ // Build element descriptions sorted top-to-bottom, left-to-right
1621
+ const sorted = [...allElements].sort((a, b) => {
1622
+ const rowDiff = Math.floor(a.y / 50) - Math.floor(b.y / 50);
1623
+ return rowDiff !== 0 ? rowDiff : a.x - b.x;
1624
+ });
1625
+ const elementDescs = [];
1626
+ for (const el of sorted) {
1627
+ const parts = [];
1628
+ parts.push(`[${el.id}] ${el.type}`);
1629
+ parts.push(`at (${Math.round(el.x)}, ${Math.round(el.y)})`);
1630
+ if (el.width || el.height) {
1631
+ parts.push(`size ${Math.round(el.width || 0)}x${Math.round(el.height || 0)}`);
1632
+ }
1633
+ if (el.text)
1634
+ parts.push(`text: "${el.text}"`);
1635
+ if (el.label?.text)
1636
+ parts.push(`label: "${el.label.text}"`);
1637
+ if (el.backgroundColor && el.backgroundColor !== 'transparent') {
1638
+ parts.push(`bg: ${el.backgroundColor}`);
1639
+ }
1640
+ if (el.strokeColor && el.strokeColor !== '#000000') {
1641
+ parts.push(`stroke: ${el.strokeColor}`);
1642
+ }
1643
+ if (el.locked)
1644
+ parts.push('(locked)');
1645
+ if (el.groupIds && el.groupIds.length > 0) {
1646
+ parts.push(`groups: [${el.groupIds.join(', ')}]`);
1647
+ }
1648
+ elementDescs.push(` ${parts.join(' | ')}`);
1649
+ }
1650
+ // Find connections (arrows)
1651
+ const arrows = allElements.filter(el => el.type === 'arrow');
1652
+ const connectionDescs = [];
1653
+ for (const arrow of arrows) {
1654
+ const arrowAny = arrow;
1655
+ if (arrowAny.startBinding?.elementId || arrowAny.endBinding?.elementId) {
1656
+ const from = arrowAny.startBinding?.elementId || '?';
1657
+ const to = arrowAny.endBinding?.elementId || '?';
1658
+ connectionDescs.push(` ${from} --> ${to} (arrow: ${arrow.id})`);
1659
+ }
1660
+ }
1661
+ // Build description
1662
+ const lines = [];
1663
+ lines.push(`## Canvas Description`);
1664
+ lines.push(`Total elements: ${allElements.length}`);
1665
+ lines.push(`Types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ')}`);
1666
+ lines.push(`Bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`);
1667
+ lines.push('');
1668
+ lines.push('### Elements (top-to-bottom, left-to-right):');
1669
+ lines.push(...elementDescs);
1670
+ if (connectionDescs.length > 0) {
1671
+ lines.push('');
1672
+ lines.push('### Connections:');
1673
+ lines.push(...connectionDescs);
1674
+ }
1675
+ // Groups
1676
+ const groupedElements = allElements.filter(el => el.groupIds && el.groupIds.length > 0);
1677
+ if (groupedElements.length > 0) {
1678
+ const groupMap = {};
1679
+ for (const el of groupedElements) {
1680
+ for (const gid of (el.groupIds || [])) {
1681
+ if (!groupMap[gid])
1682
+ groupMap[gid] = [];
1683
+ groupMap[gid].push(el.id);
1684
+ }
1685
+ }
1686
+ lines.push('');
1687
+ lines.push('### Groups:');
1688
+ for (const [gid, ids] of Object.entries(groupMap)) {
1689
+ lines.push(` Group ${gid}: [${ids.join(', ')}]`);
1690
+ }
1691
+ }
1692
+ return {
1693
+ content: [{ type: 'text', text: lines.join('\n') }]
1694
+ };
1695
+ }
1696
+ case 'get_canvas_screenshot': {
1697
+ const params = z.object({
1698
+ background: z.boolean().optional()
1699
+ }).parse(args || {});
1700
+ logger.info('Taking canvas screenshot via MCP');
1701
+ const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
1702
+ method: 'POST',
1703
+ headers: canvasHeaders(),
1704
+ body: JSON.stringify({
1705
+ format: 'png',
1706
+ background: params.background ?? true
1707
+ })
1708
+ });
1709
+ if (!response.ok) {
1710
+ const errorData = await response.json();
1711
+ throw new Error(errorData.error || `Screenshot failed: ${response.status}`);
1712
+ }
1713
+ const result = await response.json();
1714
+ return {
1715
+ content: [
1716
+ {
1717
+ type: 'image',
1718
+ data: result.data,
1719
+ mimeType: 'image/png'
1720
+ },
1721
+ {
1722
+ type: 'text',
1723
+ text: 'Canvas screenshot captured. This is what the diagram currently looks like.'
1724
+ }
1725
+ ]
1726
+ };
1727
+ }
1728
+ case 'read_diagram_guide': {
1729
+ return {
1730
+ content: [{ type: 'text', text: DIAGRAM_DESIGN_GUIDE }]
1731
+ };
1732
+ }
1733
+ case 'export_to_excalidraw_url': {
1734
+ logger.info('Exporting to excalidraw.com URL');
1735
+ const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
1736
+ headers: canvasHeaders()
1737
+ });
1738
+ if (!urlExportResponse.ok) {
1739
+ throw new Error(`Failed to fetch elements: ${urlExportResponse.status}`);
1740
+ }
1741
+ const urlExportData = await urlExportResponse.json();
1742
+ const urlExportElements = urlExportData.elements || [];
1743
+ if (urlExportElements.length === 0) {
1744
+ throw new Error('Canvas is empty — nothing to export');
1745
+ }
1746
+ // 2. Clean elements: strip server metadata, add Excalidraw defaults,
1747
+ // generate bound text elements, and resolve arrow bindings
1748
+ const cleanedExportElements = [];
1749
+ const boundTextElements = [];
1750
+ let indexCounter = 0;
1751
+ function makeBaseElement(el, rest) {
1752
+ return {
1753
+ ...rest,
1754
+ angle: rest.angle ?? 0,
1755
+ strokeColor: rest.strokeColor ?? '#1e1e1e',
1756
+ backgroundColor: rest.backgroundColor ?? 'transparent',
1757
+ fillStyle: rest.fillStyle ?? 'solid',
1758
+ strokeWidth: rest.strokeWidth ?? 2,
1759
+ strokeStyle: rest.strokeStyle ?? 'solid',
1760
+ roughness: rest.roughness ?? 1,
1761
+ opacity: rest.opacity ?? 100,
1762
+ groupIds: rest.groupIds ?? [],
1763
+ frameId: rest.frameId ?? null,
1764
+ index: rest.index ?? `a${indexCounter++}`,
1765
+ roundness: rest.roundness ?? (el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse'
1766
+ ? { type: 3 } : null),
1767
+ seed: rest.seed ?? Math.floor(Math.random() * 2147483647),
1768
+ version: rest.version ?? 1,
1769
+ versionNonce: rest.versionNonce ?? Math.floor(Math.random() * 2147483647),
1770
+ isDeleted: false,
1771
+ boundElements: rest.boundElements ?? null,
1772
+ updated: Date.now(),
1773
+ link: rest.link ?? null,
1774
+ locked: rest.locked ?? false
1775
+ };
1776
+ }
1777
+ for (const el of urlExportElements) {
1778
+ // Strip server-only fields
1779
+ const { createdAt, updatedAt, syncedAt, source: _src, syncTimestamp, label, start, end, text, version: _ver, ...rest } = el;
1780
+ const base = makeBaseElement(el, rest);
1781
+ // Standalone text elements: keep text directly
1782
+ if (el.type === 'text') {
1783
+ base.text = text ?? '';
1784
+ base.originalText = text ?? '';
1785
+ base.fontSize = rest.fontSize ?? 20;
1786
+ base.fontFamily = rest.fontFamily ?? 1;
1787
+ base.textAlign = rest.textAlign ?? 'center';
1788
+ base.verticalAlign = rest.verticalAlign ?? 'middle';
1789
+ base.autoResize = rest.autoResize ?? true;
1790
+ base.lineHeight = rest.lineHeight ?? 1.25;
1791
+ base.containerId = rest.containerId ?? null;
1792
+ cleanedExportElements.push(base);
1793
+ continue;
1794
+ }
1795
+ // Arrows: server already resolved bindings (start/end → startBinding/endBinding + positions)
1796
+ if (el.type === 'arrow' || el.type === 'line') {
1797
+ base.points = rest.points ?? [[0, 0], [100, 0]];
1798
+ base.lastCommittedPoint = null;
1799
+ // Preserve server-resolved bindings with fixedPoint for excalidraw.com
1800
+ if (rest.startBinding) {
1801
+ base.startBinding = { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null };
1802
+ }
1803
+ else {
1804
+ base.startBinding = null;
1805
+ }
1806
+ if (rest.endBinding) {
1807
+ base.endBinding = { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null };
1808
+ }
1809
+ else {
1810
+ base.endBinding = null;
1811
+ }
1812
+ base.startArrowhead = rest.startArrowhead ?? null;
1813
+ base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
1814
+ base.elbowed = rest.elbowed ?? false;
1815
+ }
1816
+ // Generate bound text element for label on shapes and arrows
1817
+ const labelText = label?.text || text;
1818
+ if (labelText) {
1819
+ const textId = `${base.id}-label`;
1820
+ // Add binding reference to parent
1821
+ base.boundElements = [
1822
+ ...(Array.isArray(base.boundElements) ? base.boundElements : []),
1823
+ { type: 'text', id: textId }
1824
+ ];
1825
+ // Compute text position: centered in shape, or at arrow midpoint
1826
+ let textX, textY, textW, textH;
1827
+ const isArrow = el.type === 'arrow' || el.type === 'line';
1828
+ if (isArrow) {
1829
+ // Position at midpoint of arrow path
1830
+ const pts = base.points || [[0, 0], [100, 0]];
1831
+ const lastPt = pts[pts.length - 1];
1832
+ const midX = base.x + (lastPt[0] / 2);
1833
+ const midY = base.y + (lastPt[1] / 2);
1834
+ const labelW = Math.max(labelText.length * 10, 60);
1835
+ textX = midX - labelW / 2;
1836
+ textY = midY - 12;
1837
+ textW = labelW;
1838
+ textH = 24;
1839
+ }
1840
+ else {
1841
+ // Center inside shape container
1842
+ const containerW = base.width ?? 160;
1843
+ const containerH = base.height ?? 80;
1844
+ textX = base.x + 10;
1845
+ textY = base.y + containerH / 4;
1846
+ textW = containerW - 20;
1847
+ textH = containerH / 2;
1848
+ }
1849
+ boundTextElements.push({
1850
+ id: textId,
1851
+ type: 'text',
1852
+ x: textX,
1853
+ y: textY,
1854
+ width: textW,
1855
+ height: textH,
1856
+ angle: 0,
1857
+ strokeColor: isArrow ? '#1e1e1e' : base.strokeColor,
1858
+ backgroundColor: 'transparent',
1859
+ fillStyle: 'solid',
1860
+ strokeWidth: 1,
1861
+ strokeStyle: 'solid',
1862
+ roughness: 1,
1863
+ opacity: 100,
1864
+ groupIds: [],
1865
+ frameId: null,
1866
+ index: `a${indexCounter++}`,
1867
+ roundness: null,
1868
+ seed: Math.floor(Math.random() * 2147483647),
1869
+ version: 1,
1870
+ versionNonce: Math.floor(Math.random() * 2147483647),
1871
+ isDeleted: false,
1872
+ boundElements: null,
1873
+ updated: Date.now(),
1874
+ link: null,
1875
+ locked: false,
1876
+ text: labelText,
1877
+ originalText: labelText,
1878
+ fontSize: isArrow ? 14 : (rest.fontSize ?? 16),
1879
+ fontFamily: rest.fontFamily ?? 1,
1880
+ textAlign: 'center',
1881
+ verticalAlign: 'middle',
1882
+ autoResize: true,
1883
+ lineHeight: 1.25,
1884
+ containerId: base.id
1885
+ });
1886
+ }
1887
+ cleanedExportElements.push(base);
1888
+ }
1889
+ // Patch shapes' boundElements to include connected arrows
1890
+ const shapeBoundArrows = new Map();
1891
+ for (const el of cleanedExportElements) {
1892
+ if (el.startBinding?.elementId) {
1893
+ const arr = shapeBoundArrows.get(el.startBinding.elementId) || [];
1894
+ arr.push({ type: 'arrow', id: el.id });
1895
+ shapeBoundArrows.set(el.startBinding.elementId, arr);
1896
+ }
1897
+ if (el.endBinding?.elementId) {
1898
+ const arr = shapeBoundArrows.get(el.endBinding.elementId) || [];
1899
+ arr.push({ type: 'arrow', id: el.id });
1900
+ shapeBoundArrows.set(el.endBinding.elementId, arr);
1901
+ }
1902
+ }
1903
+ for (const el of cleanedExportElements) {
1904
+ const arrowBindings = shapeBoundArrows.get(el.id);
1905
+ if (arrowBindings) {
1906
+ el.boundElements = [
1907
+ ...(Array.isArray(el.boundElements) ? el.boundElements : []),
1908
+ ...arrowBindings
1909
+ ];
1910
+ }
1911
+ }
1912
+ // Append all bound text elements after their parents
1913
+ cleanedExportElements.push(...boundTextElements);
1914
+ // Build .excalidraw scene JSON
1915
+ const excalidrawScene = {
1916
+ type: 'excalidraw',
1917
+ version: 2,
1918
+ source: 'https://excalidraw.com',
1919
+ elements: cleanedExportElements,
1920
+ appState: {
1921
+ viewBackgroundColor: '#ffffff',
1922
+ gridSize: null
1923
+ },
1924
+ files: {}
1925
+ };
1926
+ const sceneJson = JSON.stringify(excalidrawScene);
1927
+ const dataBytes = new TextEncoder().encode(sceneJson);
1928
+ // Excalidraw's concatBuffers: [4-byte version=1][4-byte len][chunk]...
1929
+ function concatBuffers(...bufs) {
1930
+ let total = 4; // version header
1931
+ for (const b of bufs)
1932
+ total += 4 + b.length;
1933
+ const out = new Uint8Array(total);
1934
+ const dv = new DataView(out.buffer);
1935
+ dv.setUint32(0, 1); // CONCAT_BUFFERS_VERSION = 1
1936
+ let off = 4;
1937
+ for (const b of bufs) {
1938
+ dv.setUint32(off, b.length);
1939
+ off += 4;
1940
+ out.set(b, off);
1941
+ off += b.length;
1942
+ }
1943
+ return out;
1944
+ }
1945
+ const encoder = new TextEncoder();
1946
+ // 3. Inner data: concatBuffers(fileMetadata, dataJSON)
1947
+ const fileMetadata = encoder.encode('{}');
1948
+ const innerData = concatBuffers(fileMetadata, dataBytes);
1949
+ // 4. Compress with zlib deflate
1950
+ const compressed = deflateSync(Buffer.from(innerData));
1951
+ // 5. Encrypt with AES-GCM 128-bit key
1952
+ const cryptoKey = await webcrypto.subtle.generateKey({ name: 'AES-GCM', length: 128 }, true, ['encrypt']);
1953
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
1954
+ const encrypted = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, compressed);
1955
+ // 6. Outer payload: concatBuffers(encodingMeta, iv, ciphertext)
1956
+ const encodingMeta = encoder.encode(JSON.stringify({
1957
+ version: 2,
1958
+ compression: 'pako@1',
1959
+ encryption: 'AES-GCM'
1960
+ }));
1961
+ const ciphertext = new Uint8Array(encrypted);
1962
+ const payload = concatBuffers(encodingMeta, iv, ciphertext);
1963
+ // 7. POST to excalidraw.com JSON store
1964
+ const uploadResponse = await fetch('https://json.excalidraw.com/api/v2/post/', {
1965
+ method: 'POST',
1966
+ body: Buffer.from(payload)
1967
+ });
1968
+ if (!uploadResponse.ok) {
1969
+ throw new Error(`Upload to excalidraw.com failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
1970
+ }
1971
+ const uploadResult = await uploadResponse.json();
1972
+ // 8. Export key as JWK to get the "k" field
1973
+ const jwk = await webcrypto.subtle.exportKey('jwk', cryptoKey);
1974
+ // 9. Build shareable URL
1975
+ const shareUrl = `https://excalidraw.com/#json=${uploadResult.id},${jwk.k}`;
1976
+ return {
1977
+ content: [{
1978
+ type: 'text',
1979
+ text: `Diagram exported to excalidraw.com!\n\nShareable URL: ${shareUrl}\n\nAnyone with this link can view and edit the diagram.`
1980
+ }]
1981
+ };
1982
+ }
1983
+ case 'set_viewport': {
1984
+ const viewportParams = z.object({
1985
+ scrollToContent: z.boolean().optional(),
1986
+ scrollToElementId: z.string().optional(),
1987
+ zoom: z.number().min(0.1).max(10).optional(),
1988
+ offsetX: z.number().optional(),
1989
+ offsetY: z.number().optional()
1990
+ }).parse(args || {});
1991
+ logger.info('Setting viewport via MCP', viewportParams);
1992
+ const viewportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/viewport`, {
1993
+ method: 'POST',
1994
+ headers: canvasHeaders(),
1995
+ body: JSON.stringify(viewportParams)
1996
+ });
1997
+ if (!viewportResponse.ok) {
1998
+ const viewportError = await viewportResponse.json();
1999
+ throw new Error(viewportError.error || `Viewport request failed: ${viewportResponse.status}`);
2000
+ }
2001
+ const viewportResult = await viewportResponse.json();
2002
+ return {
2003
+ content: [{
2004
+ type: 'text',
2005
+ text: `Viewport updated successfully.\n\n${JSON.stringify(viewportResult, null, 2)}`
2006
+ }]
2007
+ };
2008
+ }
2009
+ case 'search_elements': {
2010
+ const params = z.object({ query: z.string() }).parse(args);
2011
+ logger.info('Searching elements via MCP', { query: params.query });
2012
+ const results = dbSearchElements(params.query);
2013
+ return {
2014
+ content: [{
2015
+ type: 'text',
2016
+ text: results.length > 0
2017
+ ? `Found ${results.length} matching elements:\n\n${JSON.stringify(results, null, 2)}`
2018
+ : `No elements found matching "${params.query}"`
2019
+ }]
2020
+ };
2021
+ }
2022
+ case 'list_projects': {
2023
+ logger.info('Listing projects via MCP');
2024
+ const projects = dbListProjects();
2025
+ const active = dbGetActiveProject();
2026
+ return {
2027
+ content: [{
2028
+ type: 'text',
2029
+ text: `Active project: ${active.name} (${active.id})\n\nAll projects:\n${JSON.stringify(projects, null, 2)}`
2030
+ }]
2031
+ };
2032
+ }
2033
+ case 'switch_project': {
2034
+ const params = z.object({
2035
+ projectId: z.string().optional(),
2036
+ createName: z.string().optional(),
2037
+ createDescription: z.string().optional()
2038
+ }).parse(args || {});
2039
+ if (params.createName) {
2040
+ const newProject = dbCreateProject(params.createName, params.createDescription);
2041
+ dbSetActiveProject(newProject.id);
2042
+ logger.info('Created and switched to new project', { project: newProject });
2043
+ return {
2044
+ content: [{
2045
+ type: 'text',
2046
+ text: `Created new project "${newProject.name}" and switched to it.\n\n${JSON.stringify(newProject, null, 2)}`
2047
+ }]
2048
+ };
2049
+ }
2050
+ if (params.projectId) {
2051
+ dbSetActiveProject(params.projectId);
2052
+ const active = dbGetActiveProject();
2053
+ logger.info('Switched project', { project: active });
2054
+ return {
2055
+ content: [{
2056
+ type: 'text',
2057
+ text: `Switched to project "${active.name}" (${active.id})`
2058
+ }]
2059
+ };
2060
+ }
2061
+ throw new Error('Provide either projectId to switch to or createName to create a new project');
2062
+ }
2063
+ case 'element_history': {
2064
+ const params = z.object({
2065
+ elementId: z.string().optional(),
2066
+ limit: z.number().optional()
2067
+ }).parse(args || {});
2068
+ const limit = params.limit ?? 50;
2069
+ if (params.elementId) {
2070
+ const history = dbGetElementHistory(params.elementId, limit);
2071
+ return {
2072
+ content: [{
2073
+ type: 'text',
2074
+ text: history.length > 0
2075
+ ? `Version history for element ${params.elementId} (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
2076
+ : `No history found for element ${params.elementId}`
2077
+ }]
2078
+ };
2079
+ }
2080
+ const history = dbGetProjectHistory(limit);
2081
+ const active = dbGetActiveProject();
2082
+ return {
2083
+ content: [{
2084
+ type: 'text',
2085
+ text: history.length > 0
2086
+ ? `Project history for "${active.name}" (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
2087
+ : `No history in project "${active.name}"`
2088
+ }]
2089
+ };
2090
+ }
2091
+ case 'list_tenants': {
2092
+ logger.info('Listing tenants via MCP');
2093
+ const tenants = dbListTenants();
2094
+ const activeTenant = dbGetActiveTenant();
2095
+ return {
2096
+ content: [{
2097
+ type: 'text',
2098
+ text: `Active tenant: ${activeTenant.name} (${activeTenant.id})\nWorkspace: ${activeTenant.workspace_path}\n\nAll tenants:\n${JSON.stringify(tenants, null, 2)}`
2099
+ }]
2100
+ };
2101
+ }
2102
+ case 'switch_tenant': {
2103
+ const params = z.object({ tenantId: z.string() }).parse(args);
2104
+ logger.info('Switching tenant via MCP', { tenantId: params.tenantId });
2105
+ dbSetActiveTenant(params.tenantId);
2106
+ const tenant = dbGetActiveTenant();
2107
+ const activeProject = dbGetActiveProject();
2108
+ try {
2109
+ await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
2110
+ method: 'PUT',
2111
+ headers: canvasHeaders(),
2112
+ body: JSON.stringify({ tenantId: params.tenantId })
2113
+ });
2114
+ }
2115
+ catch { }
2116
+ return {
2117
+ content: [{
2118
+ type: 'text',
2119
+ text: `Switched to tenant "${tenant.name}" (${tenant.id})\nWorkspace: ${tenant.workspace_path}\nActive project: ${activeProject.name} (${activeProject.id})`
2120
+ }]
2121
+ };
2122
+ }
2123
+ default:
2124
+ throw new Error(`Unknown tool: ${name}`);
2125
+ }
2126
+ }
2127
+ catch (error) {
2128
+ logger.error(`Error handling tool call: ${error.message}`, { error });
2129
+ return {
2130
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
2131
+ isError: true
2132
+ };
2133
+ }
2134
+ });
2135
+ // Set up request handler for listing available tools
2136
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
2137
+ logger.info('Listing available tools');
2138
+ return { tools };
2139
+ });
2140
+ // Start server
2141
+ async function runServer() {
2142
+ try {
2143
+ logger.info('Starting Excalidraw MCP server...');
2144
+ // Initialize SQLite before anything else
2145
+ initDb();
2146
+ // Bootstrap tenant from process.cwd() (may be home dir for global MCPs)
2147
+ let workspacePath = process.cwd();
2148
+ function applyTenant(wp) {
2149
+ const tid = createHash('sha256').update(wp).digest('hex').slice(0, 12);
2150
+ const tname = path.basename(wp);
2151
+ dbEnsureTenant(tid, tname, wp);
2152
+ dbSetActiveTenant(tid);
2153
+ logger.info(`Tenant initialized: "${tname}" (${tid}) from ${wp}`);
2154
+ return { tenantId: tid, tenantName: tname };
2155
+ }
2156
+ applyTenant(workspacePath);
2157
+ try {
2158
+ await startCanvasServer();
2159
+ logger.info('Canvas server started — lifecycle managed by MCP process');
2160
+ }
2161
+ catch (canvasError) {
2162
+ logger.warn('Canvas server failed to start:', canvasError.message);
2163
+ logger.warn('MCP tools will work without real-time canvas sync');
2164
+ }
2165
+ const transport = new StdioServerTransport();
2166
+ logger.debug('Connecting to stdio transport...');
2167
+ await server.connect(transport);
2168
+ logger.info('Excalidraw MCP server running on stdio');
2169
+ // After connecting, ask the client for the real workspace roots.
2170
+ // Global MCPs often get cwd=HOME; roots gives us the actual workspace.
2171
+ try {
2172
+ const { roots } = await server.listRoots(undefined, { timeout: 5_000 });
2173
+ if (roots && roots.length > 0) {
2174
+ const rootUri = roots[0].uri;
2175
+ const rootPath = rootUri.startsWith('file://') ? decodeURIComponent(rootUri.slice(7)) : rootUri;
2176
+ if (rootPath && rootPath !== workspacePath) {
2177
+ logger.info(`Client reported workspace root: ${rootPath} (was ${workspacePath})`);
2178
+ workspacePath = rootPath;
2179
+ const { tenantId: newTid } = applyTenant(workspacePath);
2180
+ try {
2181
+ await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
2182
+ method: 'PUT',
2183
+ headers: canvasHeaders(),
2184
+ body: JSON.stringify({ tenantId: newTid })
2185
+ });
2186
+ }
2187
+ catch { }
2188
+ }
2189
+ }
2190
+ }
2191
+ catch (rootsErr) {
2192
+ logger.debug('Could not retrieve roots from client (not supported or timed out):', rootsErr.message);
2193
+ }
2194
+ async function shutdown() {
2195
+ logger.info('MCP transport closed — shutting down');
2196
+ try {
2197
+ await stopCanvasServer();
2198
+ }
2199
+ catch { }
2200
+ try {
2201
+ closeDb();
2202
+ }
2203
+ catch { }
2204
+ process.exit(0);
2205
+ }
2206
+ server.onclose = shutdown;
2207
+ process.stdin.on('close', shutdown);
2208
+ process.on('SIGTERM', shutdown);
2209
+ process.on('SIGINT', shutdown);
2210
+ process.stdin.resume();
2211
+ }
2212
+ catch (error) {
2213
+ logger.error('Error starting server:', error);
2214
+ process.stderr.write(`Failed to start MCP server: ${error.message}\n${error.stack}\n`);
2215
+ process.exit(1);
2216
+ }
2217
+ }
2218
+ // Add global error handlers
2219
+ process.on('uncaughtException', (error) => {
2220
+ logger.error('Uncaught exception:', error);
2221
+ process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
2222
+ setTimeout(() => process.exit(1), 1000);
2223
+ });
2224
+ process.on('unhandledRejection', (reason, promise) => {
2225
+ logger.error('Unhandled promise rejection:', reason);
2226
+ process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`);
2227
+ setTimeout(() => process.exit(1), 1000);
2228
+ });
2229
+ // For testing and debugging purposes
2230
+ if (process.env.DEBUG === 'true') {
2231
+ logger.debug('Debug mode enabled');
2232
+ }
2233
+ // Start the server if this file is run directly
2234
+ if (fileURLToPath(import.meta.url) === process.argv[1]) {
2235
+ runServer().catch(error => {
2236
+ logger.error('Failed to start server:', error);
2237
+ process.exit(1);
2238
+ });
2239
+ }
2240
+ export default runServer;
2241
+ //# sourceMappingURL=index.js.map