excalidrop 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +411 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +533 -0
- package/dist/cli.js.map +1 -0
- package/dist/frontend/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
- package/dist/frontend/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
- package/dist/frontend/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
- package/dist/frontend/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
- package/dist/frontend/assets/Tableau10-B-NsZVaP.js +1 -0
- package/dist/frontend/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
- package/dist/frontend/assets/advancedFormat-bKUmsig-.js +1 -0
- package/dist/frontend/assets/ar-SA-G6X2FPQ2-C7K_UR3P.js +10 -0
- package/dist/frontend/assets/arc-BI_FJWE7.js +1 -0
- package/dist/frontend/assets/array-BKyUJesY.js +1 -0
- package/dist/frontend/assets/az-AZ-76LH7QW2-PkhuRuSr.js +1 -0
- package/dist/frontend/assets/band-dPffDWoQ.js +1 -0
- package/dist/frontend/assets/bg-BG-XCXSNQG7-CgLRSYTl.js +5 -0
- package/dist/frontend/assets/blockDiagram-38ab4fdb-BFdBRYSn.js +118 -0
- package/dist/frontend/assets/blockDiagram-68f4deed-Ynbqr7bQ.js +118 -0
- package/dist/frontend/assets/bn-BD-2XOGV67Q-CEvkU0mz.js +5 -0
- package/dist/frontend/assets/c4Diagram-15b5d702-Dm7F1Pn_.js +10 -0
- package/dist/frontend/assets/c4Diagram-3d4e48cf-COkK-e67.js +10 -0
- package/dist/frontend/assets/ca-ES-6MX7JW3Y-DzP6q9bE.js +8 -0
- package/dist/frontend/assets/channel-BqCiG5n3.js +1 -0
- package/dist/frontend/assets/classDiagram-70f12bd4-CRzulwvH.js +2 -0
- package/dist/frontend/assets/classDiagram-d40c83e7-DNbA-n-a.js +2 -0
- package/dist/frontend/assets/classDiagram-v2-d5a6b087-l-GglywH.js +2 -0
- package/dist/frontend/assets/classDiagram-v2-f2320105-B_TY1zoc.js +2 -0
- package/dist/frontend/assets/clone-B6Jqcrku.js +1 -0
- package/dist/frontend/assets/createText-2e5e7dd3-DB_k52Pn.js +5 -0
- package/dist/frontend/assets/createText-d213de94-D-G49giw.js +5 -0
- package/dist/frontend/assets/cs-CZ-2BRQDIVT-Bf4TOzpR.js +11 -0
- package/dist/frontend/assets/cytoscape-cose-bilkent-DQYypj0P.js +331 -0
- package/dist/frontend/assets/da-DK-5WZEPLOC-jO-5kyXb.js +5 -0
- package/dist/frontend/assets/de-DE-XR44H4JA-BkNXWOVh.js +8 -0
- package/dist/frontend/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
- package/dist/frontend/assets/directory-open-4ed118d0-BzWybGaI.js +1 -0
- package/dist/frontend/assets/edges-332bd1c7-CVvMtkTa.js +4 -0
- package/dist/frontend/assets/edges-e0da2a9e-DQBAvC8-.js +4 -0
- package/dist/frontend/assets/el-GR-BZB4AONW-Dv7Y9hMk.js +10 -0
- package/dist/frontend/assets/elk.bundled-YVS3PsMH.js +26 -0
- package/dist/frontend/assets/erDiagram-880f2ed8-BjBeR38f.js +51 -0
- package/dist/frontend/assets/erDiagram-9861fffd-B2Bi3R45.js +51 -0
- package/dist/frontend/assets/es-ES-U4NZUMDT-CWhs0SQY.js +9 -0
- package/dist/frontend/assets/eu-ES-A7QVB2H4-BfkFwc3e.js +11 -0
- package/dist/frontend/assets/fa-IR-HGAKTJCU-BgWzSj46.js +8 -0
- package/dist/frontend/assets/fi-FI-Z5N7JZ37-CUyvkum-.js +6 -0
- package/dist/frontend/assets/file-open-002ab408-DIuFHtCF.js +1 -0
- package/dist/frontend/assets/file-open-7c801643-684qeFg4.js +1 -0
- package/dist/frontend/assets/file-save-3189631c-x92wctJd.js +1 -0
- package/dist/frontend/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
- package/dist/frontend/assets/flowDb-7c981674-BSZyyVBf.js +10 -0
- package/dist/frontend/assets/flowDb-956e92f1-BC3-CLkF.js +10 -0
- package/dist/frontend/assets/flowDiagram-66a62f08-H57DAvmM.js +4 -0
- package/dist/frontend/assets/flowDiagram-cbd28bf7-Bw4Z2nBD.js +4 -0
- package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-CBjbm_oB.js +1 -0
- package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-CoQufPp2.js +1 -0
- package/dist/frontend/assets/flowchart-elk-definition-36e2d292-1kZNdf3X.js +114 -0
- package/dist/frontend/assets/flowchart-elk-definition-4a651766-DDhG2Esi.js +114 -0
- package/dist/frontend/assets/fr-FR-RHASNOE6-7xzoRwbd.js +9 -0
- package/dist/frontend/assets/ganttDiagram-04f9e578-CtUSsXCz.js +257 -0
- package/dist/frontend/assets/ganttDiagram-c361ad54-K42AklFY.js +257 -0
- package/dist/frontend/assets/gitGraphDiagram-21fc4d3e-uW6XA6Y6.js +70 -0
- package/dist/frontend/assets/gitGraphDiagram-72cf32ee-CCNbhnBg.js +70 -0
- package/dist/frontend/assets/gl-ES-HMX3MZ6V-CXuDDwNP.js +10 -0
- package/dist/frontend/assets/graph-CYOmcSYN.js +1 -0
- package/dist/frontend/assets/graph-o42dOfI7.js +1 -0
- package/dist/frontend/assets/he-IL-6SHJWFNN-O9dzh9EM.js +10 -0
- package/dist/frontend/assets/hi-IN-IWLTKZ5I-DlrOXhWs.js +4 -0
- package/dist/frontend/assets/hu-HU-A5ZG7DT2-C3eNg-uI.js +7 -0
- package/dist/frontend/assets/id-ID-SAP4L64H-6iM4pI5c.js +10 -0
- package/dist/frontend/assets/image-blob-reduce.esm-B6b2_-a4.js +7 -0
- package/dist/frontend/assets/index-3862675e-DrvLhypC.js +1 -0
- package/dist/frontend/assets/index-6079d271-BgVTRqg2.js +1 -0
- package/dist/frontend/assets/index-BRA5OktX.css +1 -0
- package/dist/frontend/assets/index-D-cr7jpJ.js +349 -0
- package/dist/frontend/assets/index-DGmpr33w.js +3 -0
- package/dist/frontend/assets/index-HkUXslJy.js +87 -0
- package/dist/frontend/assets/infoDiagram-4a4f5b27-ZxmAcaW1.js +7 -0
- package/dist/frontend/assets/infoDiagram-f8f76790-Bn-PhiFG.js +7 -0
- package/dist/frontend/assets/init-Gi6I4Gst.js +1 -0
- package/dist/frontend/assets/it-IT-JPQ66NNP-B26NQzGp.js +11 -0
- package/dist/frontend/assets/ja-JP-DBVTYXUO-CB0fvnYJ.js +8 -0
- package/dist/frontend/assets/journeyDiagram-29694f62-ggDKs06f.js +139 -0
- package/dist/frontend/assets/journeyDiagram-49397b02-pdDS-n-z.js +139 -0
- package/dist/frontend/assets/kaa-6HZHGXH3-YNdBdcwJ.js +1 -0
- package/dist/frontend/assets/kab-KAB-ZGHBKWFO-D86pwZO0.js +8 -0
- package/dist/frontend/assets/katex-ChWnQ-fc.js +261 -0
- package/dist/frontend/assets/kk-KZ-P5N5QNE5-CpG5oheM.js +1 -0
- package/dist/frontend/assets/km-KH-HSX4SM5Z-DYOZ3Azw.js +11 -0
- package/dist/frontend/assets/ko-KR-MTYHY66A-BHorm6lC.js +9 -0
- package/dist/frontend/assets/ku-TR-6OUDTVRD-CM9RgEHv.js +9 -0
- package/dist/frontend/assets/layout-CCtkxrAf.js +1 -0
- package/dist/frontend/assets/layout-DW9Qf-At.js +1 -0
- package/dist/frontend/assets/line-DvZfgg9P.js +1 -0
- package/dist/frontend/assets/linear-FwFrGRfa.js +1 -0
- package/dist/frontend/assets/lt-LT-XHIRWOB4-DvriKXBj.js +3 -0
- package/dist/frontend/assets/lv-LV-5QDEKY6T-DzQpwk87.js +7 -0
- package/dist/frontend/assets/mindmap-definition-ac74a2e8-DsRo8f2w.js +95 -0
- package/dist/frontend/assets/mindmap-definition-fc14e90a-dQ9reluI.js +95 -0
- package/dist/frontend/assets/mr-IN-CRQNXWMA-C7RzxxP5.js +13 -0
- package/dist/frontend/assets/my-MM-5M5IBNSE-B-RRuy29.js +1 -0
- package/dist/frontend/assets/nb-NO-T6EIAALU-B47x16Ik.js +10 -0
- package/dist/frontend/assets/nl-NL-IS3SIHDZ-BxAKoQne.js +8 -0
- package/dist/frontend/assets/nn-NO-6E72VCQL-dfpbEDSZ.js +8 -0
- package/dist/frontend/assets/oc-FR-POXYY2M6-gv6geBjV.js +8 -0
- package/dist/frontend/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/frontend/assets/pa-IN-N4M65BXN-CY30-qH7.js +4 -0
- package/dist/frontend/assets/path-CbwjOpE9.js +1 -0
- package/dist/frontend/assets/pica-DWYGHLQV.js +7 -0
- package/dist/frontend/assets/pie-CDiWh1rj.js +1 -0
- package/dist/frontend/assets/pieDiagram-421022e6-Bgy-duyf.js +35 -0
- package/dist/frontend/assets/pieDiagram-8a3498a8-DH7FsbPK.js +35 -0
- package/dist/frontend/assets/pl-PL-T2D74RX3-TrHugsIA.js +9 -0
- package/dist/frontend/assets/pt-BR-5N22H2LF-CJxdFp9c.js +9 -0
- package/dist/frontend/assets/pt-PT-UZXXM6DQ-CB-1q6YJ.js +9 -0
- package/dist/frontend/assets/quadrantDiagram-0957ecba-DcwYI1oi.js +7 -0
- package/dist/frontend/assets/quadrantDiagram-120e2f19-CvHU5a9v.js +7 -0
- package/dist/frontend/assets/requirementDiagram-23d650b8-tdagN8HP.js +52 -0
- package/dist/frontend/assets/requirementDiagram-deff3bca-BMGM-fSI.js +52 -0
- package/dist/frontend/assets/ro-RO-JPDTUUEW-DQq9DBOd.js +11 -0
- package/dist/frontend/assets/roundRect-0PYZxl1G.js +1 -0
- package/dist/frontend/assets/ru-RU-B4JR7IUQ-CWX32rfh.js +9 -0
- package/dist/frontend/assets/sankeyDiagram-04a897e0-3i6zWpY2.js +8 -0
- package/dist/frontend/assets/sankeyDiagram-23345273-ncj1Ol7S.js +8 -0
- package/dist/frontend/assets/sankeyLinkHorizontal-DgqkLiUE.js +1 -0
- package/dist/frontend/assets/selectAll-DJwesbwT.js +1 -0
- package/dist/frontend/assets/sequenceDiagram-17ac3bff-ChyKKRZA.js +122 -0
- package/dist/frontend/assets/sequenceDiagram-704730f1-D57_wP9k.js +122 -0
- package/dist/frontend/assets/si-LK-N5RQ5JYF-Lkc5ZxyG.js +1 -0
- package/dist/frontend/assets/sk-SK-C5VTKIMK-DyvYXjqZ.js +6 -0
- package/dist/frontend/assets/sl-SI-NN7IZMDC-D_MvP9Rh.js +6 -0
- package/dist/frontend/assets/stateDiagram-587899a1-OjFTnFLS.js +1 -0
- package/dist/frontend/assets/stateDiagram-9c5f0230-BzU36S5C.js +1 -0
- package/dist/frontend/assets/stateDiagram-v2-51a3dcff-CfX1qo7U.js +1 -0
- package/dist/frontend/assets/stateDiagram-v2-d93cdb3a-BH7Ud4sU.js +1 -0
- package/dist/frontend/assets/styles-2ab5d517-CA8x9Nh5.js +116 -0
- package/dist/frontend/assets/styles-5f03d8d2-BnO2pLhV.js +160 -0
- package/dist/frontend/assets/styles-6aaf32cf-COnot1ea.js +207 -0
- package/dist/frontend/assets/styles-9a916d00-C3QkBgTF.js +160 -0
- package/dist/frontend/assets/styles-c10674c1-CPAG2_rr.js +116 -0
- package/dist/frontend/assets/styles-edf9a4b0-B8Q9fyu0.js +207 -0
- package/dist/frontend/assets/subset-shared.chunk.js +84 -0
- package/dist/frontend/assets/subset-worker.chunk.js +1 -0
- package/dist/frontend/assets/sv-SE-XGPEYMSR-t6voGQSI.js +10 -0
- package/dist/frontend/assets/svgDrawCommon-08f97a94-CdT21LWf.js +1 -0
- package/dist/frontend/assets/svgDrawCommon-3ba9043b-D1MU7jAE.js +1 -0
- package/dist/frontend/assets/ta-IN-2NMHFXQM-7VmfDDyV.js +9 -0
- package/dist/frontend/assets/th-TH-HPSO5L25-B5rLEY9P.js +2 -0
- package/dist/frontend/assets/timeline-definition-7e6b55e7-BIcL44O_.js +61 -0
- package/dist/frontend/assets/timeline-definition-85554ec2-TgrYYwpU.js +61 -0
- package/dist/frontend/assets/tr-TR-DEFEU3FU-0CgOHXTn.js +7 -0
- package/dist/frontend/assets/uk-UA-QMV73CPH-CzSFsIsx.js +6 -0
- package/dist/frontend/assets/union-CnQAJu0l.js +1 -0
- package/dist/frontend/assets/vi-VN-M7AON7JQ-Bhx3rYU9.js +5 -0
- package/dist/frontend/assets/xml-BOsq7VnW.js +1 -0
- package/dist/frontend/assets/xychartDiagram-b6496bcd-BMKDop6v.js +7 -0
- package/dist/frontend/assets/xychartDiagram-e933f94c-DAWithrN.js +7 -0
- package/dist/frontend/assets/zh-CN-LNUGB5OW-Br52_wH4.js +10 -0
- package/dist/frontend/assets/zh-HK-E62DVLB3-Cj6mnIQ9.js +1 -0
- package/dist/frontend/assets/zh-TW-RAJ6MFWO-yLEsVBbC.js +9 -0
- package/dist/frontend/assets/zipObject-BeXj5PVd.js +1 -0
- package/dist/frontend/index.html +339 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2305 -0
- package/dist/index.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +133 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1217 -0
- package/dist/server.js.map +1 -0
- package/dist/target.d.ts +41 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +244 -0
- package/dist/target.js.map +1 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +53 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/auth.d.ts +15 -0
- package/dist/utils/auth.d.ts.map +1 -0
- package/dist/utils/auth.js +68 -0
- package/dist/utils/auth.js.map +1 -0
- package/dist/utils/github.d.ts +26 -0
- package/dist/utils/github.d.ts.map +1 -0
- package/dist/utils/github.js +111 -0
- package/dist/utils/github.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +25 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/r2store.d.ts +19 -0
- package/dist/utils/r2store.d.ts.map +1 -0
- package/dist/utils/r2store.js +75 -0
- package/dist/utils/r2store.js.map +1 -0
- package/frontend/index.html +338 -0
- package/frontend/src/App.tsx +1091 -0
- package/frontend/src/main.tsx +15 -0
- package/frontend/src/utils/ghSync.ts +171 -0
- package/frontend/src/utils/mermaidConverter.ts +49 -0
- package/frontend/src/utils/webmcp.ts +285 -0
- package/package.json +101 -0
- package/scripts/check-local-bind.mjs +164 -0
- package/scripts/postinstall.mjs +23 -0
- package/scripts/publish-pages.sh +39 -0
- package/scripts/seed-r2.mjs +9 -0
- package/skills/excalidraw-skill/SKILL.md +303 -0
- package/skills/excalidraw-skill/evals/evals.json +23 -0
- package/skills/excalidraw-skill/references/cheatsheet.md +142 -0
- package/skills/excalidraw-skill/scripts/clear-canvas.cjs +38 -0
- package/skills/excalidraw-skill/scripts/create-element.cjs +68 -0
- package/skills/excalidraw-skill/scripts/delete-element.cjs +48 -0
- package/skills/excalidraw-skill/scripts/export-elements.cjs +53 -0
- package/skills/excalidraw-skill/scripts/healthcheck.cjs +35 -0
- package/skills/excalidraw-skill/scripts/import-elements.cjs +81 -0
- package/skills/excalidraw-skill/scripts/update-element.cjs +70 -0
- package/src/cli.ts +559 -0
- package/src/index.ts +2557 -0
- package/src/remote.ts +96 -0
- package/src/server.ts +1349 -0
- package/src/target.ts +208 -0
- package/src/types.ts +338 -0
- package/src/utils/auth.ts +61 -0
- package/src/utils/github.ts +93 -0
- package/src/utils/logger.ts +35 -0
- package/src/utils/r2store.ts +76 -0
- package/tsconfig.json +50 -0
- package/vite.config.js +37 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,2557 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Disable colors to prevent ANSI color codes from breaking JSON parsing
|
|
4
|
+
process.env.NODE_DISABLE_COLORS = '1';
|
|
5
|
+
process.env.NO_COLOR = '1';
|
|
6
|
+
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { deflateSync } from 'zlib';
|
|
9
|
+
import { webcrypto } from 'crypto';
|
|
10
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import {
|
|
13
|
+
CallToolRequestSchema,
|
|
14
|
+
ListToolsRequestSchema,
|
|
15
|
+
CallToolRequest,
|
|
16
|
+
Tool
|
|
17
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import dotenv from 'dotenv';
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import logger from './utils/logger.js';
|
|
23
|
+
import {
|
|
24
|
+
generateId,
|
|
25
|
+
EXCALIDRAW_ELEMENT_TYPES,
|
|
26
|
+
ServerElement,
|
|
27
|
+
ExcalidrawElementType,
|
|
28
|
+
validateElement,
|
|
29
|
+
normalizeFontFamily
|
|
30
|
+
} from './types.js';
|
|
31
|
+
import fetch from 'node-fetch';
|
|
32
|
+
|
|
33
|
+
// Load environment variables
|
|
34
|
+
dotenv.config();
|
|
35
|
+
|
|
36
|
+
// Safe file path validation to prevent path traversal attacks
|
|
37
|
+
const ALLOWED_EXPORT_DIR = process.env.EXCALIDRAW_EXPORT_DIR || process.cwd();
|
|
38
|
+
|
|
39
|
+
function sanitizeFilePath(filePath: string): string {
|
|
40
|
+
const resolved = path.resolve(filePath);
|
|
41
|
+
const allowedDir = path.resolve(ALLOWED_EXPORT_DIR);
|
|
42
|
+
if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Path traversal blocked: "${filePath}" resolves outside the allowed directory "${allowedDir}". ` +
|
|
45
|
+
`Set EXCALIDRAW_EXPORT_DIR to change the allowed base directory.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Express server configuration (mutable — switch_canvas repoints it at runtime)
|
|
52
|
+
let ACTIVE_CANVAS_URL = process.env.EXPRESS_SERVER_URL || 'http://127.0.0.1:3000';
|
|
53
|
+
function activeCanvasUrl(): string {
|
|
54
|
+
return process.env.EXPRESS_SERVER_URL || ACTIVE_CANVAS_URL;
|
|
55
|
+
}
|
|
56
|
+
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
|
|
57
|
+
|
|
58
|
+
// API Response types
|
|
59
|
+
interface ApiResponse {
|
|
60
|
+
success: boolean;
|
|
61
|
+
element?: ServerElement;
|
|
62
|
+
elements?: ServerElement[];
|
|
63
|
+
message?: string;
|
|
64
|
+
error?: string;
|
|
65
|
+
count?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SyncResponse {
|
|
69
|
+
element?: ServerElement;
|
|
70
|
+
elements?: ServerElement[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Helper functions to sync with Express server (canvas) or remote GitHub target
|
|
74
|
+
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
|
|
75
|
+
const tgt = await import('./target.js');
|
|
76
|
+
if (tgt.isRemote()) {
|
|
77
|
+
try {
|
|
78
|
+
if (operation === 'create') return { element: tgt.rCreate(data as ServerElement) };
|
|
79
|
+
if (operation === 'update') return { element: tgt.rUpdate((data as any).id, data) };
|
|
80
|
+
if (operation === 'delete') { tgt.rDelete((data as any).id); return {}; }
|
|
81
|
+
if (operation === 'batch_create') return { elements: tgt.rBatch(data as ServerElement[]) };
|
|
82
|
+
} catch (e) { logger.warn(`Remote sync failed for ${operation}:`, (e as Error).message); return null; }
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!ENABLE_CANVAS_SYNC) {
|
|
86
|
+
logger.debug('Canvas sync disabled, skipping');
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
let url: string;
|
|
92
|
+
let options: any;
|
|
93
|
+
|
|
94
|
+
switch (operation) {
|
|
95
|
+
case 'create':
|
|
96
|
+
url = `${activeCanvasUrl()}/api/elements`;
|
|
97
|
+
options = {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify(data)
|
|
101
|
+
};
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case 'update':
|
|
105
|
+
url = `${activeCanvasUrl()}/api/elements/${data.id}`;
|
|
106
|
+
options = {
|
|
107
|
+
method: 'PUT',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(data)
|
|
110
|
+
};
|
|
111
|
+
break;
|
|
112
|
+
|
|
113
|
+
case 'delete':
|
|
114
|
+
url = `${activeCanvasUrl()}/api/elements/${data.id}`;
|
|
115
|
+
options = { method: 'DELETE' };
|
|
116
|
+
break;
|
|
117
|
+
|
|
118
|
+
case 'batch_create':
|
|
119
|
+
url = `${activeCanvasUrl()}/api/elements/batch`;
|
|
120
|
+
options = {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({ elements: data })
|
|
124
|
+
};
|
|
125
|
+
break;
|
|
126
|
+
|
|
127
|
+
default:
|
|
128
|
+
logger.warn(`Unknown sync operation: ${operation}`);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
logger.debug(`Syncing to canvas: ${operation}`, { url, data });
|
|
133
|
+
const response = await fetch(url, options);
|
|
134
|
+
|
|
135
|
+
// Parse JSON response regardless of HTTP status
|
|
136
|
+
const result = await response.json() as ApiResponse;
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
logger.warn(`Canvas sync returned error status: ${response.status}`, result);
|
|
140
|
+
throw new Error(result.error || `Canvas sync failed: ${response.status} ${response.statusText}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
logger.debug(`Canvas sync successful: ${operation}`, result);
|
|
144
|
+
return result as SyncResponse;
|
|
145
|
+
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message);
|
|
148
|
+
// Don't throw - we want MCP operations to work even if canvas is unavailable
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Helper to sync element creation to canvas
|
|
154
|
+
async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
|
|
155
|
+
const result = await syncToCanvas('create', elementData);
|
|
156
|
+
return result?.element || elementData;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Helper to sync element update to canvas
|
|
160
|
+
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
|
|
161
|
+
const result = await syncToCanvas('update', elementData);
|
|
162
|
+
return result?.element || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Helper to sync element deletion to canvas
|
|
166
|
+
async function deleteElementOnCanvas(elementId: string): Promise<any> {
|
|
167
|
+
const result = await syncToCanvas('delete', { id: elementId });
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Helper to sync batch creation to canvas
|
|
172
|
+
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
|
|
173
|
+
const result = await syncToCanvas('batch_create', elementsData);
|
|
174
|
+
return result?.elements || elementsData;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Helper to fetch element from canvas
|
|
178
|
+
async function getElementFromCanvas(elementId: string): Promise<ServerElement | null> {
|
|
179
|
+
const tgt = await import('./target.js');
|
|
180
|
+
if (tgt.isRemote()) {
|
|
181
|
+
try { return tgt.rGet(elementId); } catch { return null; }
|
|
182
|
+
}
|
|
183
|
+
if (!ENABLE_CANVAS_SYNC) {
|
|
184
|
+
logger.debug('Canvas sync disabled, skipping fetch');
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetch(`${activeCanvasUrl()}/api/elements/${elementId}`);
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const data = await response.json() as { element?: ServerElement };
|
|
195
|
+
return data.element || null;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.error('Error fetching element from canvas:', error);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// In-memory storage for scene state
|
|
203
|
+
interface SceneState {
|
|
204
|
+
theme: string;
|
|
205
|
+
viewport: { x: number; y: number; zoom: number };
|
|
206
|
+
selectedElements: Set<string>;
|
|
207
|
+
groups: Map<string, string[]>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sceneState: SceneState = {
|
|
211
|
+
theme: 'light',
|
|
212
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
213
|
+
selectedElements: new Set(),
|
|
214
|
+
groups: new Map()
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Points schema: accept both {x, y} objects and [x, y] tuples
|
|
218
|
+
const PointObjectSchema = z.object({ x: z.number(), y: z.number() });
|
|
219
|
+
const PointTupleSchema = z.tuple([z.number(), z.number()]);
|
|
220
|
+
const PointSchema = z.union([PointObjectSchema, PointTupleSchema]);
|
|
221
|
+
|
|
222
|
+
// Normalize points to [x, y] tuple format that Excalidraw expects
|
|
223
|
+
function normalizePoints(points: Array<{ x: number; y: number } | [number, number]>): [number, number][] {
|
|
224
|
+
return points.map(p => {
|
|
225
|
+
if (Array.isArray(p)) return p as [number, number];
|
|
226
|
+
return [p.x, p.y] as [number, number];
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Schema definitions using zod
|
|
231
|
+
const ElementSchema = z.object({
|
|
232
|
+
id: z.string().optional(),
|
|
233
|
+
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
|
|
234
|
+
x: z.number(),
|
|
235
|
+
y: z.number(),
|
|
236
|
+
width: z.number().optional(),
|
|
237
|
+
height: z.number().optional(),
|
|
238
|
+
points: z.array(PointSchema).optional(),
|
|
239
|
+
backgroundColor: z.string().optional(),
|
|
240
|
+
strokeColor: z.string().optional(),
|
|
241
|
+
strokeWidth: z.number().optional(),
|
|
242
|
+
roughness: z.number().optional(),
|
|
243
|
+
opacity: z.number().optional(),
|
|
244
|
+
text: z.string().optional(),
|
|
245
|
+
fontSize: z.number().optional(),
|
|
246
|
+
fontFamily: z.union([z.string(), z.number()]).optional(),
|
|
247
|
+
groupIds: z.array(z.string()).optional(),
|
|
248
|
+
locked: z.boolean().optional(),
|
|
249
|
+
strokeStyle: z.string().optional(),
|
|
250
|
+
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
|
|
251
|
+
fillStyle: z.string().optional(),
|
|
252
|
+
elbowed: z.boolean().optional(),
|
|
253
|
+
startElementId: z.string().optional(),
|
|
254
|
+
endElementId: z.string().optional(),
|
|
255
|
+
endArrowhead: z.string().optional(),
|
|
256
|
+
startArrowhead: z.string().optional(),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const ElementIdSchema = z.object({
|
|
260
|
+
id: z.string()
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const ElementIdsSchema = z.object({
|
|
264
|
+
elementIds: z.array(z.string())
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const GroupIdSchema = z.object({
|
|
268
|
+
groupId: z.string()
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const AlignElementsSchema = z.object({
|
|
272
|
+
elementIds: z.array(z.string()),
|
|
273
|
+
alignment: z.enum(['left', 'center', 'right', 'top', 'middle', 'bottom'])
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const DistributeElementsSchema = z.object({
|
|
277
|
+
elementIds: z.array(z.string()),
|
|
278
|
+
direction: z.enum(['horizontal', 'vertical'])
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const QuerySchema = z.object({
|
|
282
|
+
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
|
|
283
|
+
filter: z.record(z.any()).optional(),
|
|
284
|
+
bbox: z.object({
|
|
285
|
+
x_min: z.number().optional(),
|
|
286
|
+
x_max: z.number().optional(),
|
|
287
|
+
y_min: z.number().optional(),
|
|
288
|
+
y_max: z.number().optional()
|
|
289
|
+
}).optional()
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const ResourceSchema = z.object({
|
|
293
|
+
resource: z.enum(['scene', 'library', 'theme', 'elements'])
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Diagram design guide — injected into LLM context via read_diagram_guide tool
|
|
297
|
+
const DIAGRAM_DESIGN_GUIDE = `# Excalidraw Diagram Design Guide
|
|
298
|
+
|
|
299
|
+
## Color Palette
|
|
300
|
+
|
|
301
|
+
### Stroke Colors (use for borders & text)
|
|
302
|
+
| Name | Hex | Use for |
|
|
303
|
+
|---------|-----------|-----------------------------|
|
|
304
|
+
| Black | #1e1e1e | Default text & borders |
|
|
305
|
+
| Red | #e03131 | Errors, warnings, critical |
|
|
306
|
+
| Green | #2f9e44 | Success, approved, healthy |
|
|
307
|
+
| Blue | #1971c2 | Primary actions, links |
|
|
308
|
+
| Purple | #9c36b5 | Services, middleware |
|
|
309
|
+
| Orange | #e8590c | Async, queues, events |
|
|
310
|
+
| Cyan | #0c8599 | Data stores, databases |
|
|
311
|
+
| Gray | #868e96 | Annotations, secondary |
|
|
312
|
+
|
|
313
|
+
### Fill Colors (use for backgroundColor — pastel fills)
|
|
314
|
+
| Name | Hex | Pairs with stroke |
|
|
315
|
+
|--------------|-----------|-------------------|
|
|
316
|
+
| Light Red | #ffc9c9 | #e03131 |
|
|
317
|
+
| Light Green | #b2f2bb | #2f9e44 |
|
|
318
|
+
| Light Blue | #a5d8ff | #1971c2 |
|
|
319
|
+
| Light Purple | #eebefa | #9c36b5 |
|
|
320
|
+
| Light Orange | #ffd8a8 | #e8590c |
|
|
321
|
+
| Light Cyan | #99e9f2 | #0c8599 |
|
|
322
|
+
| Light Gray | #e9ecef | #868e96 |
|
|
323
|
+
| White | #ffffff | #1e1e1e |
|
|
324
|
+
|
|
325
|
+
## Sizing Rules
|
|
326
|
+
|
|
327
|
+
- **Minimum shape size**: width >= 120px, height >= 60px
|
|
328
|
+
- **Font sizes**: body text >= 16, titles/headers >= 20, small labels >= 14
|
|
329
|
+
- **Padding**: leave at least 20px inside shapes for text breathing room
|
|
330
|
+
- **Arrow length**: minimum 80px between connected shapes
|
|
331
|
+
- **Consistent sizing**: keep same-role shapes identical dimensions
|
|
332
|
+
|
|
333
|
+
## Layout Patterns
|
|
334
|
+
|
|
335
|
+
- **Grid snap**: align to 20px grid for clean layouts
|
|
336
|
+
- **Spacing**: 40–80px gap between adjacent shapes
|
|
337
|
+
- **Flow direction**: top-to-bottom (vertical) or left-to-right (horizontal)
|
|
338
|
+
- **Hierarchy**: important nodes larger or higher; left-to-right = temporal order
|
|
339
|
+
- **Grouping**: cluster related elements visually; use background rectangles as zones
|
|
340
|
+
|
|
341
|
+
## Arrow Binding Best Practices
|
|
342
|
+
|
|
343
|
+
- **Always bind**: use \`startElementId\` / \`endElementId\` to connect arrows to shapes
|
|
344
|
+
- **Dashed arrows**: use \`strokeStyle: "dashed"\` for async, optional, or event flows
|
|
345
|
+
- **Dotted arrows**: use \`strokeStyle: "dotted"\` for weak dependencies or annotations
|
|
346
|
+
- **Arrowheads**: default "arrow" for directed flow; "dot" for data stores; null for lines
|
|
347
|
+
- **Label arrows**: set \`text\` on arrows to describe the relationship (e.g., "HTTP", "publishes")
|
|
348
|
+
|
|
349
|
+
## Diagram Type Templates
|
|
350
|
+
|
|
351
|
+
### Architecture Diagram
|
|
352
|
+
- Shapes: 160×80 rectangles for services, 120×60 for small components
|
|
353
|
+
- Colors: different fill per layer (frontend=blue, backend=purple, data=cyan)
|
|
354
|
+
- Arrows: solid for sync calls, dashed for async/events
|
|
355
|
+
- Zones: large light-gray background rectangles with 20px fontSize labels
|
|
356
|
+
|
|
357
|
+
### Flowchart
|
|
358
|
+
- Shapes: 140×70 rectangles for steps, 100×100 diamonds for decisions
|
|
359
|
+
- Flow: top-to-bottom, 60px vertical spacing
|
|
360
|
+
- Colors: green start, red end, blue for process steps
|
|
361
|
+
- Arrows: solid, with "Yes"/"No" labels from diamonds
|
|
362
|
+
|
|
363
|
+
### ER Diagram
|
|
364
|
+
- Shapes: 180×40 per entity (wider for attribute lists)
|
|
365
|
+
- Layout: 80px between entities
|
|
366
|
+
- Arrows: use start/end arrowheads to show cardinality
|
|
367
|
+
- Colors: light-blue fill for entities, no fill for junction tables
|
|
368
|
+
|
|
369
|
+
## Anti-Patterns to Avoid
|
|
370
|
+
|
|
371
|
+
1. **Overlapping elements** — always leave gaps; use distribute_elements
|
|
372
|
+
2. **Cramped spacing** — minimum 40px between shapes
|
|
373
|
+
3. **Tiny fonts** — never below 14px; prefer 16+
|
|
374
|
+
4. **Manual arrow coordinates** — always use startElementId/endElementId binding
|
|
375
|
+
5. **Too many colors** — limit to 3–4 fill colors per diagram
|
|
376
|
+
6. **Inconsistent sizes** — same-role shapes should be same width/height
|
|
377
|
+
7. **No labels** — every shape and meaningful arrow should have text
|
|
378
|
+
8. **Flat layouts** — use zones/groups to create visual hierarchy
|
|
379
|
+
|
|
380
|
+
## Drawing Order (Recommended)
|
|
381
|
+
|
|
382
|
+
1. **Background zones** — large rectangles with light fill, low opacity
|
|
383
|
+
2. **Primary shapes** — services, entities, steps (with labels via \`text\`)
|
|
384
|
+
3. **Arrows** — connect shapes using binding IDs
|
|
385
|
+
4. **Annotations** — standalone text elements for notes, titles
|
|
386
|
+
5. **Refinement** — align, distribute, adjust spacing, screenshot to verify
|
|
387
|
+
`;
|
|
388
|
+
|
|
389
|
+
// Tool definitions
|
|
390
|
+
const tools: Tool[] = [
|
|
391
|
+
{
|
|
392
|
+
name: 'create_element',
|
|
393
|
+
description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges).',
|
|
394
|
+
inputSchema: {
|
|
395
|
+
type: 'object',
|
|
396
|
+
properties: {
|
|
397
|
+
id: { type: 'string', description: 'Custom element ID (optional, auto-generated if omitted). Use with startElementId/endElementId in batch_create_elements.' },
|
|
398
|
+
type: {
|
|
399
|
+
type: 'string',
|
|
400
|
+
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
|
401
|
+
},
|
|
402
|
+
x: { type: 'number' },
|
|
403
|
+
y: { type: 'number' },
|
|
404
|
+
width: { type: 'number' },
|
|
405
|
+
height: { type: 'number' },
|
|
406
|
+
backgroundColor: { type: 'string' },
|
|
407
|
+
strokeColor: { type: 'string' },
|
|
408
|
+
strokeWidth: { type: 'number' },
|
|
409
|
+
strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' },
|
|
410
|
+
roughness: { type: 'number' },
|
|
411
|
+
opacity: { type: 'number' },
|
|
412
|
+
text: { type: 'string' },
|
|
413
|
+
fontSize: { type: 'number' },
|
|
414
|
+
fontFamily: { type: ['string', 'number'], description: 'Font family: virgil/hand/handwritten (1), helvetica/sans/sans-serif (2), cascadia/mono/monospace (3), excalifont (5), nunito (6), lilita/lilita one (7), comic shanns/comic (8), or numeric ID' },
|
|
415
|
+
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
|
|
416
|
+
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
|
|
417
|
+
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
|
418
|
+
startArrowhead: { type: 'string', description: 'Arrowhead style at start: arrow, bar, dot, triangle, or null' }
|
|
419
|
+
},
|
|
420
|
+
required: ['type', 'x', 'y']
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'update_element',
|
|
425
|
+
description: 'Update an existing Excalidraw element',
|
|
426
|
+
inputSchema: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
properties: {
|
|
429
|
+
id: { type: 'string' },
|
|
430
|
+
type: {
|
|
431
|
+
type: 'string',
|
|
432
|
+
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
|
433
|
+
},
|
|
434
|
+
x: { type: 'number' },
|
|
435
|
+
y: { type: 'number' },
|
|
436
|
+
width: { type: 'number' },
|
|
437
|
+
height: { type: 'number' },
|
|
438
|
+
backgroundColor: { type: 'string' },
|
|
439
|
+
strokeColor: { type: 'string' },
|
|
440
|
+
strokeWidth: { type: 'number' },
|
|
441
|
+
strokeStyle: { type: 'string' },
|
|
442
|
+
roughness: { type: 'number' },
|
|
443
|
+
opacity: { type: 'number' },
|
|
444
|
+
text: { type: 'string' },
|
|
445
|
+
fontSize: { type: 'number' },
|
|
446
|
+
fontFamily: { type: ['string', 'number'], description: 'Font family: virgil/hand/handwritten (1), helvetica/sans/sans-serif (2), cascadia/mono/monospace (3), excalifont (5), nunito (6), lilita/lilita one (7), comic shanns/comic (8), or numeric ID' }
|
|
447
|
+
},
|
|
448
|
+
required: ['id']
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: 'delete_element',
|
|
453
|
+
description: 'Delete an Excalidraw element',
|
|
454
|
+
inputSchema: {
|
|
455
|
+
type: 'object',
|
|
456
|
+
properties: {
|
|
457
|
+
id: { type: 'string' }
|
|
458
|
+
},
|
|
459
|
+
required: ['id']
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
name: 'query_elements',
|
|
464
|
+
description: 'Query Excalidraw elements with optional filters',
|
|
465
|
+
inputSchema: {
|
|
466
|
+
type: 'object',
|
|
467
|
+
properties: {
|
|
468
|
+
type: {
|
|
469
|
+
type: 'string',
|
|
470
|
+
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
|
471
|
+
},
|
|
472
|
+
filter: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
additionalProperties: true
|
|
475
|
+
},
|
|
476
|
+
bbox: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
description: 'Bounding box filter — only return elements whose origin (x, y) falls within the given coordinate range',
|
|
479
|
+
properties: {
|
|
480
|
+
x_min: { type: 'number' },
|
|
481
|
+
x_max: { type: 'number' },
|
|
482
|
+
y_min: { type: 'number' },
|
|
483
|
+
y_max: { type: 'number' }
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: 'get_resource',
|
|
491
|
+
description: 'Get an Excalidraw resource',
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: 'object',
|
|
494
|
+
properties: {
|
|
495
|
+
resource: {
|
|
496
|
+
type: 'string',
|
|
497
|
+
enum: ['scene', 'library', 'theme', 'elements']
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
required: ['resource']
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: 'group_elements',
|
|
505
|
+
description: 'Group multiple elements together',
|
|
506
|
+
inputSchema: {
|
|
507
|
+
type: 'object',
|
|
508
|
+
properties: {
|
|
509
|
+
elementIds: {
|
|
510
|
+
type: 'array',
|
|
511
|
+
items: { type: 'string' }
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
required: ['elementIds']
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
name: 'ungroup_elements',
|
|
519
|
+
description: 'Ungroup a group of elements',
|
|
520
|
+
inputSchema: {
|
|
521
|
+
type: 'object',
|
|
522
|
+
properties: {
|
|
523
|
+
groupId: { type: 'string' }
|
|
524
|
+
},
|
|
525
|
+
required: ['groupId']
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: 'align_elements',
|
|
530
|
+
description: 'Align elements to a specific position',
|
|
531
|
+
inputSchema: {
|
|
532
|
+
type: 'object',
|
|
533
|
+
properties: {
|
|
534
|
+
elementIds: {
|
|
535
|
+
type: 'array',
|
|
536
|
+
items: { type: 'string' }
|
|
537
|
+
},
|
|
538
|
+
alignment: {
|
|
539
|
+
type: 'string',
|
|
540
|
+
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
required: ['elementIds', 'alignment']
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
name: 'distribute_elements',
|
|
548
|
+
description: 'Distribute elements evenly',
|
|
549
|
+
inputSchema: {
|
|
550
|
+
type: 'object',
|
|
551
|
+
properties: {
|
|
552
|
+
elementIds: {
|
|
553
|
+
type: 'array',
|
|
554
|
+
items: { type: 'string' }
|
|
555
|
+
},
|
|
556
|
+
direction: {
|
|
557
|
+
type: 'string',
|
|
558
|
+
enum: ['horizontal', 'vertical']
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
required: ['elementIds', 'direction']
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
name: 'lock_elements',
|
|
566
|
+
description: 'Lock elements to prevent modification',
|
|
567
|
+
inputSchema: {
|
|
568
|
+
type: 'object',
|
|
569
|
+
properties: {
|
|
570
|
+
elementIds: {
|
|
571
|
+
type: 'array',
|
|
572
|
+
items: { type: 'string' }
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
required: ['elementIds']
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
name: 'unlock_elements',
|
|
580
|
+
description: 'Unlock elements to allow modification',
|
|
581
|
+
inputSchema: {
|
|
582
|
+
type: 'object',
|
|
583
|
+
properties: {
|
|
584
|
+
elementIds: {
|
|
585
|
+
type: 'array',
|
|
586
|
+
items: { type: 'string' }
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
required: ['elementIds']
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
name: 'create_from_mermaid',
|
|
594
|
+
description: 'Convert a Mermaid diagram to Excalidraw elements and render them on the canvas',
|
|
595
|
+
inputSchema: {
|
|
596
|
+
type: 'object',
|
|
597
|
+
properties: {
|
|
598
|
+
mermaidDiagram: {
|
|
599
|
+
type: 'string',
|
|
600
|
+
description: 'The Mermaid diagram definition (e.g., "graph TD; A-->B; B-->C;")'
|
|
601
|
+
},
|
|
602
|
+
config: {
|
|
603
|
+
type: 'object',
|
|
604
|
+
description: 'Optional Mermaid configuration',
|
|
605
|
+
properties: {
|
|
606
|
+
startOnLoad: { type: 'boolean' },
|
|
607
|
+
flowchart: {
|
|
608
|
+
type: 'object',
|
|
609
|
+
properties: {
|
|
610
|
+
curve: { type: 'string', enum: ['linear', 'basis'] }
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
themeVariables: {
|
|
614
|
+
type: 'object',
|
|
615
|
+
properties: {
|
|
616
|
+
fontSize: { type: 'string' }
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
maxEdges: { type: 'number' },
|
|
620
|
+
maxTextSize: { type: 'number' }
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
required: ['mermaidDiagram']
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
name: 'batch_create_elements',
|
|
629
|
+
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.',
|
|
630
|
+
inputSchema: {
|
|
631
|
+
type: 'object',
|
|
632
|
+
properties: {
|
|
633
|
+
elements: {
|
|
634
|
+
type: 'array',
|
|
635
|
+
items: {
|
|
636
|
+
type: 'object',
|
|
637
|
+
properties: {
|
|
638
|
+
id: { type: 'string', description: 'Custom element ID. Arrows can reference this via startElementId/endElementId.' },
|
|
639
|
+
type: {
|
|
640
|
+
type: 'string',
|
|
641
|
+
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
|
642
|
+
},
|
|
643
|
+
x: { type: 'number' },
|
|
644
|
+
y: { type: 'number' },
|
|
645
|
+
width: { type: 'number' },
|
|
646
|
+
height: { type: 'number' },
|
|
647
|
+
backgroundColor: { type: 'string' },
|
|
648
|
+
strokeColor: { type: 'string' },
|
|
649
|
+
strokeWidth: { type: 'number' },
|
|
650
|
+
strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' },
|
|
651
|
+
roughness: { type: 'number' },
|
|
652
|
+
opacity: { type: 'number' },
|
|
653
|
+
text: { type: 'string' },
|
|
654
|
+
fontSize: { type: 'number' },
|
|
655
|
+
fontFamily: { type: ['string', 'number'], description: 'Font family: virgil/hand/handwritten (1), helvetica/sans/sans-serif (2), cascadia/mono/monospace (3), excalifont (5), nunito (6), lilita/lilita one (7), comic shanns/comic (8), or numeric ID' },
|
|
656
|
+
startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
|
|
657
|
+
endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
|
|
658
|
+
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
|
659
|
+
startArrowhead: { type: 'string', description: 'Arrowhead style at start: arrow, bar, dot, triangle, or null' }
|
|
660
|
+
},
|
|
661
|
+
required: ['type', 'x', 'y']
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
required: ['elements']
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: 'get_element',
|
|
670
|
+
description: 'Get a single Excalidraw element by ID',
|
|
671
|
+
inputSchema: {
|
|
672
|
+
type: 'object',
|
|
673
|
+
properties: {
|
|
674
|
+
id: { type: 'string', description: 'The element ID' }
|
|
675
|
+
},
|
|
676
|
+
required: ['id']
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
name: 'clear_canvas',
|
|
681
|
+
description: 'Clear all elements from the canvas',
|
|
682
|
+
inputSchema: {
|
|
683
|
+
type: 'object',
|
|
684
|
+
properties: {}
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
name: 'export_scene',
|
|
689
|
+
description: 'Export the current canvas to .excalidraw JSON format. Optionally write to a file.',
|
|
690
|
+
inputSchema: {
|
|
691
|
+
type: 'object',
|
|
692
|
+
properties: {
|
|
693
|
+
filePath: {
|
|
694
|
+
type: 'string',
|
|
695
|
+
description: 'Optional file path to write the .excalidraw JSON file'
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: 'import_scene',
|
|
702
|
+
description: 'Import elements from a .excalidraw JSON file or raw JSON data',
|
|
703
|
+
inputSchema: {
|
|
704
|
+
type: 'object',
|
|
705
|
+
properties: {
|
|
706
|
+
filePath: {
|
|
707
|
+
type: 'string',
|
|
708
|
+
description: 'Path to a .excalidraw JSON file'
|
|
709
|
+
},
|
|
710
|
+
data: {
|
|
711
|
+
type: 'string',
|
|
712
|
+
description: 'Raw .excalidraw JSON string (alternative to filePath)'
|
|
713
|
+
},
|
|
714
|
+
mode: {
|
|
715
|
+
type: 'string',
|
|
716
|
+
enum: ['replace', 'merge'],
|
|
717
|
+
description: '"replace" clears canvas first, "merge" appends to existing elements'
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
required: ['mode']
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
name: 'export_to_image',
|
|
725
|
+
description: 'Export the current canvas to PNG or SVG image. Requires the canvas frontend to be open in a browser.',
|
|
726
|
+
inputSchema: {
|
|
727
|
+
type: 'object',
|
|
728
|
+
properties: {
|
|
729
|
+
format: {
|
|
730
|
+
type: 'string',
|
|
731
|
+
enum: ['png', 'svg'],
|
|
732
|
+
description: 'Image format'
|
|
733
|
+
},
|
|
734
|
+
filePath: {
|
|
735
|
+
type: 'string',
|
|
736
|
+
description: 'Optional file path to save the image'
|
|
737
|
+
},
|
|
738
|
+
background: {
|
|
739
|
+
type: 'boolean',
|
|
740
|
+
description: 'Include background in export (default: true)'
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
required: ['format']
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: 'duplicate_elements',
|
|
748
|
+
description: 'Duplicate elements with a configurable offset',
|
|
749
|
+
inputSchema: {
|
|
750
|
+
type: 'object',
|
|
751
|
+
properties: {
|
|
752
|
+
elementIds: {
|
|
753
|
+
type: 'array',
|
|
754
|
+
items: { type: 'string' },
|
|
755
|
+
description: 'IDs of elements to duplicate'
|
|
756
|
+
},
|
|
757
|
+
offsetX: { type: 'number', description: 'Horizontal offset (default: 20)' },
|
|
758
|
+
offsetY: { type: 'number', description: 'Vertical offset (default: 20)' }
|
|
759
|
+
},
|
|
760
|
+
required: ['elementIds']
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
name: 'snapshot_scene',
|
|
765
|
+
description: 'Save a named snapshot of the current canvas state for later restoration',
|
|
766
|
+
inputSchema: {
|
|
767
|
+
type: 'object',
|
|
768
|
+
properties: {
|
|
769
|
+
name: {
|
|
770
|
+
type: 'string',
|
|
771
|
+
description: 'Name for this snapshot'
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
required: ['name']
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
name: 'restore_snapshot',
|
|
779
|
+
description: 'Restore the canvas from a previously saved named snapshot',
|
|
780
|
+
inputSchema: {
|
|
781
|
+
type: 'object',
|
|
782
|
+
properties: {
|
|
783
|
+
name: {
|
|
784
|
+
type: 'string',
|
|
785
|
+
description: 'Name of the snapshot to restore'
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
required: ['name']
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
name: 'describe_scene',
|
|
793
|
+
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.',
|
|
794
|
+
inputSchema: {
|
|
795
|
+
type: 'object',
|
|
796
|
+
properties: {}
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
name: 'get_canvas_screenshot',
|
|
801
|
+
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.',
|
|
802
|
+
inputSchema: {
|
|
803
|
+
type: 'object',
|
|
804
|
+
properties: {
|
|
805
|
+
background: {
|
|
806
|
+
type: 'boolean',
|
|
807
|
+
description: 'Include background in screenshot (default: true)'
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
name: 'read_diagram_guide',
|
|
814
|
+
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.',
|
|
815
|
+
inputSchema: {
|
|
816
|
+
type: 'object',
|
|
817
|
+
properties: {}
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
name: 'export_to_excalidraw_url',
|
|
822
|
+
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.',
|
|
823
|
+
inputSchema: {
|
|
824
|
+
type: 'object',
|
|
825
|
+
properties: {}
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
name: 'list_canvases',
|
|
830
|
+
description: 'List running local excalidrop canvases by probing ports 3030-3230 plus the current canvas. Use to let Claude switch between canvases.',
|
|
831
|
+
inputSchema: { type: 'object', properties: {} }
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
name: 'switch_canvas',
|
|
835
|
+
description: 'Switch this MCP session to a different running canvas. Pass port (e.g. 3031) or url. Persists via EXPRESS_SERVER_URL for subsequent tool calls.',
|
|
836
|
+
inputSchema: {
|
|
837
|
+
type: 'object',
|
|
838
|
+
properties: {
|
|
839
|
+
port: { type: 'number', description: 'Canvas port' },
|
|
840
|
+
url: { type: 'string', description: 'Canvas URL (alternative to port)' }
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
name: 'current_canvas',
|
|
846
|
+
description: 'Show the canvas URL this MCP session is currently pointed at.',
|
|
847
|
+
inputSchema: { type: 'object', properties: {} }
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
name: 'switch_remote',
|
|
851
|
+
description: 'Switch this MCP session between the local canvas and a remote GitHub-backed canvas. Pass a Pages URL (https://<owner>.github.io/<repo>/) or owner/repo to edit that repo\'s canvas (commits to GitHub, viewer updates on gh-pages). Omit target to switch back to local.',
|
|
852
|
+
inputSchema: {
|
|
853
|
+
type: 'object',
|
|
854
|
+
properties: {
|
|
855
|
+
target: { type: 'string', description: 'Pages URL or owner/repo. Omit for local canvas.' }
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
name: 'github_login',
|
|
861
|
+
description: 'Log in to GitHub via device flow (2FA enforced by GitHub). Step 1 (no args): returns a code + URL for the user to approve. Step 2 (with device_code): polls until approved, stores the token, returns a Pages #token= link the user can open to edit in the browser.',
|
|
862
|
+
inputSchema: {
|
|
863
|
+
type: 'object',
|
|
864
|
+
properties: {
|
|
865
|
+
device_code: { type: 'string', description: 'From step 1 — poll until the user approves.' },
|
|
866
|
+
interval: { type: 'number', description: 'Poll interval from step 1.' },
|
|
867
|
+
site: { type: 'string', description: 'Pages URL to build the login link for.' },
|
|
868
|
+
repo: { type: 'string', description: 'Restrict the token to one repo (owner/repo).' }
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
name: 'commit_scene',
|
|
874
|
+
description: 'Immediately commit the current remote scene to GitHub (otherwise autosyncs ~10s after last edit). Local mode: no-op.',
|
|
875
|
+
inputSchema: {
|
|
876
|
+
type: 'object',
|
|
877
|
+
properties: {
|
|
878
|
+
message: { type: 'string', description: 'Commit message' }
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
name: 'set_viewport',
|
|
884
|
+
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.',
|
|
885
|
+
inputSchema: {
|
|
886
|
+
type: 'object',
|
|
887
|
+
properties: {
|
|
888
|
+
scrollToContent: {
|
|
889
|
+
type: 'boolean',
|
|
890
|
+
description: 'Auto-fit all elements in view (zoom-to-fit)'
|
|
891
|
+
},
|
|
892
|
+
scrollToElementId: {
|
|
893
|
+
type: 'string',
|
|
894
|
+
description: 'Center the view on a specific element by ID'
|
|
895
|
+
},
|
|
896
|
+
zoom: {
|
|
897
|
+
type: 'number',
|
|
898
|
+
description: 'Zoom level (0.1–10, where 1 = 100%)'
|
|
899
|
+
},
|
|
900
|
+
offsetX: {
|
|
901
|
+
type: 'number',
|
|
902
|
+
description: 'Horizontal scroll offset'
|
|
903
|
+
},
|
|
904
|
+
offsetY: {
|
|
905
|
+
type: 'number',
|
|
906
|
+
description: 'Vertical scroll offset'
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
];
|
|
912
|
+
|
|
913
|
+
// Initialize MCP server
|
|
914
|
+
const server = new Server(
|
|
915
|
+
{
|
|
916
|
+
name: "mcp-excalidraw-server",
|
|
917
|
+
version: "2.0.0",
|
|
918
|
+
description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
capabilities: {
|
|
922
|
+
tools: Object.fromEntries(tools.map(tool => [tool.name, {
|
|
923
|
+
description: tool.description,
|
|
924
|
+
inputSchema: tool.inputSchema
|
|
925
|
+
}]))
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
// Helper function to convert text property to label format for Excalidraw
|
|
931
|
+
function convertTextToLabel(element: ServerElement): ServerElement {
|
|
932
|
+
const { text, ...rest } = element;
|
|
933
|
+
if (text) {
|
|
934
|
+
// For standalone text elements, keep text as direct property
|
|
935
|
+
if (element.type === 'text') {
|
|
936
|
+
return element; // Keep text as direct property
|
|
937
|
+
}
|
|
938
|
+
// For other elements (rectangle, ellipse, diamond), convert to label format
|
|
939
|
+
return {
|
|
940
|
+
...rest,
|
|
941
|
+
label: { text }
|
|
942
|
+
} as ServerElement;
|
|
943
|
+
}
|
|
944
|
+
return element;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Set up request handler for tool calls
|
|
948
|
+
let toolQueue: Promise<void> = Promise.resolve();
|
|
949
|
+
|
|
950
|
+
// Serialized: concurrent tool calls (e.g. switch+create+commit in one block)
|
|
951
|
+
// must not interleave against the remote scene store.
|
|
952
|
+
async function handleToolCall(request: CallToolRequest) {
|
|
953
|
+
try {
|
|
954
|
+
const { name, arguments: args } = request.params;
|
|
955
|
+
logger.info(`Handling tool call: ${name}`);
|
|
956
|
+
|
|
957
|
+
switch (name) {
|
|
958
|
+
case 'create_element': {
|
|
959
|
+
const params = ElementSchema.parse(args);
|
|
960
|
+
logger.info('Creating element via MCP', { type: params.type });
|
|
961
|
+
|
|
962
|
+
const { startElementId, endElementId, id: customId, ...elementProps } = params;
|
|
963
|
+
const id = customId || generateId();
|
|
964
|
+
const element: ServerElement = {
|
|
965
|
+
id,
|
|
966
|
+
...elementProps,
|
|
967
|
+
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
|
968
|
+
// Convert binding IDs to Excalidraw's start/end format
|
|
969
|
+
...(startElementId ? { start: { id: startElementId } } : {}),
|
|
970
|
+
...(endElementId ? { end: { id: endElementId } } : {}),
|
|
971
|
+
createdAt: new Date().toISOString(),
|
|
972
|
+
updatedAt: new Date().toISOString(),
|
|
973
|
+
version: 1
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// Normalize fontFamily from string names to numeric values
|
|
977
|
+
if (element.fontFamily !== undefined) {
|
|
978
|
+
element.fontFamily = normalizeFontFamily(element.fontFamily);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// For bound arrows without explicit points, set a default
|
|
982
|
+
if ((startElementId || endElementId) && !elementProps.points) {
|
|
983
|
+
(element as any).points = [[0, 0], [100, 0]];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Convert text to label format for Excalidraw
|
|
987
|
+
const excalidrawElement = convertTextToLabel(element);
|
|
988
|
+
|
|
989
|
+
// Create element directly on HTTP server (no local storage)
|
|
990
|
+
const canvasElement = await createElementOnCanvas(excalidrawElement);
|
|
991
|
+
|
|
992
|
+
if (!canvasElement) {
|
|
993
|
+
throw new Error('Failed to create element: HTTP server unavailable');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
logger.info('Element created via MCP and synced to canvas', {
|
|
997
|
+
id: excalidrawElement.id,
|
|
998
|
+
type: excalidrawElement.type,
|
|
999
|
+
synced: !!canvasElement
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
content: [{
|
|
1004
|
+
type: 'text',
|
|
1005
|
+
text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
|
|
1006
|
+
}]
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
case 'update_element': {
|
|
1011
|
+
const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args);
|
|
1012
|
+
const { id, points: rawPoints, ...updates } = params;
|
|
1013
|
+
|
|
1014
|
+
if (!id) throw new Error('Element ID is required');
|
|
1015
|
+
|
|
1016
|
+
// Build update payload with timestamp and version increment
|
|
1017
|
+
const updatePayload: Partial<ServerElement> & { id: string } = {
|
|
1018
|
+
id,
|
|
1019
|
+
...updates,
|
|
1020
|
+
points: rawPoints ? normalizePoints(rawPoints) : undefined,
|
|
1021
|
+
updatedAt: new Date().toISOString()
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
// Normalize fontFamily from string names to numeric values
|
|
1025
|
+
if (updatePayload.fontFamily !== undefined) {
|
|
1026
|
+
updatePayload.fontFamily = normalizeFontFamily(updatePayload.fontFamily);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Convert text to label format for Excalidraw
|
|
1030
|
+
const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
|
|
1031
|
+
|
|
1032
|
+
// Update element directly on HTTP server (no local storage)
|
|
1033
|
+
const canvasElement = await updateElementOnCanvas(excalidrawElement);
|
|
1034
|
+
|
|
1035
|
+
if (!canvasElement) {
|
|
1036
|
+
throw new Error('Failed to update element: HTTP server unavailable or element not found');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
logger.info('Element updated via MCP and synced to canvas', {
|
|
1040
|
+
id: excalidrawElement.id,
|
|
1041
|
+
synced: !!canvasElement
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
return {
|
|
1045
|
+
content: [{
|
|
1046
|
+
type: 'text',
|
|
1047
|
+
text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
|
|
1048
|
+
}]
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
case 'delete_element': {
|
|
1053
|
+
const params = ElementIdSchema.parse(args);
|
|
1054
|
+
const { id } = params;
|
|
1055
|
+
|
|
1056
|
+
// Delete element directly on HTTP server (no local storage)
|
|
1057
|
+
const canvasResult = await deleteElementOnCanvas(id);
|
|
1058
|
+
|
|
1059
|
+
if (!canvasResult || !(canvasResult as ApiResponse).success) {
|
|
1060
|
+
throw new Error('Failed to delete element: HTTP server unavailable or element not found');
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const result = { id, deleted: true, syncedToCanvas: true };
|
|
1064
|
+
logger.info('Element deleted via MCP and synced to canvas', result);
|
|
1065
|
+
|
|
1066
|
+
return {
|
|
1067
|
+
content: [{
|
|
1068
|
+
type: 'text',
|
|
1069
|
+
text: `Element deleted successfully!\n\n${JSON.stringify(result, null, 2)}\n\n✅ Synced to canvas`
|
|
1070
|
+
}]
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
case 'query_elements': {
|
|
1075
|
+
const params = QuerySchema.parse(args || {});
|
|
1076
|
+
const { type, filter, bbox } = params;
|
|
1077
|
+
|
|
1078
|
+
try {
|
|
1079
|
+
const tgt = await import('./target.js');
|
|
1080
|
+
if (tgt.isRemote()) {
|
|
1081
|
+
let results = tgt.rList(type);
|
|
1082
|
+
if (bbox) {
|
|
1083
|
+
results = results.filter(el =>
|
|
1084
|
+
(bbox.x_min === undefined || el.x >= bbox.x_min) &&
|
|
1085
|
+
(bbox.x_max === undefined || el.x <= bbox.x_max) &&
|
|
1086
|
+
(bbox.y_min === undefined || el.y >= bbox.y_min) &&
|
|
1087
|
+
(bbox.y_max === undefined || el.y <= bbox.y_max));
|
|
1088
|
+
}
|
|
1089
|
+
if (filter) results = results.filter(el => Object.entries(filter).every(([k, v]) => (el as any)[k] === v));
|
|
1090
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
1091
|
+
}
|
|
1092
|
+
// Build query parameters
|
|
1093
|
+
const queryParams = new URLSearchParams();
|
|
1094
|
+
if (type) queryParams.set('type', type);
|
|
1095
|
+
if (filter) {
|
|
1096
|
+
Object.entries(filter).forEach(([key, value]) => {
|
|
1097
|
+
queryParams.set(key, String(value));
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
if (bbox) {
|
|
1101
|
+
if (bbox.x_min !== undefined) queryParams.set('x_min', String(bbox.x_min));
|
|
1102
|
+
if (bbox.x_max !== undefined) queryParams.set('x_max', String(bbox.x_max));
|
|
1103
|
+
if (bbox.y_min !== undefined) queryParams.set('y_min', String(bbox.y_min));
|
|
1104
|
+
if (bbox.y_max !== undefined) queryParams.set('y_max', String(bbox.y_max));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Query elements from HTTP server
|
|
1108
|
+
const url = `${activeCanvasUrl()}/api/elements/search?${queryParams}`;
|
|
1109
|
+
const response = await fetch(url);
|
|
1110
|
+
|
|
1111
|
+
if (!response.ok) {
|
|
1112
|
+
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const data = await response.json() as ApiResponse;
|
|
1116
|
+
const results = data.elements || [];
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
|
|
1120
|
+
};
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
throw new Error(`Failed to query elements: ${(error as Error).message}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
case 'get_resource': {
|
|
1127
|
+
const params = ResourceSchema.parse(args);
|
|
1128
|
+
const { resource } = params;
|
|
1129
|
+
logger.info('Getting resource', { resource });
|
|
1130
|
+
|
|
1131
|
+
let result: any;
|
|
1132
|
+
switch (resource) {
|
|
1133
|
+
case 'scene':
|
|
1134
|
+
result = {
|
|
1135
|
+
theme: sceneState.theme,
|
|
1136
|
+
viewport: sceneState.viewport,
|
|
1137
|
+
selectedElements: Array.from(sceneState.selectedElements)
|
|
1138
|
+
};
|
|
1139
|
+
break;
|
|
1140
|
+
case 'library':
|
|
1141
|
+
case 'elements':
|
|
1142
|
+
try {
|
|
1143
|
+
const tgtRes = await import('./target.js');
|
|
1144
|
+
if (tgtRes.isRemote()) {
|
|
1145
|
+
result = { elements: tgtRes.rList() };
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
// Get elements from HTTP server
|
|
1149
|
+
const response = await fetch(`${activeCanvasUrl()}/api/elements`);
|
|
1150
|
+
if (!response.ok) {
|
|
1151
|
+
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
|
1152
|
+
}
|
|
1153
|
+
const data = await response.json() as ApiResponse;
|
|
1154
|
+
result = {
|
|
1155
|
+
elements: data.elements || []
|
|
1156
|
+
};
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
throw new Error(`Failed to get elements: ${(error as Error).message}`);
|
|
1159
|
+
}
|
|
1160
|
+
break;
|
|
1161
|
+
case 'theme':
|
|
1162
|
+
result = {
|
|
1163
|
+
theme: sceneState.theme
|
|
1164
|
+
};
|
|
1165
|
+
break;
|
|
1166
|
+
default:
|
|
1167
|
+
throw new Error(`Unknown resource: ${resource}`);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return {
|
|
1171
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
case 'group_elements': {
|
|
1176
|
+
const params = ElementIdsSchema.parse(args);
|
|
1177
|
+
const { elementIds } = params;
|
|
1178
|
+
|
|
1179
|
+
try {
|
|
1180
|
+
const groupId = generateId();
|
|
1181
|
+
sceneState.groups.set(groupId, elementIds);
|
|
1182
|
+
|
|
1183
|
+
// Update elements on canvas with proper error handling
|
|
1184
|
+
// Fetch existing groups and append new groupId to preserve multi-group membership
|
|
1185
|
+
const updatePromises = elementIds.map(async (id) => {
|
|
1186
|
+
const element = await getElementFromCanvas(id);
|
|
1187
|
+
const existingGroups = element?.groupIds || [];
|
|
1188
|
+
const updatedGroupIds = [...existingGroups, groupId];
|
|
1189
|
+
return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
const results = await Promise.all(updatePromises);
|
|
1193
|
+
const successCount = results.filter(result => result).length;
|
|
1194
|
+
|
|
1195
|
+
if (successCount === 0) {
|
|
1196
|
+
sceneState.groups.delete(groupId); // Rollback local state
|
|
1197
|
+
throw new Error('Failed to group any elements: HTTP server unavailable');
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
logger.info('Grouping elements', { elementIds, groupId, successCount });
|
|
1201
|
+
|
|
1202
|
+
const result = { groupId, elementIds, successCount };
|
|
1203
|
+
return {
|
|
1204
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1205
|
+
};
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
throw new Error(`Failed to group elements: ${(error as Error).message}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
case 'ungroup_elements': {
|
|
1212
|
+
const params = GroupIdSchema.parse(args);
|
|
1213
|
+
const { groupId } = params;
|
|
1214
|
+
|
|
1215
|
+
if (!sceneState.groups.has(groupId)) {
|
|
1216
|
+
throw new Error(`Group ${groupId} not found`);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
try {
|
|
1220
|
+
const elementIds = sceneState.groups.get(groupId);
|
|
1221
|
+
sceneState.groups.delete(groupId);
|
|
1222
|
+
|
|
1223
|
+
// Update elements on canvas, removing only this specific groupId
|
|
1224
|
+
const updatePromises = (elementIds ?? []).map(async (id) => {
|
|
1225
|
+
// Fetch current element to get existing groupIds
|
|
1226
|
+
const element = await getElementFromCanvas(id);
|
|
1227
|
+
if (!element) {
|
|
1228
|
+
logger.warn(`Element ${id} not found on canvas, skipping ungroup`);
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Remove only the specific groupId, preserve others
|
|
1233
|
+
const updatedGroupIds = (element.groupIds || []).filter(gid => gid !== groupId);
|
|
1234
|
+
return await updateElementOnCanvas({ id, groupIds: updatedGroupIds });
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
const results = await Promise.all(updatePromises);
|
|
1238
|
+
const successCount = results.filter(result => result !== null).length;
|
|
1239
|
+
|
|
1240
|
+
if (successCount === 0) {
|
|
1241
|
+
throw new Error('Failed to ungroup: no elements were updated (elements may not exist on canvas)');
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
logger.info('Ungrouping elements', { groupId, elementIds, successCount });
|
|
1245
|
+
|
|
1246
|
+
const result = { groupId, ungrouped: true, elementIds, successCount };
|
|
1247
|
+
return {
|
|
1248
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1249
|
+
};
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
throw new Error(`Failed to ungroup elements: ${(error as Error).message}`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
case 'align_elements': {
|
|
1256
|
+
const params = AlignElementsSchema.parse(args);
|
|
1257
|
+
const { elementIds, alignment } = params;
|
|
1258
|
+
logger.info('Aligning elements', { elementIds, alignment });
|
|
1259
|
+
|
|
1260
|
+
// Fetch all elements
|
|
1261
|
+
const elementsToAlign: ServerElement[] = [];
|
|
1262
|
+
for (const id of elementIds) {
|
|
1263
|
+
const el = await getElementFromCanvas(id);
|
|
1264
|
+
if (el) elementsToAlign.push(el);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (elementsToAlign.length < 2) {
|
|
1268
|
+
throw new Error('Need at least 2 elements to align');
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Calculate alignment target
|
|
1272
|
+
let updateFn: (el: ServerElement) => { x?: number; y?: number };
|
|
1273
|
+
switch (alignment) {
|
|
1274
|
+
case 'left': {
|
|
1275
|
+
const minX = Math.min(...elementsToAlign.map(el => el.x));
|
|
1276
|
+
updateFn = () => ({ x: minX });
|
|
1277
|
+
break;
|
|
1278
|
+
}
|
|
1279
|
+
case 'right': {
|
|
1280
|
+
const maxRight = Math.max(...elementsToAlign.map(el => el.x + (el.width || 0)));
|
|
1281
|
+
updateFn = (el) => ({ x: maxRight - (el.width || 0) });
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
case 'center': {
|
|
1285
|
+
const centers = elementsToAlign.map(el => el.x + (el.width || 0) / 2);
|
|
1286
|
+
const avgCenter = centers.reduce((a, b) => a + b, 0) / centers.length;
|
|
1287
|
+
updateFn = (el) => ({ x: avgCenter - (el.width || 0) / 2 });
|
|
1288
|
+
break;
|
|
1289
|
+
}
|
|
1290
|
+
case 'top': {
|
|
1291
|
+
const minY = Math.min(...elementsToAlign.map(el => el.y));
|
|
1292
|
+
updateFn = () => ({ y: minY });
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
case 'bottom': {
|
|
1296
|
+
const maxBottom = Math.max(...elementsToAlign.map(el => el.y + (el.height || 0)));
|
|
1297
|
+
updateFn = (el) => ({ y: maxBottom - (el.height || 0) });
|
|
1298
|
+
break;
|
|
1299
|
+
}
|
|
1300
|
+
case 'middle': {
|
|
1301
|
+
const middles = elementsToAlign.map(el => el.y + (el.height || 0) / 2);
|
|
1302
|
+
const avgMiddle = middles.reduce((a, b) => a + b, 0) / middles.length;
|
|
1303
|
+
updateFn = (el) => ({ y: avgMiddle - (el.height || 0) / 2 });
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Apply updates
|
|
1309
|
+
const updatePromises = elementsToAlign.map(async (el) => {
|
|
1310
|
+
const coords = updateFn(el);
|
|
1311
|
+
return await updateElementOnCanvas({ id: el.id, ...coords });
|
|
1312
|
+
});
|
|
1313
|
+
const results = await Promise.all(updatePromises);
|
|
1314
|
+
const successCount = results.filter(r => r).length;
|
|
1315
|
+
|
|
1316
|
+
if (successCount === 0) {
|
|
1317
|
+
throw new Error('Failed to align any elements: HTTP server unavailable');
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const result = { aligned: true, elementIds, alignment, successCount };
|
|
1321
|
+
return {
|
|
1322
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
case 'distribute_elements': {
|
|
1327
|
+
const params = DistributeElementsSchema.parse(args);
|
|
1328
|
+
const { elementIds, direction } = params;
|
|
1329
|
+
logger.info('Distributing elements', { elementIds, direction });
|
|
1330
|
+
|
|
1331
|
+
// Fetch all elements
|
|
1332
|
+
const elementsToDist: ServerElement[] = [];
|
|
1333
|
+
for (const id of elementIds) {
|
|
1334
|
+
const el = await getElementFromCanvas(id);
|
|
1335
|
+
if (el) elementsToDist.push(el);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if (elementsToDist.length < 3) {
|
|
1339
|
+
throw new Error('Need at least 3 elements to distribute');
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (direction === 'horizontal') {
|
|
1343
|
+
// Sort by x position
|
|
1344
|
+
elementsToDist.sort((a, b) => a.x - b.x);
|
|
1345
|
+
const first = elementsToDist[0]!;
|
|
1346
|
+
const last = elementsToDist[elementsToDist.length - 1]!;
|
|
1347
|
+
const totalSpan = (last.x + (last.width || 0)) - first.x;
|
|
1348
|
+
const totalElementWidth = elementsToDist.reduce((sum, el) => sum + (el.width || 0), 0);
|
|
1349
|
+
const gap = (totalSpan - totalElementWidth) / (elementsToDist.length - 1);
|
|
1350
|
+
|
|
1351
|
+
let currentX = first.x;
|
|
1352
|
+
for (const el of elementsToDist) {
|
|
1353
|
+
await updateElementOnCanvas({ id: el.id, x: currentX });
|
|
1354
|
+
currentX += (el.width || 0) + gap;
|
|
1355
|
+
}
|
|
1356
|
+
} else {
|
|
1357
|
+
// Sort by y position
|
|
1358
|
+
elementsToDist.sort((a, b) => a.y - b.y);
|
|
1359
|
+
const first = elementsToDist[0]!;
|
|
1360
|
+
const last = elementsToDist[elementsToDist.length - 1]!;
|
|
1361
|
+
const totalSpan = (last.y + (last.height || 0)) - first.y;
|
|
1362
|
+
const totalElementHeight = elementsToDist.reduce((sum, el) => sum + (el.height || 0), 0);
|
|
1363
|
+
const gap = (totalSpan - totalElementHeight) / (elementsToDist.length - 1);
|
|
1364
|
+
|
|
1365
|
+
let currentY = first.y;
|
|
1366
|
+
for (const el of elementsToDist) {
|
|
1367
|
+
await updateElementOnCanvas({ id: el.id, y: currentY });
|
|
1368
|
+
currentY += (el.height || 0) + gap;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const result = { distributed: true, elementIds, direction, count: elementsToDist.length };
|
|
1373
|
+
return {
|
|
1374
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
case 'lock_elements': {
|
|
1379
|
+
const params = ElementIdsSchema.parse(args);
|
|
1380
|
+
const { elementIds } = params;
|
|
1381
|
+
|
|
1382
|
+
try {
|
|
1383
|
+
// Lock elements through HTTP API updates
|
|
1384
|
+
const updatePromises = elementIds.map(async (id) => {
|
|
1385
|
+
return await updateElementOnCanvas({ id, locked: true });
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
const results = await Promise.all(updatePromises);
|
|
1389
|
+
const successCount = results.filter(result => result).length;
|
|
1390
|
+
|
|
1391
|
+
if (successCount === 0) {
|
|
1392
|
+
throw new Error('Failed to lock any elements: HTTP server unavailable');
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const result = { locked: true, elementIds, successCount };
|
|
1396
|
+
return {
|
|
1397
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1398
|
+
};
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
throw new Error(`Failed to lock elements: ${(error as Error).message}`);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
case 'unlock_elements': {
|
|
1405
|
+
const params = ElementIdsSchema.parse(args);
|
|
1406
|
+
const { elementIds } = params;
|
|
1407
|
+
|
|
1408
|
+
try {
|
|
1409
|
+
// Unlock elements through HTTP API updates
|
|
1410
|
+
const updatePromises = elementIds.map(async (id) => {
|
|
1411
|
+
return await updateElementOnCanvas({ id, locked: false });
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
const results = await Promise.all(updatePromises);
|
|
1415
|
+
const successCount = results.filter(result => result).length;
|
|
1416
|
+
|
|
1417
|
+
if (successCount === 0) {
|
|
1418
|
+
throw new Error('Failed to unlock any elements: HTTP server unavailable');
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const result = { unlocked: true, elementIds, successCount };
|
|
1422
|
+
return {
|
|
1423
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1424
|
+
};
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
throw new Error(`Failed to unlock elements: ${(error as Error).message}`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
case 'create_from_mermaid': {
|
|
1431
|
+
const params = z.object({
|
|
1432
|
+
mermaidDiagram: z.string(),
|
|
1433
|
+
config: z.object({
|
|
1434
|
+
startOnLoad: z.boolean().optional(),
|
|
1435
|
+
flowchart: z.object({
|
|
1436
|
+
curve: z.enum(['linear', 'basis']).optional()
|
|
1437
|
+
}).optional(),
|
|
1438
|
+
themeVariables: z.object({
|
|
1439
|
+
fontSize: z.string().optional()
|
|
1440
|
+
}).optional(),
|
|
1441
|
+
maxEdges: z.number().optional(),
|
|
1442
|
+
maxTextSize: z.number().optional()
|
|
1443
|
+
}).optional()
|
|
1444
|
+
}).parse(args);
|
|
1445
|
+
|
|
1446
|
+
logger.info('Creating Excalidraw elements from Mermaid diagram via MCP', {
|
|
1447
|
+
diagramLength: params.mermaidDiagram.length,
|
|
1448
|
+
hasConfig: !!params.config
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
try {
|
|
1452
|
+
// Send the Mermaid diagram to the frontend via the API
|
|
1453
|
+
// The frontend will use mermaid-to-excalidraw to convert it
|
|
1454
|
+
const response = await fetch(`${activeCanvasUrl()}/api/elements/from-mermaid`, {
|
|
1455
|
+
method: 'POST',
|
|
1456
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1457
|
+
body: JSON.stringify({
|
|
1458
|
+
mermaidDiagram: params.mermaidDiagram,
|
|
1459
|
+
config: params.config
|
|
1460
|
+
})
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
if (!response.ok) {
|
|
1464
|
+
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const result = await response.json() as ApiResponse;
|
|
1468
|
+
|
|
1469
|
+
logger.info('Mermaid diagram sent to frontend for conversion', {
|
|
1470
|
+
success: result.success
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
return {
|
|
1474
|
+
content: [{
|
|
1475
|
+
type: 'text',
|
|
1476
|
+
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 ${activeCanvasUrl()} to see the diagram rendered.`
|
|
1477
|
+
}]
|
|
1478
|
+
};
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
throw new Error(`Failed to process Mermaid diagram: ${(error as Error).message}`);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
case 'batch_create_elements': {
|
|
1485
|
+
const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
|
|
1486
|
+
logger.info('Batch creating elements via MCP', { count: params.elements.length });
|
|
1487
|
+
|
|
1488
|
+
const createdElements: ServerElement[] = [];
|
|
1489
|
+
|
|
1490
|
+
for (const elementData of params.elements) {
|
|
1491
|
+
const { startElementId, endElementId, id: customId, ...elementProps } = elementData;
|
|
1492
|
+
const id = customId || generateId();
|
|
1493
|
+
const element: ServerElement = {
|
|
1494
|
+
id,
|
|
1495
|
+
...elementProps,
|
|
1496
|
+
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
|
1497
|
+
// Convert binding IDs to Excalidraw's start/end format
|
|
1498
|
+
...(startElementId ? { start: { id: startElementId } } : {}),
|
|
1499
|
+
...(endElementId ? { end: { id: endElementId } } : {}),
|
|
1500
|
+
createdAt: new Date().toISOString(),
|
|
1501
|
+
updatedAt: new Date().toISOString(),
|
|
1502
|
+
version: 1
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
// Normalize fontFamily from string names to numeric values
|
|
1506
|
+
if (element.fontFamily !== undefined) {
|
|
1507
|
+
element.fontFamily = normalizeFontFamily(element.fontFamily);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// For bound arrows without explicit points, set a default
|
|
1511
|
+
if ((startElementId || endElementId) && !elementProps.points) {
|
|
1512
|
+
(element as any).points = [[0, 0], [100, 0]];
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const excalidrawElement = convertTextToLabel(element);
|
|
1516
|
+
createdElements.push(excalidrawElement);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const canvasElements = await batchCreateElementsOnCanvas(createdElements);
|
|
1520
|
+
|
|
1521
|
+
if (!canvasElements) {
|
|
1522
|
+
throw new Error('Failed to batch create elements: HTTP server unavailable');
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const result = {
|
|
1526
|
+
success: true,
|
|
1527
|
+
elements: canvasElements,
|
|
1528
|
+
count: canvasElements.length,
|
|
1529
|
+
syncedToCanvas: true
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
logger.info('Batch elements created via MCP and synced to canvas', {
|
|
1533
|
+
count: result.count,
|
|
1534
|
+
synced: result.syncedToCanvas
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
return {
|
|
1538
|
+
content: [{
|
|
1539
|
+
type: 'text',
|
|
1540
|
+
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)'}`
|
|
1541
|
+
}]
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
case 'get_element': {
|
|
1546
|
+
const params = ElementIdSchema.parse(args);
|
|
1547
|
+
const { id } = params;
|
|
1548
|
+
|
|
1549
|
+
const element = await getElementFromCanvas(id);
|
|
1550
|
+
if (!element) {
|
|
1551
|
+
throw new Error(`Element ${id} not found`);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
return {
|
|
1555
|
+
content: [{ type: 'text', text: JSON.stringify(element, null, 2) }]
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
case 'clear_canvas': {
|
|
1560
|
+
logger.info('Clearing canvas via MCP');
|
|
1561
|
+
|
|
1562
|
+
const tgtClear = await import('./target.js');
|
|
1563
|
+
if (tgtClear.isRemote()) {
|
|
1564
|
+
const n = tgtClear.rClear();
|
|
1565
|
+
return { content: [{ type: 'text', text: `Canvas cleared (${n} elements removed, will commit to GitHub).` }] };
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const response = await fetch(`${activeCanvasUrl()}/api/elements/clear`, {
|
|
1569
|
+
method: 'DELETE'
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
if (!response.ok) {
|
|
1573
|
+
throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const data = await response.json() as ApiResponse;
|
|
1577
|
+
|
|
1578
|
+
return {
|
|
1579
|
+
content: [{
|
|
1580
|
+
type: 'text',
|
|
1581
|
+
text: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}`
|
|
1582
|
+
}]
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
case 'export_scene': {
|
|
1587
|
+
const params = z.object({
|
|
1588
|
+
filePath: z.string().optional()
|
|
1589
|
+
}).parse(args || {});
|
|
1590
|
+
|
|
1591
|
+
logger.info('Exporting scene via MCP');
|
|
1592
|
+
|
|
1593
|
+
const tgtExp = await import('./target.js');
|
|
1594
|
+
if (tgtExp.isRemote()) {
|
|
1595
|
+
const sceneElements = tgtExp.rList();
|
|
1596
|
+
const excalidrawScene: any = { type: 'excalidraw', version: 2, source: 'mcp-excalidraw-server', elements: sceneElements, appState: { viewBackgroundColor: '#ffffff', gridSize: null } };
|
|
1597
|
+
const jsonString = JSON.stringify(excalidrawScene, null, 2);
|
|
1598
|
+
if (params.filePath) {
|
|
1599
|
+
const safePath = sanitizeFilePath(params.filePath);
|
|
1600
|
+
fs.writeFileSync(safePath, jsonString, 'utf-8');
|
|
1601
|
+
return { content: [{ type: 'text', text: `Scene exported to ${safePath} (${sceneElements.length} elements)` }] };
|
|
1602
|
+
}
|
|
1603
|
+
return { content: [{ type: 'text', text: jsonString }] };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
const response = await fetch(`${activeCanvasUrl()}/api/elements`);
|
|
1607
|
+
if (!response.ok) {
|
|
1608
|
+
throw new Error(`Failed to fetch elements: ${response.status} ${response.statusText}`);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const data = await response.json() as ApiResponse;
|
|
1612
|
+
const sceneElements = data.elements || [];
|
|
1613
|
+
|
|
1614
|
+
// Fetch files for image elements
|
|
1615
|
+
let sceneFiles: Record<string, any> = {};
|
|
1616
|
+
try {
|
|
1617
|
+
const filesResponse = await fetch(`${activeCanvasUrl()}/api/files`);
|
|
1618
|
+
if (filesResponse.ok) {
|
|
1619
|
+
const filesData = await filesResponse.json() as any;
|
|
1620
|
+
sceneFiles = filesData.files || {};
|
|
1621
|
+
}
|
|
1622
|
+
} catch { /* files endpoint may not exist */ }
|
|
1623
|
+
|
|
1624
|
+
const excalidrawScene: any = {
|
|
1625
|
+
type: 'excalidraw',
|
|
1626
|
+
version: 2,
|
|
1627
|
+
source: 'mcp-excalidraw-server',
|
|
1628
|
+
elements: sceneElements,
|
|
1629
|
+
appState: {
|
|
1630
|
+
viewBackgroundColor: '#ffffff',
|
|
1631
|
+
gridSize: null
|
|
1632
|
+
},
|
|
1633
|
+
...(Object.keys(sceneFiles).length > 0 ? { files: sceneFiles } : {})
|
|
1634
|
+
};
|
|
1635
|
+
|
|
1636
|
+
const jsonString = JSON.stringify(excalidrawScene, null, 2);
|
|
1637
|
+
|
|
1638
|
+
if (params.filePath) {
|
|
1639
|
+
const safePath = sanitizeFilePath(params.filePath);
|
|
1640
|
+
fs.writeFileSync(safePath, jsonString, 'utf-8');
|
|
1641
|
+
return {
|
|
1642
|
+
content: [{
|
|
1643
|
+
type: 'text',
|
|
1644
|
+
text: `Scene exported to ${safePath} (${sceneElements.length} elements)`
|
|
1645
|
+
}]
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
return {
|
|
1650
|
+
content: [{
|
|
1651
|
+
type: 'text',
|
|
1652
|
+
text: jsonString
|
|
1653
|
+
}]
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
case 'import_scene': {
|
|
1658
|
+
const params = z.object({
|
|
1659
|
+
filePath: z.string().optional(),
|
|
1660
|
+
data: z.string().optional(),
|
|
1661
|
+
mode: z.enum(['replace', 'merge'])
|
|
1662
|
+
}).parse(args);
|
|
1663
|
+
|
|
1664
|
+
logger.info('Importing scene via MCP', { mode: params.mode });
|
|
1665
|
+
|
|
1666
|
+
let sceneData: any;
|
|
1667
|
+
if (params.filePath) {
|
|
1668
|
+
const safeImportPath = sanitizeFilePath(params.filePath);
|
|
1669
|
+
const fileContent = fs.readFileSync(safeImportPath, 'utf-8');
|
|
1670
|
+
sceneData = JSON.parse(fileContent);
|
|
1671
|
+
} else if (params.data) {
|
|
1672
|
+
sceneData = JSON.parse(params.data);
|
|
1673
|
+
} else {
|
|
1674
|
+
throw new Error('Either filePath or data must be provided');
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// Extract elements from .excalidraw format or raw array
|
|
1678
|
+
const importElements: ServerElement[] = Array.isArray(sceneData)
|
|
1679
|
+
? sceneData
|
|
1680
|
+
: (sceneData.elements || []);
|
|
1681
|
+
|
|
1682
|
+
if (importElements.length === 0) {
|
|
1683
|
+
throw new Error('No elements found in the import data');
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// If replace mode, clear first
|
|
1687
|
+
if (params.mode === 'replace') {
|
|
1688
|
+
const tgtImp = await import('./target.js');
|
|
1689
|
+
if (tgtImp.isRemote()) tgtImp.rClear();
|
|
1690
|
+
else await fetch(`${activeCanvasUrl()}/api/elements/clear`, { method: 'DELETE' });
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Batch create the imported elements
|
|
1694
|
+
const elementsToCreate = importElements.map(el => ({
|
|
1695
|
+
...el,
|
|
1696
|
+
id: el.id || generateId(),
|
|
1697
|
+
createdAt: new Date().toISOString(),
|
|
1698
|
+
updatedAt: new Date().toISOString(),
|
|
1699
|
+
version: 1
|
|
1700
|
+
}));
|
|
1701
|
+
|
|
1702
|
+
const canvasElements = await batchCreateElementsOnCanvas(elementsToCreate);
|
|
1703
|
+
|
|
1704
|
+
// Import files if present (for image elements)
|
|
1705
|
+
let importedFileCount = 0;
|
|
1706
|
+
const importFiles = sceneData.files;
|
|
1707
|
+
if (importFiles && typeof importFiles === 'object') {
|
|
1708
|
+
const fileList = Object.values(importFiles);
|
|
1709
|
+
if (fileList.length > 0) {
|
|
1710
|
+
try {
|
|
1711
|
+
await fetch(`${activeCanvasUrl()}/api/files`, {
|
|
1712
|
+
method: 'POST',
|
|
1713
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1714
|
+
body: JSON.stringify(fileList)
|
|
1715
|
+
});
|
|
1716
|
+
importedFileCount = fileList.length;
|
|
1717
|
+
} catch { /* best effort */ }
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
return {
|
|
1722
|
+
content: [{
|
|
1723
|
+
type: 'text',
|
|
1724
|
+
text: `Imported ${elementsToCreate.length} elements${importedFileCount > 0 ? ` and ${importedFileCount} files` : ''} (mode: ${params.mode})\n\n✅ Synced to canvas`
|
|
1725
|
+
}]
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
case 'export_to_image': {
|
|
1730
|
+
const params = z.object({
|
|
1731
|
+
format: z.enum(['png', 'svg']),
|
|
1732
|
+
filePath: z.string().optional(),
|
|
1733
|
+
background: z.boolean().optional()
|
|
1734
|
+
}).parse(args);
|
|
1735
|
+
|
|
1736
|
+
logger.info('Exporting to image via MCP', { format: params.format });
|
|
1737
|
+
|
|
1738
|
+
const response = await fetch(`${activeCanvasUrl()}/api/export/image`, {
|
|
1739
|
+
method: 'POST',
|
|
1740
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1741
|
+
body: JSON.stringify({
|
|
1742
|
+
format: params.format,
|
|
1743
|
+
background: params.background ?? true
|
|
1744
|
+
})
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
if (!response.ok) {
|
|
1748
|
+
const errorData = await response.json() as ApiResponse;
|
|
1749
|
+
throw new Error(errorData.error || `Export failed: ${response.status}`);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const result = await response.json() as { success: boolean; format: string; data: string };
|
|
1753
|
+
|
|
1754
|
+
if (params.filePath) {
|
|
1755
|
+
const safeImagePath = sanitizeFilePath(params.filePath);
|
|
1756
|
+
if (params.format === 'svg') {
|
|
1757
|
+
fs.writeFileSync(safeImagePath, result.data, 'utf-8');
|
|
1758
|
+
} else {
|
|
1759
|
+
fs.writeFileSync(safeImagePath, Buffer.from(result.data, 'base64'));
|
|
1760
|
+
}
|
|
1761
|
+
return {
|
|
1762
|
+
content: [{
|
|
1763
|
+
type: 'text',
|
|
1764
|
+
text: `Image exported to ${safeImagePath} (format: ${params.format})`
|
|
1765
|
+
}]
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
return {
|
|
1770
|
+
content: [{
|
|
1771
|
+
type: 'text',
|
|
1772
|
+
text: params.format === 'svg'
|
|
1773
|
+
? result.data
|
|
1774
|
+
: `Base64 ${params.format} data (${result.data.length} chars). Use filePath to save to disk.`
|
|
1775
|
+
}]
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
case 'duplicate_elements': {
|
|
1780
|
+
const params = z.object({
|
|
1781
|
+
elementIds: z.array(z.string()),
|
|
1782
|
+
offsetX: z.number().optional(),
|
|
1783
|
+
offsetY: z.number().optional()
|
|
1784
|
+
}).parse(args);
|
|
1785
|
+
|
|
1786
|
+
const offsetX = params.offsetX ?? 20;
|
|
1787
|
+
const offsetY = params.offsetY ?? 20;
|
|
1788
|
+
|
|
1789
|
+
logger.info('Duplicating elements via MCP', { count: params.elementIds.length });
|
|
1790
|
+
|
|
1791
|
+
const duplicates: ServerElement[] = [];
|
|
1792
|
+
for (const id of params.elementIds) {
|
|
1793
|
+
const original = await getElementFromCanvas(id);
|
|
1794
|
+
if (!original) {
|
|
1795
|
+
logger.warn(`Element ${id} not found, skipping duplicate`);
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const { createdAt, updatedAt, version, syncedAt, source, syncTimestamp, ...rest } = original;
|
|
1800
|
+
const duplicate: ServerElement = {
|
|
1801
|
+
...rest,
|
|
1802
|
+
id: generateId(),
|
|
1803
|
+
x: original.x + offsetX,
|
|
1804
|
+
y: original.y + offsetY,
|
|
1805
|
+
createdAt: new Date().toISOString(),
|
|
1806
|
+
updatedAt: new Date().toISOString(),
|
|
1807
|
+
version: 1
|
|
1808
|
+
};
|
|
1809
|
+
duplicates.push(duplicate);
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
if (duplicates.length === 0) {
|
|
1813
|
+
throw new Error('No elements could be duplicated (none found)');
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const canvasElements = await batchCreateElementsOnCanvas(duplicates);
|
|
1817
|
+
|
|
1818
|
+
return {
|
|
1819
|
+
content: [{
|
|
1820
|
+
type: 'text',
|
|
1821
|
+
text: `Duplicated ${duplicates.length} elements (offset: ${offsetX}, ${offsetY})\n\n${JSON.stringify(canvasElements, null, 2)}\n\n✅ Synced to canvas`
|
|
1822
|
+
}]
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
case 'snapshot_scene': {
|
|
1827
|
+
const params = z.object({ name: z.string() }).parse(args);
|
|
1828
|
+
logger.info('Saving snapshot via MCP', { name: params.name });
|
|
1829
|
+
|
|
1830
|
+
const response = await fetch(`${activeCanvasUrl()}/api/snapshots`, {
|
|
1831
|
+
method: 'POST',
|
|
1832
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1833
|
+
body: JSON.stringify({ name: params.name })
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
if (!response.ok) {
|
|
1837
|
+
throw new Error(`Failed to save snapshot: ${response.status} ${response.statusText}`);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
const result = await response.json() as any;
|
|
1841
|
+
|
|
1842
|
+
return {
|
|
1843
|
+
content: [{
|
|
1844
|
+
type: 'text',
|
|
1845
|
+
text: `Snapshot "${params.name}" saved (${result.elementCount} elements)\n\n${JSON.stringify(result, null, 2)}`
|
|
1846
|
+
}]
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
case 'restore_snapshot': {
|
|
1851
|
+
const params = z.object({ name: z.string() }).parse(args);
|
|
1852
|
+
logger.info('Restoring snapshot via MCP', { name: params.name });
|
|
1853
|
+
|
|
1854
|
+
// Fetch the snapshot
|
|
1855
|
+
const response = await fetch(`${activeCanvasUrl()}/api/snapshots/${encodeURIComponent(params.name)}`);
|
|
1856
|
+
if (!response.ok) {
|
|
1857
|
+
throw new Error(`Snapshot "${params.name}" not found`);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } };
|
|
1861
|
+
|
|
1862
|
+
// Clear current canvas
|
|
1863
|
+
await fetch(`${activeCanvasUrl()}/api/elements/clear`, { method: 'DELETE' });
|
|
1864
|
+
|
|
1865
|
+
// Restore elements
|
|
1866
|
+
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
|
|
1867
|
+
|
|
1868
|
+
return {
|
|
1869
|
+
content: [{
|
|
1870
|
+
type: 'text',
|
|
1871
|
+
text: `Snapshot "${params.name}" restored (${data.snapshot.elements.length} elements)\n\n✅ Canvas updated`
|
|
1872
|
+
}]
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
case 'describe_scene': {
|
|
1877
|
+
logger.info('Describing scene via MCP');
|
|
1878
|
+
|
|
1879
|
+
const tgtDesc = await import('./target.js');
|
|
1880
|
+
if (tgtDesc.isRemote()) {
|
|
1881
|
+
const allElements = tgtDesc.rList();
|
|
1882
|
+
if (allElements.length === 0) {
|
|
1883
|
+
return { content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }] };
|
|
1884
|
+
}
|
|
1885
|
+
const counts: Record<string, number> = {};
|
|
1886
|
+
for (const el of allElements) counts[el.type] = (counts[el.type] || 0) + 1;
|
|
1887
|
+
const lines = allElements.map(el => `[${el.id}] ${el.type} at (${Math.round(el.x)}, ${Math.round(el.y)})${(el as any).text ? ` text: "${(el as any).text}"` : ''}`);
|
|
1888
|
+
return { content: [{ type: 'text', text: `Canvas: ${allElements.length} elements (${Object.entries(counts).map(([k, v]) => `${v}x ${k}`).join(', ')})\n${lines.join('\n')}` }] };
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const response = await fetch(`${activeCanvasUrl()}/api/elements`);
|
|
1892
|
+
if (!response.ok) {
|
|
1893
|
+
throw new Error(`Failed to fetch elements: ${response.status}`);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const data = await response.json() as ApiResponse;
|
|
1897
|
+
const allElements = data.elements || [];
|
|
1898
|
+
|
|
1899
|
+
if (allElements.length === 0) {
|
|
1900
|
+
return {
|
|
1901
|
+
content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }]
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Count by type
|
|
1906
|
+
const typeCounts: Record<string, number> = {};
|
|
1907
|
+
for (const el of allElements) {
|
|
1908
|
+
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// Bounding box
|
|
1912
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1913
|
+
for (const el of allElements) {
|
|
1914
|
+
minX = Math.min(minX, el.x);
|
|
1915
|
+
minY = Math.min(minY, el.y);
|
|
1916
|
+
maxX = Math.max(maxX, el.x + (el.width || 0));
|
|
1917
|
+
maxY = Math.max(maxY, el.y + (el.height || 0));
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// Build element descriptions sorted top-to-bottom, left-to-right
|
|
1921
|
+
const sorted = [...allElements].sort((a, b) => {
|
|
1922
|
+
const rowDiff = Math.floor(a.y / 50) - Math.floor(b.y / 50);
|
|
1923
|
+
return rowDiff !== 0 ? rowDiff : a.x - b.x;
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
const elementDescs: string[] = [];
|
|
1927
|
+
for (const el of sorted) {
|
|
1928
|
+
const parts: string[] = [];
|
|
1929
|
+
parts.push(`[${el.id}] ${el.type}`);
|
|
1930
|
+
parts.push(`at (${Math.round(el.x)}, ${Math.round(el.y)})`);
|
|
1931
|
+
if (el.width || el.height) {
|
|
1932
|
+
parts.push(`size ${Math.round(el.width || 0)}x${Math.round(el.height || 0)}`);
|
|
1933
|
+
}
|
|
1934
|
+
if (el.text) parts.push(`text: "${el.text}"`);
|
|
1935
|
+
if (el.label?.text) parts.push(`label: "${el.label.text}"`);
|
|
1936
|
+
if (el.backgroundColor && el.backgroundColor !== 'transparent') {
|
|
1937
|
+
parts.push(`bg: ${el.backgroundColor}`);
|
|
1938
|
+
}
|
|
1939
|
+
if (el.strokeColor && el.strokeColor !== '#000000') {
|
|
1940
|
+
parts.push(`stroke: ${el.strokeColor}`);
|
|
1941
|
+
}
|
|
1942
|
+
if (el.locked) parts.push('(locked)');
|
|
1943
|
+
if (el.groupIds && el.groupIds.length > 0) {
|
|
1944
|
+
parts.push(`groups: [${el.groupIds.join(', ')}]`);
|
|
1945
|
+
}
|
|
1946
|
+
elementDescs.push(` ${parts.join(' | ')}`);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// Find connections (arrows)
|
|
1950
|
+
const arrows = allElements.filter(el => el.type === 'arrow');
|
|
1951
|
+
const connectionDescs: string[] = [];
|
|
1952
|
+
for (const arrow of arrows) {
|
|
1953
|
+
const arrowAny = arrow as any;
|
|
1954
|
+
if (arrowAny.startBinding?.elementId || arrowAny.endBinding?.elementId) {
|
|
1955
|
+
const from = arrowAny.startBinding?.elementId || '?';
|
|
1956
|
+
const to = arrowAny.endBinding?.elementId || '?';
|
|
1957
|
+
connectionDescs.push(` ${from} --> ${to} (arrow: ${arrow.id})`);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// Build description
|
|
1962
|
+
const lines: string[] = [];
|
|
1963
|
+
lines.push(`## Canvas Description`);
|
|
1964
|
+
lines.push(`Total elements: ${allElements.length}`);
|
|
1965
|
+
lines.push(`Types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ')}`);
|
|
1966
|
+
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)}`);
|
|
1967
|
+
lines.push('');
|
|
1968
|
+
lines.push('### Elements (top-to-bottom, left-to-right):');
|
|
1969
|
+
lines.push(...elementDescs);
|
|
1970
|
+
|
|
1971
|
+
if (connectionDescs.length > 0) {
|
|
1972
|
+
lines.push('');
|
|
1973
|
+
lines.push('### Connections:');
|
|
1974
|
+
lines.push(...connectionDescs);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Groups
|
|
1978
|
+
const groupedElements = allElements.filter(el => el.groupIds && el.groupIds.length > 0);
|
|
1979
|
+
if (groupedElements.length > 0) {
|
|
1980
|
+
const groupMap: Record<string, string[]> = {};
|
|
1981
|
+
for (const el of groupedElements) {
|
|
1982
|
+
for (const gid of (el.groupIds || [])) {
|
|
1983
|
+
if (!groupMap[gid]) groupMap[gid] = [];
|
|
1984
|
+
groupMap[gid]!.push(el.id);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
lines.push('');
|
|
1988
|
+
lines.push('### Groups:');
|
|
1989
|
+
for (const [gid, ids] of Object.entries(groupMap)) {
|
|
1990
|
+
lines.push(` Group ${gid}: [${ids.join(', ')}]`);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
return {
|
|
1995
|
+
content: [{ type: 'text', text: lines.join('\n') }]
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
case 'get_canvas_screenshot': {
|
|
2000
|
+
const params = z.object({
|
|
2001
|
+
background: z.boolean().optional()
|
|
2002
|
+
}).parse(args || {});
|
|
2003
|
+
|
|
2004
|
+
logger.info('Taking canvas screenshot via MCP');
|
|
2005
|
+
|
|
2006
|
+
const response = await fetch(`${activeCanvasUrl()}/api/export/image`, {
|
|
2007
|
+
method: 'POST',
|
|
2008
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2009
|
+
body: JSON.stringify({
|
|
2010
|
+
format: 'png',
|
|
2011
|
+
background: params.background ?? true
|
|
2012
|
+
})
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
if (!response.ok) {
|
|
2016
|
+
const errorData = await response.json() as ApiResponse;
|
|
2017
|
+
throw new Error(errorData.error || `Screenshot failed: ${response.status}`);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
const result = await response.json() as { success: boolean; format: string; data: string };
|
|
2021
|
+
|
|
2022
|
+
return {
|
|
2023
|
+
content: [
|
|
2024
|
+
{
|
|
2025
|
+
type: 'image' as const,
|
|
2026
|
+
data: result.data,
|
|
2027
|
+
mimeType: 'image/png'
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
type: 'text',
|
|
2031
|
+
text: 'Canvas screenshot captured. This is what the diagram currently looks like.'
|
|
2032
|
+
}
|
|
2033
|
+
]
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
case 'read_diagram_guide': {
|
|
2038
|
+
return {
|
|
2039
|
+
content: [{ type: 'text', text: DIAGRAM_DESIGN_GUIDE }]
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
case 'export_to_excalidraw_url': {
|
|
2044
|
+
logger.info('Exporting to excalidraw.com URL');
|
|
2045
|
+
|
|
2046
|
+
// 1. Fetch current scene elements
|
|
2047
|
+
const urlExportResponse = await fetch(`${activeCanvasUrl()}/api/elements`);
|
|
2048
|
+
if (!urlExportResponse.ok) {
|
|
2049
|
+
throw new Error(`Failed to fetch elements: ${urlExportResponse.status}`);
|
|
2050
|
+
}
|
|
2051
|
+
const urlExportData = await urlExportResponse.json() as ApiResponse;
|
|
2052
|
+
const urlExportElements = urlExportData.elements || [];
|
|
2053
|
+
|
|
2054
|
+
if (urlExportElements.length === 0) {
|
|
2055
|
+
throw new Error('Canvas is empty — nothing to export');
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// 2. Clean elements: strip server metadata, add Excalidraw defaults,
|
|
2059
|
+
// generate bound text elements, and resolve arrow bindings
|
|
2060
|
+
const cleanedExportElements: Record<string, any>[] = [];
|
|
2061
|
+
const boundTextElements: Record<string, any>[] = [];
|
|
2062
|
+
let indexCounter = 0;
|
|
2063
|
+
|
|
2064
|
+
function makeBaseElement(el: any, rest: any): Record<string, any> {
|
|
2065
|
+
return {
|
|
2066
|
+
...rest,
|
|
2067
|
+
angle: rest.angle ?? 0,
|
|
2068
|
+
strokeColor: rest.strokeColor ?? '#1e1e1e',
|
|
2069
|
+
backgroundColor: rest.backgroundColor ?? 'transparent',
|
|
2070
|
+
fillStyle: rest.fillStyle ?? 'solid',
|
|
2071
|
+
strokeWidth: rest.strokeWidth ?? 2,
|
|
2072
|
+
strokeStyle: rest.strokeStyle ?? 'solid',
|
|
2073
|
+
roughness: rest.roughness ?? 1,
|
|
2074
|
+
opacity: rest.opacity ?? 100,
|
|
2075
|
+
groupIds: rest.groupIds ?? [],
|
|
2076
|
+
frameId: rest.frameId ?? null,
|
|
2077
|
+
index: rest.index ?? `a${indexCounter++}`,
|
|
2078
|
+
roundness: rest.roundness ?? (
|
|
2079
|
+
el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse'
|
|
2080
|
+
? { type: 3 } : null
|
|
2081
|
+
),
|
|
2082
|
+
seed: rest.seed ?? Math.floor(Math.random() * 2147483647),
|
|
2083
|
+
version: rest.version ?? 1,
|
|
2084
|
+
versionNonce: rest.versionNonce ?? Math.floor(Math.random() * 2147483647),
|
|
2085
|
+
isDeleted: false,
|
|
2086
|
+
boundElements: rest.boundElements ?? null,
|
|
2087
|
+
updated: Date.now(),
|
|
2088
|
+
link: rest.link ?? null,
|
|
2089
|
+
locked: rest.locked ?? false
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
for (const el of urlExportElements) {
|
|
2094
|
+
// Strip server-only fields
|
|
2095
|
+
const {
|
|
2096
|
+
createdAt, updatedAt, syncedAt, source: _src,
|
|
2097
|
+
syncTimestamp, label, start, end, text,
|
|
2098
|
+
version: _ver,
|
|
2099
|
+
...rest
|
|
2100
|
+
} = el as any;
|
|
2101
|
+
|
|
2102
|
+
const base = makeBaseElement(el, rest);
|
|
2103
|
+
|
|
2104
|
+
// Standalone text elements: keep text directly
|
|
2105
|
+
if (el.type === 'text') {
|
|
2106
|
+
base.text = text ?? '';
|
|
2107
|
+
base.originalText = text ?? '';
|
|
2108
|
+
base.fontSize = rest.fontSize ?? 20;
|
|
2109
|
+
base.fontFamily = normalizeFontFamily(rest.fontFamily) ?? 1;
|
|
2110
|
+
base.textAlign = rest.textAlign ?? 'center';
|
|
2111
|
+
base.verticalAlign = rest.verticalAlign ?? 'middle';
|
|
2112
|
+
base.autoResize = rest.autoResize ?? true;
|
|
2113
|
+
base.lineHeight = rest.lineHeight ?? 1.25;
|
|
2114
|
+
base.containerId = rest.containerId ?? null;
|
|
2115
|
+
cleanedExportElements.push(base);
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// Arrows: server already resolved bindings (start/end → startBinding/endBinding + positions)
|
|
2120
|
+
if (el.type === 'arrow' || el.type === 'line') {
|
|
2121
|
+
base.points = rest.points ?? [[0, 0], [100, 0]];
|
|
2122
|
+
base.lastCommittedPoint = null;
|
|
2123
|
+
// Preserve server-resolved bindings with fixedPoint for excalidraw.com
|
|
2124
|
+
if (rest.startBinding) {
|
|
2125
|
+
base.startBinding = { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null };
|
|
2126
|
+
} else {
|
|
2127
|
+
base.startBinding = null;
|
|
2128
|
+
}
|
|
2129
|
+
if (rest.endBinding) {
|
|
2130
|
+
base.endBinding = { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null };
|
|
2131
|
+
} else {
|
|
2132
|
+
base.endBinding = null;
|
|
2133
|
+
}
|
|
2134
|
+
base.startArrowhead = rest.startArrowhead ?? null;
|
|
2135
|
+
base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
|
|
2136
|
+
base.elbowed = rest.elbowed ?? false;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// Generate bound text element for label on shapes and arrows
|
|
2140
|
+
const labelText = label?.text || text;
|
|
2141
|
+
if (labelText) {
|
|
2142
|
+
const textId = `${base.id}-label`;
|
|
2143
|
+
// Add binding reference to parent
|
|
2144
|
+
base.boundElements = [
|
|
2145
|
+
...(Array.isArray(base.boundElements) ? base.boundElements : []),
|
|
2146
|
+
{ type: 'text', id: textId }
|
|
2147
|
+
];
|
|
2148
|
+
|
|
2149
|
+
// Compute text position: centered in shape, or at arrow midpoint
|
|
2150
|
+
let textX: number, textY: number, textW: number, textH: number;
|
|
2151
|
+
const isArrow = el.type === 'arrow' || el.type === 'line';
|
|
2152
|
+
|
|
2153
|
+
if (isArrow) {
|
|
2154
|
+
// Position at midpoint of arrow path
|
|
2155
|
+
const pts = base.points || [[0, 0], [100, 0]];
|
|
2156
|
+
const lastPt = pts[pts.length - 1];
|
|
2157
|
+
const midX = base.x + (lastPt[0] / 2);
|
|
2158
|
+
const midY = base.y + (lastPt[1] / 2);
|
|
2159
|
+
const labelW = Math.max(labelText.length * 10, 60);
|
|
2160
|
+
textX = midX - labelW / 2;
|
|
2161
|
+
textY = midY - 12;
|
|
2162
|
+
textW = labelW;
|
|
2163
|
+
textH = 24;
|
|
2164
|
+
} else {
|
|
2165
|
+
// Center inside shape container
|
|
2166
|
+
const containerW = base.width ?? 160;
|
|
2167
|
+
const containerH = base.height ?? 80;
|
|
2168
|
+
textX = base.x + 10;
|
|
2169
|
+
textY = base.y + containerH / 4;
|
|
2170
|
+
textW = containerW - 20;
|
|
2171
|
+
textH = containerH / 2;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
boundTextElements.push({
|
|
2175
|
+
id: textId,
|
|
2176
|
+
type: 'text',
|
|
2177
|
+
x: textX,
|
|
2178
|
+
y: textY,
|
|
2179
|
+
width: textW,
|
|
2180
|
+
height: textH,
|
|
2181
|
+
angle: 0,
|
|
2182
|
+
strokeColor: isArrow ? '#1e1e1e' : base.strokeColor,
|
|
2183
|
+
backgroundColor: 'transparent',
|
|
2184
|
+
fillStyle: 'solid',
|
|
2185
|
+
strokeWidth: 1,
|
|
2186
|
+
strokeStyle: 'solid',
|
|
2187
|
+
roughness: 1,
|
|
2188
|
+
opacity: 100,
|
|
2189
|
+
groupIds: [],
|
|
2190
|
+
frameId: null,
|
|
2191
|
+
index: `a${indexCounter++}`,
|
|
2192
|
+
roundness: null,
|
|
2193
|
+
seed: Math.floor(Math.random() * 2147483647),
|
|
2194
|
+
version: 1,
|
|
2195
|
+
versionNonce: Math.floor(Math.random() * 2147483647),
|
|
2196
|
+
isDeleted: false,
|
|
2197
|
+
boundElements: null,
|
|
2198
|
+
updated: Date.now(),
|
|
2199
|
+
link: null,
|
|
2200
|
+
locked: false,
|
|
2201
|
+
text: labelText,
|
|
2202
|
+
originalText: labelText,
|
|
2203
|
+
fontSize: isArrow ? 14 : (rest.fontSize ?? 16),
|
|
2204
|
+
fontFamily: normalizeFontFamily(rest.fontFamily) ?? 1,
|
|
2205
|
+
textAlign: 'center',
|
|
2206
|
+
verticalAlign: 'middle',
|
|
2207
|
+
autoResize: true,
|
|
2208
|
+
lineHeight: 1.25,
|
|
2209
|
+
containerId: base.id
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
cleanedExportElements.push(base);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// Patch shapes' boundElements to include connected arrows
|
|
2217
|
+
const shapeBoundArrows = new Map<string, { type: string; id: string }[]>();
|
|
2218
|
+
for (const el of cleanedExportElements) {
|
|
2219
|
+
if (el.startBinding?.elementId) {
|
|
2220
|
+
const arr = shapeBoundArrows.get(el.startBinding.elementId) || [];
|
|
2221
|
+
arr.push({ type: 'arrow', id: el.id });
|
|
2222
|
+
shapeBoundArrows.set(el.startBinding.elementId, arr);
|
|
2223
|
+
}
|
|
2224
|
+
if (el.endBinding?.elementId) {
|
|
2225
|
+
const arr = shapeBoundArrows.get(el.endBinding.elementId) || [];
|
|
2226
|
+
arr.push({ type: 'arrow', id: el.id });
|
|
2227
|
+
shapeBoundArrows.set(el.endBinding.elementId, arr);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
for (const el of cleanedExportElements) {
|
|
2231
|
+
const arrowBindings = shapeBoundArrows.get(el.id);
|
|
2232
|
+
if (arrowBindings) {
|
|
2233
|
+
el.boundElements = [
|
|
2234
|
+
...(Array.isArray(el.boundElements) ? el.boundElements : []),
|
|
2235
|
+
...arrowBindings
|
|
2236
|
+
];
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// Append all bound text elements after their parents
|
|
2241
|
+
cleanedExportElements.push(...boundTextElements);
|
|
2242
|
+
|
|
2243
|
+
// Build .excalidraw scene JSON
|
|
2244
|
+
const excalidrawScene = {
|
|
2245
|
+
type: 'excalidraw',
|
|
2246
|
+
version: 2,
|
|
2247
|
+
source: 'https://excalidraw.com',
|
|
2248
|
+
elements: cleanedExportElements,
|
|
2249
|
+
appState: {
|
|
2250
|
+
viewBackgroundColor: '#ffffff',
|
|
2251
|
+
gridSize: null
|
|
2252
|
+
},
|
|
2253
|
+
files: {}
|
|
2254
|
+
};
|
|
2255
|
+
const sceneJson = JSON.stringify(excalidrawScene);
|
|
2256
|
+
const dataBytes = new TextEncoder().encode(sceneJson);
|
|
2257
|
+
|
|
2258
|
+
// Excalidraw's concatBuffers: [4-byte version=1][4-byte len][chunk]...
|
|
2259
|
+
function concatBuffers(...bufs: Uint8Array[]): Uint8Array {
|
|
2260
|
+
let total = 4; // version header
|
|
2261
|
+
for (const b of bufs) total += 4 + b.length;
|
|
2262
|
+
const out = new Uint8Array(total);
|
|
2263
|
+
const dv = new DataView(out.buffer);
|
|
2264
|
+
dv.setUint32(0, 1); // CONCAT_BUFFERS_VERSION = 1
|
|
2265
|
+
let off = 4;
|
|
2266
|
+
for (const b of bufs) {
|
|
2267
|
+
dv.setUint32(off, b.length);
|
|
2268
|
+
off += 4;
|
|
2269
|
+
out.set(b, off);
|
|
2270
|
+
off += b.length;
|
|
2271
|
+
}
|
|
2272
|
+
return out;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const encoder = new TextEncoder();
|
|
2276
|
+
|
|
2277
|
+
// 3. Inner data: concatBuffers(fileMetadata, dataJSON)
|
|
2278
|
+
const fileMetadata = encoder.encode('{}');
|
|
2279
|
+
const innerData = concatBuffers(fileMetadata, dataBytes);
|
|
2280
|
+
|
|
2281
|
+
// 4. Compress with zlib deflate
|
|
2282
|
+
const compressed = deflateSync(Buffer.from(innerData));
|
|
2283
|
+
|
|
2284
|
+
// 5. Encrypt with AES-GCM 128-bit key
|
|
2285
|
+
const cryptoKey = await webcrypto.subtle.generateKey(
|
|
2286
|
+
{ name: 'AES-GCM', length: 128 },
|
|
2287
|
+
true,
|
|
2288
|
+
['encrypt']
|
|
2289
|
+
);
|
|
2290
|
+
|
|
2291
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
2292
|
+
const encrypted = await webcrypto.subtle.encrypt(
|
|
2293
|
+
{ name: 'AES-GCM', iv },
|
|
2294
|
+
cryptoKey,
|
|
2295
|
+
compressed
|
|
2296
|
+
);
|
|
2297
|
+
|
|
2298
|
+
// 6. Outer payload: concatBuffers(encodingMeta, iv, ciphertext)
|
|
2299
|
+
const encodingMeta = encoder.encode(JSON.stringify({
|
|
2300
|
+
version: 2,
|
|
2301
|
+
compression: 'pako@1',
|
|
2302
|
+
encryption: 'AES-GCM'
|
|
2303
|
+
}));
|
|
2304
|
+
const ciphertext = new Uint8Array(encrypted);
|
|
2305
|
+
const payload = concatBuffers(encodingMeta, iv, ciphertext);
|
|
2306
|
+
|
|
2307
|
+
// 7. POST to excalidraw.com JSON store
|
|
2308
|
+
const uploadResponse = await fetch('https://json.excalidraw.com/api/v2/post/', {
|
|
2309
|
+
method: 'POST',
|
|
2310
|
+
body: Buffer.from(payload)
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
if (!uploadResponse.ok) {
|
|
2314
|
+
throw new Error(`Upload to excalidraw.com failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
const uploadResult = await uploadResponse.json() as { id: string };
|
|
2318
|
+
|
|
2319
|
+
// 8. Export key as JWK to get the "k" field
|
|
2320
|
+
const jwk = await webcrypto.subtle.exportKey('jwk', cryptoKey);
|
|
2321
|
+
|
|
2322
|
+
// 9. Build shareable URL
|
|
2323
|
+
const shareUrl = `https://excalidraw.com/#json=${uploadResult.id},${jwk.k}`;
|
|
2324
|
+
|
|
2325
|
+
return {
|
|
2326
|
+
content: [{
|
|
2327
|
+
type: 'text',
|
|
2328
|
+
text: `Diagram exported to excalidraw.com!\n\nShareable URL: ${shareUrl}\n\nAnyone with this link can view and edit the diagram.`
|
|
2329
|
+
}]
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
case 'set_viewport': {
|
|
2334
|
+
const viewportParams = z.object({
|
|
2335
|
+
scrollToContent: z.boolean().optional(),
|
|
2336
|
+
scrollToElementId: z.string().optional(),
|
|
2337
|
+
zoom: z.number().min(0.1).max(10).optional(),
|
|
2338
|
+
offsetX: z.number().optional(),
|
|
2339
|
+
offsetY: z.number().optional()
|
|
2340
|
+
}).parse(args || {});
|
|
2341
|
+
|
|
2342
|
+
logger.info('Setting viewport via MCP', viewportParams);
|
|
2343
|
+
|
|
2344
|
+
const viewportResponse = await fetch(`${activeCanvasUrl()}/api/viewport`, {
|
|
2345
|
+
method: 'POST',
|
|
2346
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2347
|
+
body: JSON.stringify(viewportParams)
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
if (!viewportResponse.ok) {
|
|
2351
|
+
const viewportError = await viewportResponse.json() as ApiResponse;
|
|
2352
|
+
throw new Error(viewportError.error || `Viewport request failed: ${viewportResponse.status}`);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const viewportResult = await viewportResponse.json() as { success: boolean; message?: string };
|
|
2356
|
+
|
|
2357
|
+
return {
|
|
2358
|
+
content: [{
|
|
2359
|
+
type: 'text',
|
|
2360
|
+
text: `Viewport updated successfully.\n\n${JSON.stringify(viewportResult, null, 2)}`
|
|
2361
|
+
}]
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
case 'list_canvases': {
|
|
2366
|
+
const found: { url: string; port: number; healthy: boolean; elements?: number }[] = [];
|
|
2367
|
+
const ports = new Set<number>();
|
|
2368
|
+
try {
|
|
2369
|
+
const u = new URL(activeCanvasUrl());
|
|
2370
|
+
if (u.port) ports.add(Number(u.port));
|
|
2371
|
+
} catch { /* ignore */ }
|
|
2372
|
+
for (let p = 3030; p < 3230; p++) ports.add(p);
|
|
2373
|
+
for (const port of ports) {
|
|
2374
|
+
const url = `http://127.0.0.1:${port}`;
|
|
2375
|
+
try {
|
|
2376
|
+
const ctl = new AbortController();
|
|
2377
|
+
const t = setTimeout(() => ctl.abort(), 150);
|
|
2378
|
+
const r = await fetch(`${url}/health`, { signal: ctl.signal as any });
|
|
2379
|
+
clearTimeout(t);
|
|
2380
|
+
if (r.ok) {
|
|
2381
|
+
const h = await r.json() as any;
|
|
2382
|
+
found.push({ url, port, healthy: true, elements: h.elements_count });
|
|
2383
|
+
}
|
|
2384
|
+
} catch { /* not running */ }
|
|
2385
|
+
}
|
|
2386
|
+
return { content: [{ type: 'text', text: JSON.stringify({ current: activeCanvasUrl(), canvases: found }, null, 2) }] };
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
case 'switch_canvas': {
|
|
2390
|
+
const sp = z.object({ port: z.number().optional(), url: z.string().optional() }).parse(args || {});
|
|
2391
|
+
const surl = sp.url || (sp.port ? `http://127.0.0.1:${sp.port}` : null);
|
|
2392
|
+
if (!surl) throw new Error('Pass { port } or { url }');
|
|
2393
|
+
const r = await fetch(`${surl}/health`);
|
|
2394
|
+
if (!r.ok) throw new Error(`No canvas at ${surl} (HTTP ${r.status})`);
|
|
2395
|
+
ACTIVE_CANVAS_URL = surl;
|
|
2396
|
+
process.env.EXPRESS_SERVER_URL = surl;
|
|
2397
|
+
return { content: [{ type: 'text', text: `Switched to canvas ${surl}` }] };
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
case 'current_canvas': {
|
|
2401
|
+
const tgtCur = await import('./target.js');
|
|
2402
|
+
const t = tgtCur.getTarget();
|
|
2403
|
+
return { content: [{ type: 'text', text: t.kind === 'remote' ? `remote:${t.repo} (local canvas: ${activeCanvasUrl()})` : activeCanvasUrl() }] };
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
case 'switch_remote': {
|
|
2407
|
+
const sp = z.object({ target: z.string().optional() }).parse(args || {});
|
|
2408
|
+
const tgtSw = await import('./target.js');
|
|
2409
|
+
const out = await tgtSw.switchRemote(sp.target);
|
|
2410
|
+
if (out.target.kind === 'remote') {
|
|
2411
|
+
return { content: [{ type: 'text', text: `Switched to remote canvas ${out.target.repo} (${out.elements} elements loaded). Edits commit to GitHub; viewer updates on gh-pages. Site: https://${out.target.repo.replace('/', '.github.io/')}/` }] };
|
|
2412
|
+
}
|
|
2413
|
+
return { content: [{ type: 'text', text: `Switched back to local canvas ${activeCanvasUrl()}` }] };
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
case 'github_login': {
|
|
2417
|
+
const lp = z.object({ device_code: z.string().optional(), interval: z.number().optional(), site: z.string().optional(), repo: z.string().optional() }).parse(args || {});
|
|
2418
|
+
const tgtLogin = await import('./target.js');
|
|
2419
|
+
const { APP_CLIENT_ID: _cid } = await import('./target.js');
|
|
2420
|
+
const clientId = process.env.GITHUB_OAUTH_CLIENT_ID || _cid;
|
|
2421
|
+
if (!lp.device_code) {
|
|
2422
|
+
const existing = tgtLogin.resolveToken();
|
|
2423
|
+
if (existing) return { content: [{ type: 'text', text: 'Already logged in to GitHub (token from env/gh CLI/stored).' }] };
|
|
2424
|
+
if (!clientId) throw new Error('No token found and GITHUB_OAUTH_CLIENT_ID not set. Run `gh auth login`, set GITHUB_TOKEN, or configure an OAuth App client ID for device flow.');
|
|
2425
|
+
const dev = await tgtLogin.deviceStart(clientId);
|
|
2426
|
+
return { content: [{ type: 'text', text: `Ask the user to open ${dev.verification_uri} and enter code ${dev.user_code}, then call github_login again with { device_code: "${dev.device_code}", interval: ${dev.interval}${lp.site ? `, site: "${lp.site}"` : ''} }` }] };
|
|
2427
|
+
}
|
|
2428
|
+
// Restrict the token to the canvas repo when we know it (least privilege)
|
|
2429
|
+
let repositoryId: number | undefined;
|
|
2430
|
+
const repoRef = lp.repo || lp.site;
|
|
2431
|
+
if (repoRef) {
|
|
2432
|
+
try {
|
|
2433
|
+
const [owner, repo] = tgtLogin.parseRepo(repoRef).split('/');
|
|
2434
|
+
repositoryId = (await tgtLogin.repoId(owner!, repo!)) || undefined;
|
|
2435
|
+
} catch { /* unrestricted token */ }
|
|
2436
|
+
}
|
|
2437
|
+
const token = await tgtLogin.devicePoll(clientId, lp.device_code, lp.interval || 5, 300, repositoryId);
|
|
2438
|
+
const link = lp.site ? `\nBrowser login link: ${lp.site.replace(/\/$/, '')}/#token=${token}` : '';
|
|
2439
|
+
return { content: [{ type: 'text', text: `Logged in to GitHub (token stored${repositoryId ? `, restricted to repo ${repositoryId}` : ''}).${link}` }] };
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
case 'commit_scene': {
|
|
2443
|
+
const cp = z.object({ message: z.string().optional() }).parse(args || {});
|
|
2444
|
+
const tgtCommit = await import('./target.js');
|
|
2445
|
+
if (!tgtCommit.isRemote()) return { content: [{ type: 'text', text: 'Local mode — nothing to commit (local canvas syncs live).' }] };
|
|
2446
|
+
const out = await tgtCommit.commitNow(cp.message);
|
|
2447
|
+
return { content: [{ type: 'text', text: `Committed ${out.count} elements (${out.sha.slice(0, 7)}).` }] };
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
default:
|
|
2451
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
2452
|
+
}
|
|
2453
|
+
} catch (error) {
|
|
2454
|
+
logger.error(`Error handling tool call: ${(error as Error).message}`, { error });
|
|
2455
|
+
return {
|
|
2456
|
+
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
|
|
2457
|
+
isError: true
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
server.setRequestHandler(CallToolRequestSchema, (request: CallToolRequest) => {
|
|
2463
|
+
const task = toolQueue.then(() => handleToolCall(request));
|
|
2464
|
+
toolQueue = task.then(() => undefined, () => undefined);
|
|
2465
|
+
return task;
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
// Set up request handler for listing available tools
|
|
2469
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
2470
|
+
logger.info('Listing available tools');
|
|
2471
|
+
return { tools };
|
|
2472
|
+
});
|
|
2473
|
+
|
|
2474
|
+
// Start server
|
|
2475
|
+
async function runServer(): Promise<void> {
|
|
2476
|
+
try {
|
|
2477
|
+
logger.info('Starting Excalidraw MCP server...');
|
|
2478
|
+
|
|
2479
|
+
const transport = new StdioServerTransport();
|
|
2480
|
+
logger.debug('Connecting to stdio transport...');
|
|
2481
|
+
|
|
2482
|
+
// Remembered remote target FIRST: restore before serving so the first tool
|
|
2483
|
+
// call never races a half-loaded scene (active=remote but store empty).
|
|
2484
|
+
try {
|
|
2485
|
+
const tgtInit = await import('./target.js');
|
|
2486
|
+
const saved = tgtInit.loadSavedTarget();
|
|
2487
|
+
if (saved) {
|
|
2488
|
+
const out = await tgtInit.switchRemote(saved);
|
|
2489
|
+
logger.info(`Restored remote canvas ${saved} (${out.elements} elements) from .excalidrop.json`);
|
|
2490
|
+
}
|
|
2491
|
+
} catch (e) { logger.warn('No saved remote target (local mode): ' + (e as Error).message); }
|
|
2492
|
+
await server.connect(transport);
|
|
2493
|
+
logger.info('Excalidraw MCP server running on stdio');
|
|
2494
|
+
|
|
2495
|
+
process.stdin.resume();
|
|
2496
|
+
} catch (error) {
|
|
2497
|
+
logger.error('Error starting server:', error);
|
|
2498
|
+
process.stderr.write(`Failed to start MCP server: ${(error as Error).message}\n${(error as Error).stack}\n`);
|
|
2499
|
+
process.exit(1);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
// Add global error handlers
|
|
2504
|
+
process.on('uncaughtException', (error: Error) => {
|
|
2505
|
+
logger.error('Uncaught exception:', error);
|
|
2506
|
+
process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
|
|
2507
|
+
setTimeout(() => process.exit(1), 1000);
|
|
2508
|
+
});
|
|
2509
|
+
|
|
2510
|
+
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
|
|
2511
|
+
logger.error('Unhandled promise rejection:', reason);
|
|
2512
|
+
process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`);
|
|
2513
|
+
setTimeout(() => process.exit(1), 1000);
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
// For testing and debugging purposes
|
|
2517
|
+
if (process.env.DEBUG === 'true') {
|
|
2518
|
+
logger.debug('Debug mode enabled');
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function getErrorCode(error: unknown): string | undefined {
|
|
2522
|
+
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
2523
|
+
const code = (error as { code?: unknown }).code;
|
|
2524
|
+
return typeof code === 'string' ? code : undefined;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
return undefined;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function resolveEntrypointPath(filePath: string | undefined): string | null {
|
|
2531
|
+
if (!filePath) return null;
|
|
2532
|
+
|
|
2533
|
+
try {
|
|
2534
|
+
return fs.realpathSync(filePath);
|
|
2535
|
+
} catch (error) {
|
|
2536
|
+
const code = getErrorCode(error);
|
|
2537
|
+
if (code !== 'ENOENT') {
|
|
2538
|
+
logger.warn(`fs.realpathSync failed for "${filePath}", falling back to path.resolve.`, {
|
|
2539
|
+
code,
|
|
2540
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
return path.resolve(filePath);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Start the server if this file is run directly.
|
|
2548
|
+
// npm/npx commonly invoke package bins through symlinks; compare real paths so
|
|
2549
|
+
// the stdio transport still starts from those standard install paths.
|
|
2550
|
+
if (resolveEntrypointPath(fileURLToPath(import.meta.url)) === resolveEntrypointPath(process.argv[1])) {
|
|
2551
|
+
runServer().catch(error => {
|
|
2552
|
+
logger.error('Failed to start server:', error);
|
|
2553
|
+
process.exit(1);
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
export default runServer;
|