interactive-drawer 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -0
- package/dist/checkpoint-store.d.ts +39 -0
- package/dist/index.js +1606 -0
- package/dist/mcp-app.html +124 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +698 -0
- package/dist/shared.d.ts +32 -0
- package/dist/viewer/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
- package/dist/viewer/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
- package/dist/viewer/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
- package/dist/viewer/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
- package/dist/viewer/assets/Tableau10-B-NsZVaP.js +1 -0
- package/dist/viewer/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
- package/dist/viewer/assets/ar-SA-G6X2FPQ2-DadQCH2-.js +10 -0
- package/dist/viewer/assets/arc-CQgrOt5x.js +1 -0
- package/dist/viewer/assets/array-BKyUJesY.js +1 -0
- package/dist/viewer/assets/az-AZ-76LH7QW2-DQfUuqqi.js +1 -0
- package/dist/viewer/assets/bg-BG-XCXSNQG7-BB40UBFK.js +5 -0
- package/dist/viewer/assets/blockDiagram-c4efeb88-BaF5AmVI.js +118 -0
- package/dist/viewer/assets/bn-BD-2XOGV67Q-uzDHfiqC.js +5 -0
- package/dist/viewer/assets/c4Diagram-c83219d4-hWhr-GlJ.js +10 -0
- package/dist/viewer/assets/ca-ES-6MX7JW3Y-BW7AckRl.js +8 -0
- package/dist/viewer/assets/channel-CXejbLtU.js +1 -0
- package/dist/viewer/assets/classDiagram-beda092f-CymCu8CT.js +2 -0
- package/dist/viewer/assets/classDiagram-v2-2358418a-DJAISyxA.js +2 -0
- package/dist/viewer/assets/clone-CfbodbqL.js +1 -0
- package/dist/viewer/assets/createText-1719965b-BnEANXd0.js +7 -0
- package/dist/viewer/assets/cs-CZ-2BRQDIVT-DuVk-qPr.js +11 -0
- package/dist/viewer/assets/da-DK-5WZEPLOC-DFahLFHr.js +5 -0
- package/dist/viewer/assets/de-DE-XR44H4JA-DoRzoyVr.js +8 -0
- package/dist/viewer/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
- package/dist/viewer/assets/directory-open-4ed118d0-CunoC1EB.js +1 -0
- package/dist/viewer/assets/edges-96097737-BaZooScF.js +4 -0
- package/dist/viewer/assets/el-GR-BZB4AONW--pwLECF8.js +10 -0
- package/dist/viewer/assets/erDiagram-0228fc6a-57itH3DJ.js +51 -0
- package/dist/viewer/assets/es-ES-U4NZUMDT-C1NjAcMt.js +9 -0
- package/dist/viewer/assets/eu-ES-A7QVB2H4-Ck1DCJa4.js +11 -0
- package/dist/viewer/assets/fa-IR-HGAKTJCU-tLLl_5WD.js +8 -0
- package/dist/viewer/assets/fi-FI-Z5N7JZ37-DVtDP0gn.js +6 -0
- package/dist/viewer/assets/file-open-002ab408-DIuFHtCF.js +1 -0
- package/dist/viewer/assets/file-open-7c801643-684qeFg4.js +1 -0
- package/dist/viewer/assets/file-save-3189631c-C1wFhQhH.js +1 -0
- package/dist/viewer/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
- package/dist/viewer/assets/flowDb-c6c81e3f-C7AEsokK.js +10 -0
- package/dist/viewer/assets/flowDiagram-50d868cf-CssTUsiu.js +4 -0
- package/dist/viewer/assets/flowDiagram-v2-4f6560a1-D0GG9NIT.js +1 -0
- package/dist/viewer/assets/flowchart-elk-definition-6af322e1-CRyPEh1i.js +139 -0
- package/dist/viewer/assets/fr-FR-RHASNOE6-T_qZ0zEK.js +9 -0
- package/dist/viewer/assets/ganttDiagram-a2739b55-DsyTeswu.js +257 -0
- package/dist/viewer/assets/gitGraphDiagram-82fe8481-D85sePVV.js +70 -0
- package/dist/viewer/assets/gl-ES-HMX3MZ6V-IW3daBzE.js +10 -0
- package/dist/viewer/assets/graph-i-2SK2w1.js +1 -0
- package/dist/viewer/assets/he-IL-6SHJWFNN-CUxTwbdR.js +10 -0
- package/dist/viewer/assets/hi-IN-IWLTKZ5I-DvQFf43e.js +4 -0
- package/dist/viewer/assets/hu-HU-A5ZG7DT2-CO4Q4G1z.js +7 -0
- package/dist/viewer/assets/id-ID-SAP4L64H-Bf_UXdp6.js +10 -0
- package/dist/viewer/assets/image-blob-reduce.esm-D6s-rqMO.js +7 -0
- package/dist/viewer/assets/index-5325376f-BRzpdHqA.js +1 -0
- package/dist/viewer/assets/index-BDC2daNC.js +275 -0
- package/dist/viewer/assets/index-BcAUh2Nm.css +1 -0
- package/dist/viewer/assets/index-rEzfVlKD.js +97 -0
- package/dist/viewer/assets/infoDiagram-8eee0895-BkRxMuec.js +7 -0
- package/dist/viewer/assets/init-Gi6I4Gst.js +1 -0
- package/dist/viewer/assets/it-IT-JPQ66NNP-AwjkJc7y.js +11 -0
- package/dist/viewer/assets/ja-JP-DBVTYXUO-07sucMhN.js +8 -0
- package/dist/viewer/assets/journeyDiagram-c64418c1-6-ZVFaXG.js +139 -0
- package/dist/viewer/assets/kaa-6HZHGXH3-CXk_Wo6h.js +1 -0
- package/dist/viewer/assets/kab-KAB-ZGHBKWFO-D88OOW68.js +8 -0
- package/dist/viewer/assets/katex-BkgLa5T_.js +261 -0
- package/dist/viewer/assets/kk-KZ-P5N5QNE5-TzX_GmjC.js +1 -0
- package/dist/viewer/assets/km-KH-HSX4SM5Z-Dy-luINx.js +11 -0
- package/dist/viewer/assets/ko-KR-MTYHY66A-Bno63g5m.js +9 -0
- package/dist/viewer/assets/ku-TR-6OUDTVRD-Daw-B1bN.js +9 -0
- package/dist/viewer/assets/layout-BSQSdjji.js +1 -0
- package/dist/viewer/assets/line-DajA25FK.js +1 -0
- package/dist/viewer/assets/linear-BUWo07xp.js +1 -0
- package/dist/viewer/assets/lt-LT-XHIRWOB4-C3_9a2Cz.js +3 -0
- package/dist/viewer/assets/lv-LV-5QDEKY6T-3H9zX9pi.js +7 -0
- package/dist/viewer/assets/mindmap-definition-8da855dc-DIeJ0MUj.js +425 -0
- package/dist/viewer/assets/mr-IN-CRQNXWMA-DLf6m35n.js +13 -0
- package/dist/viewer/assets/my-MM-5M5IBNSE-CW_RWZBw.js +1 -0
- package/dist/viewer/assets/nb-NO-T6EIAALU-B0ZpuoZJ.js +10 -0
- package/dist/viewer/assets/nl-NL-IS3SIHDZ-nZ0JmIIM.js +8 -0
- package/dist/viewer/assets/nn-NO-6E72VCQL-C_dDeYAp.js +8 -0
- package/dist/viewer/assets/oc-FR-POXYY2M6-CsidHxkc.js +8 -0
- package/dist/viewer/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/viewer/assets/pa-IN-N4M65BXN-DuEUGA-r.js +4 -0
- package/dist/viewer/assets/path-CbwjOpE9.js +1 -0
- package/dist/viewer/assets/pica-B90j7hbh.js +7 -0
- package/dist/viewer/assets/pieDiagram-a8764435-CY0HndHR.js +35 -0
- package/dist/viewer/assets/pl-PL-T2D74RX3-D87DC8D9.js +9 -0
- package/dist/viewer/assets/pt-BR-5N22H2LF-BtSoEeex.js +9 -0
- package/dist/viewer/assets/pt-PT-UZXXM6DQ-D4bk4PLe.js +9 -0
- package/dist/viewer/assets/quadrantDiagram-1e28029f-DGki2tT-.js +7 -0
- package/dist/viewer/assets/requirementDiagram-08caed73-BkzKWnbZ.js +52 -0
- package/dist/viewer/assets/ro-RO-JPDTUUEW-Ra2U-BVb.js +11 -0
- package/dist/viewer/assets/roundRect-0PYZxl1G.js +1 -0
- package/dist/viewer/assets/ru-RU-B4JR7IUQ-DAMFn0BJ.js +9 -0
- package/dist/viewer/assets/sankeyDiagram-a04cb91d-C1WQeKAS.js +8 -0
- package/dist/viewer/assets/sequenceDiagram-c5b8d532-BQy1AIyh.js +122 -0
- package/dist/viewer/assets/si-LK-N5RQ5JYF-DtRsuwQ6.js +1 -0
- package/dist/viewer/assets/sk-SK-C5VTKIMK-Dph_9bOP.js +6 -0
- package/dist/viewer/assets/sl-SI-NN7IZMDC-CzYryWZd.js +6 -0
- package/dist/viewer/assets/stateDiagram-1ecb1508-f7QEpJSj.js +1 -0
- package/dist/viewer/assets/stateDiagram-v2-c2b004d7-B8MjoGqH.js +1 -0
- package/dist/viewer/assets/styles-b4e223ce-DfLT12Nx.js +160 -0
- package/dist/viewer/assets/styles-ca3715f6-CnuYZzQT.js +207 -0
- package/dist/viewer/assets/styles-d45a18b0-Ci9tlpxY.js +116 -0
- package/dist/viewer/assets/subset-shared.chunk-B9IoriSA.js +84 -0
- package/dist/viewer/assets/subset-worker.chunk-Bpe7TPYu.js +1 -0
- package/dist/viewer/assets/sv-SE-XGPEYMSR-B4Y9n4LB.js +10 -0
- package/dist/viewer/assets/svgDrawCommon-b86b1483-4HzjGm6B.js +1 -0
- package/dist/viewer/assets/ta-IN-2NMHFXQM-DE7pdoSM.js +9 -0
- package/dist/viewer/assets/th-TH-HPSO5L25-Dd8iADEt.js +2 -0
- package/dist/viewer/assets/timeline-definition-faaaa080-DYgcS_Y5.js +61 -0
- package/dist/viewer/assets/tr-TR-DEFEU3FU-DTOmyIrt.js +7 -0
- package/dist/viewer/assets/uk-UA-QMV73CPH-B05zHRYx.js +6 -0
- package/dist/viewer/assets/vi-VN-M7AON7JQ-ChW6Q5sn.js +5 -0
- package/dist/viewer/assets/xychartDiagram-f5964ef8-BrW97HI1.js +7 -0
- package/dist/viewer/assets/zh-CN-LNUGB5OW-DF3LdZpE.js +10 -0
- package/dist/viewer/assets/zh-HK-E62DVLB3-C3QgGFS0.js +1 -0
- package/dist/viewer/assets/zh-TW-RAJ6MFWO-ciUClBxf.js +9 -0
- package/dist/viewer/index.html +13 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/shared.ts
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
async function resolveElements(parsed, store) {
|
|
15
|
+
const restoreEl = parsed.find((el) => el.type === "restoreCheckpoint");
|
|
16
|
+
let resolvedElements;
|
|
17
|
+
if (restoreEl?.id) {
|
|
18
|
+
const base = await store.load(restoreEl.id);
|
|
19
|
+
if (!base) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
error: `Checkpoint "${restoreEl.id}" not found \u2014 it may have expired or never existed. Please recreate the diagram from scratch.`
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const deleteIds = /* @__PURE__ */ new Set();
|
|
26
|
+
for (const el of parsed) {
|
|
27
|
+
if (el.type === "delete") {
|
|
28
|
+
for (const id of String(el.ids ?? el.id).split(",")) deleteIds.add(id.trim());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const baseFiltered = base.elements.filter(
|
|
32
|
+
(el) => !deleteIds.has(el.id) && !deleteIds.has(el.containerId)
|
|
33
|
+
);
|
|
34
|
+
const newEls = parsed.filter(
|
|
35
|
+
(el) => el.type !== "restoreCheckpoint" && el.type !== "delete"
|
|
36
|
+
);
|
|
37
|
+
resolvedElements = [...baseFiltered, ...newEls];
|
|
38
|
+
} else {
|
|
39
|
+
resolvedElements = parsed.filter((el) => el.type !== "delete");
|
|
40
|
+
}
|
|
41
|
+
const cameras = parsed.filter((el) => el.type === "cameraUpdate");
|
|
42
|
+
const badRatio = cameras.find((c) => {
|
|
43
|
+
if (!c.width || !c.height) return false;
|
|
44
|
+
const ratio = c.width / c.height;
|
|
45
|
+
return Math.abs(ratio - 4 / 3) > 0.15;
|
|
46
|
+
});
|
|
47
|
+
const ratioHint = badRatio ? `
|
|
48
|
+
Tip: your cameraUpdate used ${badRatio.width}x${badRatio.height} \u2014 try to stick with 4:3 aspect ratio (e.g. 400x300, 800x600) in future.` : "";
|
|
49
|
+
return { ok: true, resolvedElements, ratioHint };
|
|
50
|
+
}
|
|
51
|
+
function generateCheckpointId() {
|
|
52
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 18);
|
|
53
|
+
}
|
|
54
|
+
var MAX_INPUT_BYTES, RECALL_CHEAT_SHEET, PSEUDO_TYPES;
|
|
55
|
+
var init_shared = __esm({
|
|
56
|
+
"src/shared.ts"() {
|
|
57
|
+
"use strict";
|
|
58
|
+
MAX_INPUT_BYTES = 5 * 1024 * 1024;
|
|
59
|
+
RECALL_CHEAT_SHEET = `# Excalidraw Element Format
|
|
60
|
+
|
|
61
|
+
Thanks for calling read_me! Do NOT call it again in this conversation \u2014 you will not see anything new. Now use create_view to draw.
|
|
62
|
+
|
|
63
|
+
## Basic Workflow
|
|
64
|
+
|
|
65
|
+
Follow these steps in order:
|
|
66
|
+
|
|
67
|
+
1. **create_session** \u2014 Call this first. You will get a session key and a viewer URL (using the server base URL shown above).
|
|
68
|
+
**IMPORTANT: Tell the user to open the viewer URL in their browser NOW**, before you start drawing. This way they can watch the diagram appear in real time.
|
|
69
|
+
Always use the exact viewer URL returned by create_session \u2014 never guess or construct URLs yourself.
|
|
70
|
+
|
|
71
|
+
2. **read_me** \u2014 You already called this (you're reading it now). No need to call again.
|
|
72
|
+
|
|
73
|
+
3. **create_view** \u2014 Send elements JSON with the session key to draw the diagram. The viewer page updates automatically.
|
|
74
|
+
You can call create_view multiple times to update the diagram (use restoreCheckpoint for incremental edits).
|
|
75
|
+
|
|
76
|
+
## Drawing Style (ASK BEFORE DRAWING)
|
|
77
|
+
|
|
78
|
+
Before your first create_view call, **ask the user** which font and sloppiness (roughness) they prefer. Present the combinations below and let them choose. If they have no preference, use the defaults (Excalifont + Artist).
|
|
79
|
+
|
|
80
|
+
### Fonts (fontFamily)
|
|
81
|
+
| Value | Name | Style |
|
|
82
|
+
|-------|------|-------|
|
|
83
|
+
| \`1\` | **Excalifont** (default) | Hand-drawn, casual \u2014 matches Excalidraw's sketchy aesthetic |
|
|
84
|
+
| \`2\` | **Nunito** | Clean, rounded sans-serif \u2014 polished and modern |
|
|
85
|
+
| \`3\` | **Comic Shanns** | Comic/playful \u2014 informal and fun |
|
|
86
|
+
|
|
87
|
+
Set \`fontFamily\` on text elements and in \`label\` objects: \`"fontFamily": 2\`
|
|
88
|
+
|
|
89
|
+
### Sloppiness (roughness)
|
|
90
|
+
| Value | Name | Style |
|
|
91
|
+
|-------|------|-------|
|
|
92
|
+
| \`0\` | **Architect** | Precise, clean lines \u2014 technical / formal |
|
|
93
|
+
| \`1\` | **Artist** (default) | Slightly rough \u2014 natural hand-drawn feel |
|
|
94
|
+
| \`2\` | **Cartoonist** | Very rough, wobbly \u2014 playful / sketch-like |
|
|
95
|
+
|
|
96
|
+
Set \`roughness\` on shape and arrow elements: \`"roughness": 0\`
|
|
97
|
+
|
|
98
|
+
### Recommended Combinations
|
|
99
|
+
| Combination | Font | Roughness | Best For |
|
|
100
|
+
|-------------|------|-----------|----------|
|
|
101
|
+
| **Sketch** (default) | Excalifont (1) | Artist (1) | General diagrams, brainstorming |
|
|
102
|
+
| **Clean** | Nunito (2) | Architect (0) | Technical docs, presentations |
|
|
103
|
+
| **Playful** | Comic Shanns (3) | Cartoonist (2) | Informal, fun, comics |
|
|
104
|
+
| **Hand-drawn formal** | Excalifont (1) | Architect (0) | Hand-written notes, precise sketches |
|
|
105
|
+
| **Polished casual** | Nunito (2) | Artist (1) | Blog posts, explainers |
|
|
106
|
+
| **Comic precise** | Comic Shanns (3) | Architect (0) | Clean comic panels, game design |
|
|
107
|
+
|
|
108
|
+
Once the user picks a style, apply the chosen \`fontFamily\` and \`roughness\` **consistently to all elements** throughout the diagram. Do not mix styles unless the user explicitly requests it.
|
|
109
|
+
|
|
110
|
+
## Color Palette (use consistently across all tools)
|
|
111
|
+
|
|
112
|
+
### Primary Colors
|
|
113
|
+
| Name | Hex | Use |
|
|
114
|
+
|------|-----|-----|
|
|
115
|
+
| Blue | \`#4a9eed\` | Primary actions, links, data series 1 |
|
|
116
|
+
| Amber | \`#f59e0b\` | Warnings, highlights, data series 2 |
|
|
117
|
+
| Green | \`#22c55e\` | Success, positive, data series 3 |
|
|
118
|
+
| Red | \`#ef4444\` | Errors, negative, data series 4 |
|
|
119
|
+
| Purple | \`#8b5cf6\` | Accents, special items, data series 5 |
|
|
120
|
+
| Pink | \`#ec4899\` | Decorative, data series 6 |
|
|
121
|
+
| Cyan | \`#06b6d4\` | Info, secondary, data series 7 |
|
|
122
|
+
| Lime | \`#84cc16\` | Extra, data series 8 |
|
|
123
|
+
|
|
124
|
+
### Excalidraw Fills (pastel, for shape backgrounds)
|
|
125
|
+
| Color | Hex | Good For |
|
|
126
|
+
|-------|-----|----------|
|
|
127
|
+
| Light Blue | \`#a5d8ff\` | Input, sources, primary nodes |
|
|
128
|
+
| Light Green | \`#b2f2bb\` | Success, output, completed |
|
|
129
|
+
| Light Orange | \`#ffd8a8\` | Warning, pending, external |
|
|
130
|
+
| Light Purple | \`#d0bfff\` | Processing, middleware, special |
|
|
131
|
+
| Light Red | \`#ffc9c9\` | Error, critical, alerts |
|
|
132
|
+
| Light Yellow | \`#fff3bf\` | Notes, decisions, planning |
|
|
133
|
+
| Light Teal | \`#c3fae8\` | Storage, data, memory |
|
|
134
|
+
| Light Pink | \`#eebefa\` | Analytics, metrics |
|
|
135
|
+
|
|
136
|
+
### Background Zones (use with opacity: 30 for layered diagrams)
|
|
137
|
+
| Color | Hex | Good For |
|
|
138
|
+
|-------|-----|----------|
|
|
139
|
+
| Blue zone | \`#dbe4ff\` | UI / frontend layer |
|
|
140
|
+
| Purple zone | \`#e5dbff\` | Logic / agent layer |
|
|
141
|
+
| Green zone | \`#d3f9d8\` | Data / tool layer |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Excalidraw Elements
|
|
146
|
+
|
|
147
|
+
### Required Fields (all elements)
|
|
148
|
+
\`type\`, \`id\` (unique string), \`x\`, \`y\`, \`width\`, \`height\`
|
|
149
|
+
|
|
150
|
+
### Defaults (skip these)
|
|
151
|
+
strokeColor="#1e1e1e", backgroundColor="transparent", fillStyle="solid", strokeWidth=2, roughness=1, opacity=100
|
|
152
|
+
Canvas background is white.
|
|
153
|
+
|
|
154
|
+
### Element Types
|
|
155
|
+
|
|
156
|
+
**Rectangle**: \`{ "type": "rectangle", "id": "r1", "x": 100, "y": 100, "width": 200, "height": 100 }\`
|
|
157
|
+
- \`roundness: { type: 3 }\` for rounded corners
|
|
158
|
+
- \`backgroundColor: "#a5d8ff"\`, \`fillStyle: "solid"\` for filled
|
|
159
|
+
|
|
160
|
+
**Ellipse**: \`{ "type": "ellipse", "id": "e1", "x": 100, "y": 100, "width": 150, "height": 150 }\`
|
|
161
|
+
|
|
162
|
+
**Diamond**: \`{ "type": "diamond", "id": "d1", "x": 100, "y": 100, "width": 150, "height": 150 }\`
|
|
163
|
+
|
|
164
|
+
**Labeled shape (PREFERRED)**: Add \`label\` to any shape for auto-centered text. No separate text element needed.
|
|
165
|
+
\`{ "type": "rectangle", "id": "r1", "x": 100, "y": 100, "width": 200, "height": 80, "label": { "text": "Hello", "fontSize": 20 } }\`
|
|
166
|
+
- Works on rectangle, ellipse, diamond
|
|
167
|
+
- Text auto-centers and container auto-resizes to fit
|
|
168
|
+
- Saves tokens vs separate text elements
|
|
169
|
+
|
|
170
|
+
**Labeled arrow**: \`"label": { "text": "connects" }\` on an arrow element.
|
|
171
|
+
|
|
172
|
+
**Standalone text** (titles, annotations only):
|
|
173
|
+
\`{ "type": "text", "id": "t1", "x": 150, "y": 138, "text": "Hello", "fontSize": 20 }\`
|
|
174
|
+
- x is the LEFT edge of the text. To center text at position cx: set x = cx - estimatedWidth/2
|
|
175
|
+
- estimatedWidth \u2248 text.length \xD7 fontSize \xD7 0.5
|
|
176
|
+
- Do NOT rely on textAlign or width for positioning \u2014 they only affect multi-line wrapping
|
|
177
|
+
|
|
178
|
+
**Arrow**: \`{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 200, "height": 0, "points": [[0,0],[200,0]], "endArrowhead": "arrow" }\`
|
|
179
|
+
- points: [dx, dy] offsets from element x,y
|
|
180
|
+
- endArrowhead: null | "arrow" | "bar" | "dot" | "triangle"
|
|
181
|
+
|
|
182
|
+
### Arrow Bindings
|
|
183
|
+
Arrow: \`"startBinding": { "elementId": "r1", "fixedPoint": [1, 0.5] }\`
|
|
184
|
+
fixedPoint: top=[0.5,0], bottom=[0.5,1], left=[0,0.5], right=[1,0.5]
|
|
185
|
+
|
|
186
|
+
**cameraUpdate** (pseudo-element \u2014 controls the viewport, not drawn):
|
|
187
|
+
\`{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 0, "y": 0 }\`
|
|
188
|
+
- x, y: top-left corner of the visible area (scene coordinates)
|
|
189
|
+
- width, height: size of the visible area \u2014 MUST be 4:3 ratio (400\xD7300, 600\xD7450, 800\xD7600, 1200\xD7900, 1600\xD71200)
|
|
190
|
+
- Animates smoothly between positions \u2014 use multiple cameraUpdates to guide attention as you draw
|
|
191
|
+
- No \`id\` needed \u2014 this is not a drawn element
|
|
192
|
+
|
|
193
|
+
**delete** (pseudo-element \u2014 removes elements by id):
|
|
194
|
+
\`{ "type": "delete", "ids": "b2,a1,t3" }\`
|
|
195
|
+
- Comma-separated list of element ids to remove
|
|
196
|
+
- Also removes bound text elements (matching \`containerId\`)
|
|
197
|
+
- Place AFTER the elements you want to remove
|
|
198
|
+
- Never reuse a deleted id \u2014 always assign new ids to replacements
|
|
199
|
+
|
|
200
|
+
### Drawing Order (CRITICAL for streaming)
|
|
201
|
+
- Array order = z-order (first = back, last = front)
|
|
202
|
+
- **Emit progressively**: background \u2192 shape \u2192 its label \u2192 its arrows \u2192 next shape
|
|
203
|
+
- BAD: all rectangles \u2192 all texts \u2192 all arrows
|
|
204
|
+
- GOOD: bg_shape \u2192 shape1 \u2192 text1 \u2192 arrow1 \u2192 shape2 \u2192 text2 \u2192 ...
|
|
205
|
+
|
|
206
|
+
### Example: Two connected labeled boxes
|
|
207
|
+
\`\`\`json
|
|
208
|
+
[
|
|
209
|
+
{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 50, "y": 50 },
|
|
210
|
+
{ "type": "rectangle", "id": "b1", "x": 100, "y": 100, "width": 200, "height": 100, "roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid", "label": { "text": "Start", "fontSize": 20 } },
|
|
211
|
+
{ "type": "rectangle", "id": "b2", "x": 450, "y": 100, "width": 200, "height": 100, "roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid", "label": { "text": "End", "fontSize": 20 } },
|
|
212
|
+
{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 150, "height": 0, "points": [[0,0],[150,0]], "endArrowhead": "arrow", "startBinding": { "elementId": "b1", "fixedPoint": [1, 0.5] }, "endBinding": { "elementId": "b2", "fixedPoint": [0, 0.5] } }
|
|
213
|
+
]
|
|
214
|
+
\`\`\`
|
|
215
|
+
|
|
216
|
+
### Camera & Sizing (CRITICAL for readability)
|
|
217
|
+
|
|
218
|
+
The diagram displays inline at ~700px width. Design for this constraint.
|
|
219
|
+
|
|
220
|
+
**Recommended camera sizes (4:3 aspect ratio ONLY):**
|
|
221
|
+
- Camera **S**: width 400, height 300 \u2014 close-up on a small group (2-3 elements)
|
|
222
|
+
- Camera **M**: width 600, height 450 \u2014 medium view, a section of a diagram
|
|
223
|
+
- Camera **L**: width 800, height 600 \u2014 standard full diagram (DEFAULT)
|
|
224
|
+
- Camera **XL**: width 1200, height 900 \u2014 large diagram overview. WARNING: font size smaller than 18 is unreadable
|
|
225
|
+
- Camera **XXL**: width 1600, height 1200 \u2014 panorama / final overview of complex diagrams. WARNING: minimum readable font size is 21
|
|
226
|
+
|
|
227
|
+
ALWAYS use one of these exact sizes. Non-4:3 viewports cause distortion.
|
|
228
|
+
|
|
229
|
+
**Font size rules:**
|
|
230
|
+
- Minimum fontSize: **16** for body text, labels, descriptions
|
|
231
|
+
- Minimum fontSize: **20** for titles and headings
|
|
232
|
+
- Minimum fontSize: **14** for secondary annotations only (sparingly)
|
|
233
|
+
- NEVER use fontSize below 14 \u2014 it becomes unreadable at display scale
|
|
234
|
+
|
|
235
|
+
**Element sizing rules:**
|
|
236
|
+
- Minimum shape size: 120\xD760 for labeled rectangles/ellipses
|
|
237
|
+
- Leave 20-30px gaps between elements minimum
|
|
238
|
+
- Prefer fewer, larger elements over many tiny ones
|
|
239
|
+
|
|
240
|
+
ALWAYS start with a \`cameraUpdate\` as the FIRST element. For example:
|
|
241
|
+
\`{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 0, "y": 0 }\`
|
|
242
|
+
|
|
243
|
+
- x, y: top-left corner of visible area (scene coordinates)
|
|
244
|
+
- ALWAYS emit the cameraUpdate BEFORE drawing the elements it frames \u2014 camera moves first, then content appears
|
|
245
|
+
- The camera animates smoothly between positions
|
|
246
|
+
- Leave padding: don't match camera size to content size exactly (e.g., 500px content in 800x600 camera)
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
\`{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 0, "y": 0 }\` \u2014 standard view
|
|
250
|
+
\`{ "type": "cameraUpdate", "width": 400, "height": 300, "x": 200, "y": 100 }\` \u2014 zoom into a detail
|
|
251
|
+
\`{ "type": "cameraUpdate", "width": 1600, "height": 1200, "x": -50, "y": -50 }\` \u2014 panorama overview
|
|
252
|
+
|
|
253
|
+
Tip: For large diagrams, emit a cameraUpdate to focus on each section as you draw it.
|
|
254
|
+
|
|
255
|
+
## Diagram Example
|
|
256
|
+
|
|
257
|
+
Example prompt: "Explain how photosynthesis works"
|
|
258
|
+
|
|
259
|
+
Uses 2 camera positions: start zoomed in (M) for title, then zoom out (L) to reveal the full diagram. Sun art drawn last as a finishing touch.
|
|
260
|
+
|
|
261
|
+
- **Camera 1** (400x300): Draw the title "Photosynthesis" and formula subtitle zoomed in
|
|
262
|
+
- **Camera 2** (800x600): Zoom out \u2014 draw the leaf zone, process flow (Light Reactions \u2192 Calvin Cycle), inputs (Sunlight, Water, CO2), outputs (O2, Glucose), and finally a cute 8-ray sun
|
|
263
|
+
|
|
264
|
+
\`\`\`json
|
|
265
|
+
[
|
|
266
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":200,"y":-20},
|
|
267
|
+
{"type":"text","id":"ti","x":280,"y":10,"text":"Photosynthesis","fontSize":28,"strokeColor":"#1e1e1e"},
|
|
268
|
+
{"type":"text","id":"fo","x":245,"y":48,"text":"6CO2 + 6H2O --> C6H12O6 + 6O2","fontSize":16,"strokeColor":"#757575"},
|
|
269
|
+
{"type":"cameraUpdate","width":800,"height":600,"x":0,"y":-20},
|
|
270
|
+
{"type":"rectangle","id":"lf","x":150,"y":90,"width":520,"height":380,"backgroundColor":"#d3f9d8","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#22c55e","strokeWidth":1,"opacity":35},
|
|
271
|
+
{"type":"text","id":"lfl","x":170,"y":96,"text":"Inside the Leaf","fontSize":16,"strokeColor":"#15803d"},
|
|
272
|
+
{"type":"rectangle","id":"lr","x":190,"y":190,"width":160,"height":70,"backgroundColor":"#fff3bf","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#f59e0b","label":{"text":"Light Reactions","fontSize":16}},
|
|
273
|
+
{"type":"arrow","id":"a1","x":350,"y":225,"width":120,"height":0,"points":[[0,0],[120,0]],"strokeColor":"#1e1e1e","strokeWidth":2,"endArrowhead":"arrow","label":{"text":"ATP","fontSize":14}},
|
|
274
|
+
{"type":"rectangle","id":"cc","x":470,"y":190,"width":160,"height":70,"backgroundColor":"#d0bfff","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#8b5cf6","label":{"text":"Calvin Cycle","fontSize":16}},
|
|
275
|
+
{"type":"rectangle","id":"sl","x":10,"y":200,"width":120,"height":50,"backgroundColor":"#fff3bf","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#f59e0b","label":{"text":"Sunlight","fontSize":16}},
|
|
276
|
+
{"type":"arrow","id":"a2","x":130,"y":225,"width":60,"height":0,"points":[[0,0],[60,0]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":"arrow"},
|
|
277
|
+
{"type":"rectangle","id":"wa","x":200,"y":360,"width":140,"height":50,"backgroundColor":"#a5d8ff","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#4a9eed","label":{"text":"Water (H2O)","fontSize":16}},
|
|
278
|
+
{"type":"arrow","id":"a3","x":270,"y":360,"width":0,"height":-100,"points":[[0,0],[0,-100]],"strokeColor":"#4a9eed","strokeWidth":2,"endArrowhead":"arrow"},
|
|
279
|
+
{"type":"rectangle","id":"co","x":480,"y":360,"width":130,"height":50,"backgroundColor":"#ffd8a8","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#f59e0b","label":{"text":"CO2","fontSize":16}},
|
|
280
|
+
{"type":"arrow","id":"a4","x":545,"y":360,"width":0,"height":-100,"points":[[0,0],[0,-100]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":"arrow"},
|
|
281
|
+
{"type":"rectangle","id":"ox","x":540,"y":100,"width":100,"height":40,"backgroundColor":"#ffc9c9","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#ef4444","label":{"text":"O2","fontSize":16}},
|
|
282
|
+
{"type":"arrow","id":"a5","x":310,"y":190,"width":230,"height":-50,"points":[[0,0],[230,-50]],"strokeColor":"#ef4444","strokeWidth":2,"endArrowhead":"arrow"},
|
|
283
|
+
{"type":"rectangle","id":"gl","x":690,"y":195,"width":120,"height":60,"backgroundColor":"#c3fae8","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#22c55e","label":{"text":"Glucose","fontSize":18}},
|
|
284
|
+
{"type":"arrow","id":"a6","x":630,"y":225,"width":60,"height":0,"points":[[0,0],[60,0]],"strokeColor":"#22c55e","strokeWidth":2,"endArrowhead":"arrow"},
|
|
285
|
+
{"type":"ellipse","id":"sun","x":30,"y":110,"width":50,"height":50,"backgroundColor":"#fff3bf","fillStyle":"solid","strokeColor":"#f59e0b","strokeWidth":2},
|
|
286
|
+
{"type":"arrow","id":"r1","x":55,"y":108,"width":0,"height":-14,"points":[[0,0],[0,-14]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null},
|
|
287
|
+
{"type":"arrow","id":"r2","x":55,"y":162,"width":0,"height":14,"points":[[0,0],[0,14]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null},
|
|
288
|
+
{"type":"arrow","id":"r3","x":28,"y":135,"width":-14,"height":0,"points":[[0,0],[-14,0]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null},
|
|
289
|
+
{"type":"arrow","id":"r4","x":82,"y":135,"width":14,"height":0,"points":[[0,0],[14,0]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null},
|
|
290
|
+
{"type":"arrow","id":"r5","x":73,"y":117,"width":10,"height":-10,"points":[[0,0],[10,-10]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null},
|
|
291
|
+
{"type":"arrow","id":"r6","x":37,"y":117,"width":-10,"height":-10,"points":[[0,0],[-10,-10]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null},
|
|
292
|
+
{"type":"arrow","id":"r7","x":73,"y":153,"width":10,"height":10,"points":[[0,0],[10,10]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null},
|
|
293
|
+
{"type":"arrow","id":"r8","x":37,"y":153,"width":-10,"height":10,"points":[[0,0],[-10,10]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":null,"startArrowhead":null}
|
|
294
|
+
]
|
|
295
|
+
\`\`\`
|
|
296
|
+
|
|
297
|
+
Common mistakes to avoid:
|
|
298
|
+
- **Camera size must match content with padding** \u2014 if your content is 500px tall, use 800x600 camera, not 500px. No padding = truncated edges
|
|
299
|
+
- **Center titles relative to the diagram below** \u2014 estimate the diagram's total width and center the title text over it, not over the canvas
|
|
300
|
+
- **Arrow labels need space** \u2014 long labels like "ATP + NADPH" overflow short arrows. Keep labels short or make arrows wider
|
|
301
|
+
- **Elements overlap when y-coordinates are close** \u2014 always check that text, boxes, and labels don't stack on top of each other (e.g., an output box overlapping a zone label)
|
|
302
|
+
- **Draw art/illustrations LAST** \u2014 cute decorations (sun, stars, icons) should appear as the final drawing step so they don't distract from the main content being built
|
|
303
|
+
|
|
304
|
+
## Sequence flow Diagram Example
|
|
305
|
+
|
|
306
|
+
Example prompt: "show a sequence diagram explaining MCP Apps"
|
|
307
|
+
|
|
308
|
+
This demonstrates a UML-style sequence diagram with 4 actors (User, Agent, App iframe, MCP Server), dashed lifelines, and labeled arrows showing the full MCP Apps request/response flow. Camera pans progressively across the diagram:
|
|
309
|
+
|
|
310
|
+
- **Camera 1** (600x450): Title "MCP Apps \u2014 Sequence Flow"
|
|
311
|
+
- **Cameras 2\u20135** (400x300 each): Zoom into each actor column right-to-left \u2014 draw header box + dashed lifeline for Server, App, Agent, User. Right-to-left so the camera snakes smoothly: pan left across actors, then pan right following the first message arrows
|
|
312
|
+
- **Camera 6** (400x300): Zoom into User \u2014 draw stick figure (head + body)
|
|
313
|
+
- **Camera 7** (600x450): Zoom out \u2014 draw first message arrows: user prompt \u2192 agent, agent tools/call \u2192 server, tool result back, result forwarded to app iframe
|
|
314
|
+
- **Camera 8** (600x450): Pan down \u2014 draw user interaction with app, app requesting tools/call back to agent
|
|
315
|
+
- **Camera 9** (600x450): Pan further down \u2014 agent forwards to server, fresh data flows back through the chain, context update from app to agent
|
|
316
|
+
- **Camera 10** (800x600): Final zoom-out showing the complete sequence
|
|
317
|
+
|
|
318
|
+
\`\`\`json
|
|
319
|
+
[
|
|
320
|
+
{"type":"cameraUpdate","width":600,"height":450,"x":80,"y":-10},
|
|
321
|
+
{"type":"text","id":"title","x":200,"y":15,"text":"MCP Apps \u2014 Sequence Flow","fontSize":24,"strokeColor":"#1e1e1e"},
|
|
322
|
+
|
|
323
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":450,"y":-5},
|
|
324
|
+
{"type":"rectangle","id":"sHead","x":600,"y":60,"width":130,"height":40,"backgroundColor":"#ffd8a8","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#f59e0b","strokeWidth":2,"label":{"text":"MCP Server","fontSize":16}},
|
|
325
|
+
{"type":"arrow","id":"sLine","x":665,"y":100,"width":0,"height":490,"points":[[0,0],[0,490]],"strokeColor":"#b0b0b0","strokeWidth":1,"strokeStyle":"dashed","endArrowhead":null},
|
|
326
|
+
|
|
327
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":250,"y":-5},
|
|
328
|
+
{"type":"rectangle","id":"appHead","x":400,"y":60,"width":130,"height":40,"backgroundColor":"#b2f2bb","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#22c55e","strokeWidth":2,"label":{"text":"App iframe","fontSize":16}},
|
|
329
|
+
{"type":"arrow","id":"appLine","x":465,"y":100,"width":0,"height":490,"points":[[0,0],[0,490]],"strokeColor":"#b0b0b0","strokeWidth":1,"strokeStyle":"dashed","endArrowhead":null},
|
|
330
|
+
|
|
331
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":80,"y":-5},
|
|
332
|
+
{"type":"rectangle","id":"aHead","x":230,"y":60,"width":100,"height":40,"backgroundColor":"#d0bfff","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#8b5cf6","strokeWidth":2,"label":{"text":"Agent","fontSize":16}},
|
|
333
|
+
{"type":"arrow","id":"aLine","x":280,"y":100,"width":0,"height":490,"points":[[0,0],[0,490]],"strokeColor":"#b0b0b0","strokeWidth":1,"strokeStyle":"dashed","endArrowhead":null},
|
|
334
|
+
|
|
335
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":-10,"y":-5},
|
|
336
|
+
{"type":"rectangle","id":"uHead","x":60,"y":60,"width":100,"height":40,"backgroundColor":"#a5d8ff","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#4a9eed","strokeWidth":2,"label":{"text":"User","fontSize":16}},
|
|
337
|
+
{"type":"arrow","id":"uLine","x":110,"y":100,"width":0,"height":490,"points":[[0,0],[0,490]],"strokeColor":"#b0b0b0","strokeWidth":1,"strokeStyle":"dashed","endArrowhead":null},
|
|
338
|
+
|
|
339
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":-40,"y":50},
|
|
340
|
+
{"type":"ellipse","id":"uh","x":58,"y":110,"width":20,"height":20,"backgroundColor":"#a5d8ff","fillStyle":"solid","strokeColor":"#4a9eed","strokeWidth":2},
|
|
341
|
+
{"type":"rectangle","id":"ub","x":57,"y":132,"width":22,"height":26,"backgroundColor":"#a5d8ff","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#4a9eed","strokeWidth":2},
|
|
342
|
+
|
|
343
|
+
{"type":"cameraUpdate","width":600,"height":450,"x":-20,"y":-30},
|
|
344
|
+
{"type":"arrow","id":"m1","x":110,"y":135,"width":170,"height":0,"points":[[0,0],[170,0]],"strokeColor":"#1e1e1e","strokeWidth":2,"endArrowhead":"arrow","label":{"text":"display a chart","fontSize":14}},
|
|
345
|
+
{"type":"rectangle","id":"note1","x":130,"y":162,"width":310,"height":26,"backgroundColor":"#fff3bf","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#f59e0b","strokeWidth":1,"opacity":50,"label":{"text":"Interactive app rendered in chat","fontSize":14}},
|
|
346
|
+
|
|
347
|
+
{"type":"cameraUpdate","width":600,"height":450,"x":170,"y":25},
|
|
348
|
+
{"type":"arrow","id":"m2","x":280,"y":210,"width":385,"height":0,"points":[[0,0],[385,0]],"strokeColor":"#8b5cf6","strokeWidth":2,"endArrowhead":"arrow","label":{"text":"tools/call","fontSize":16}},
|
|
349
|
+
{"type":"arrow","id":"m3","x":665,"y":250,"width":-385,"height":0,"points":[[0,0],[-385,0]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":"arrow","strokeStyle":"dashed","label":{"text":"tool input/result","fontSize":16}},
|
|
350
|
+
{"type":"arrow","id":"m4","x":280,"y":290,"width":185,"height":0,"points":[[0,0],[185,0]],"strokeColor":"#8b5cf6","strokeWidth":2,"endArrowhead":"arrow","strokeStyle":"dashed","label":{"text":"result \u2192 app","fontSize":16}},
|
|
351
|
+
|
|
352
|
+
{"type":"cameraUpdate","width":600,"height":450,"x":-10,"y":135},
|
|
353
|
+
{"type":"arrow","id":"m5","x":110,"y":340,"width":355,"height":0,"points":[[0,0],[355,0]],"strokeColor":"#4a9eed","strokeWidth":2,"endArrowhead":"arrow","label":{"text":"user interacts","fontSize":16}},
|
|
354
|
+
{"type":"arrow","id":"m6","x":465,"y":380,"width":-185,"height":0,"points":[[0,0],[-185,0]],"strokeColor":"#22c55e","strokeWidth":2,"endArrowhead":"arrow","label":{"text":"tools/call request","fontSize":16}},
|
|
355
|
+
|
|
356
|
+
{"type":"cameraUpdate","width":600,"height":450,"x":170,"y":235},
|
|
357
|
+
{"type":"arrow","id":"m7","x":280,"y":420,"width":385,"height":0,"points":[[0,0],[385,0]],"strokeColor":"#8b5cf6","strokeWidth":2,"endArrowhead":"arrow","label":{"text":"tools/call (forwarded)","fontSize":16}},
|
|
358
|
+
{"type":"arrow","id":"m8","x":665,"y":460,"width":-385,"height":0,"points":[[0,0],[-385,0]],"strokeColor":"#f59e0b","strokeWidth":2,"endArrowhead":"arrow","strokeStyle":"dashed","label":{"text":"fresh data","fontSize":16}},
|
|
359
|
+
{"type":"arrow","id":"m9","x":280,"y":500,"width":185,"height":0,"points":[[0,0],[185,0]],"strokeColor":"#8b5cf6","strokeWidth":2,"endArrowhead":"arrow","strokeStyle":"dashed","label":{"text":"fresh data","fontSize":16}},
|
|
360
|
+
|
|
361
|
+
{"type":"cameraUpdate","width":600,"height":450,"x":50,"y":327},
|
|
362
|
+
{"type":"rectangle","id":"note2","x":130,"y":522,"width":310,"height":26,"backgroundColor":"#d3f9d8","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#22c55e","strokeWidth":1,"opacity":50,"label":{"text":"App updates with new data","fontSize":14}},
|
|
363
|
+
{"type":"arrow","id":"m10","x":465,"y":570,"width":-185,"height":0,"points":[[0,0],[-185,0]],"strokeColor":"#22c55e","strokeWidth":2,"endArrowhead":"arrow","strokeStyle":"dashed","label":{"text":"context update","fontSize":16}},
|
|
364
|
+
|
|
365
|
+
{"type":"cameraUpdate","width":800,"height":600,"x":-5,"y":2}
|
|
366
|
+
]
|
|
367
|
+
\`\`\`
|
|
368
|
+
|
|
369
|
+
## Checkpoints (restoring previous state)
|
|
370
|
+
|
|
371
|
+
Every create_view call returns a \`checkpointId\` in its response. To continue from a previous diagram state, start your elements array with a restoreCheckpoint element:
|
|
372
|
+
|
|
373
|
+
\`[{"type":"restoreCheckpoint","id":"<checkpointId>"}, ...additional new elements...]\`
|
|
374
|
+
|
|
375
|
+
The saved state (including any user edits made in fullscreen) is loaded from the client, and your new elements are appended on top. This saves tokens \u2014 you don't need to re-send the entire diagram.
|
|
376
|
+
|
|
377
|
+
## Deleting Elements
|
|
378
|
+
|
|
379
|
+
Remove elements by id using the \`delete\` pseudo-element:
|
|
380
|
+
|
|
381
|
+
\`{"type":"delete","ids":"b2,a1,t3"}\`
|
|
382
|
+
|
|
383
|
+
Works in two modes:
|
|
384
|
+
- **With restoreCheckpoint**: restore a saved state, then surgically remove specific elements before adding new ones
|
|
385
|
+
- **Inline (animation mode)**: draw elements, then delete and replace them later in the same array to create transformation effects
|
|
386
|
+
|
|
387
|
+
Place delete entries AFTER the elements you want to remove. The final render filters them out.
|
|
388
|
+
|
|
389
|
+
**IMPORTANT**: Every element id must be unique. Never reuse an id after deleting it \u2014 always assign a new id to replacement elements.
|
|
390
|
+
|
|
391
|
+
## Animation Mode \u2014 Transform in Place
|
|
392
|
+
|
|
393
|
+
Instead of building left-to-right and panning away, you can animate by DELETING elements and replacing them at the same position. Combined with slight camera moves, this creates smooth visual transformations during streaming.
|
|
394
|
+
|
|
395
|
+
Pattern:
|
|
396
|
+
1. Draw initial elements
|
|
397
|
+
2. cameraUpdate (shift/zoom slightly)
|
|
398
|
+
3. \`{"type":"delete","ids":"old1,old2"}\`
|
|
399
|
+
4. Draw replacements at same coordinates (different color/content)
|
|
400
|
+
5. Repeat
|
|
401
|
+
|
|
402
|
+
Example prompt: "Pixel snake eats apple"
|
|
403
|
+
|
|
404
|
+
Snake moves right by adding a head segment and deleting the tail. On eating the apple, tail is NOT deleted (snake grows). Camera nudges between frames add subtle motion.
|
|
405
|
+
|
|
406
|
+
\`\`\`json
|
|
407
|
+
[
|
|
408
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":0,"y":0},
|
|
409
|
+
{"type":"ellipse","id":"ap","x":260,"y":78,"width":20,"height":20,"backgroundColor":"#ef4444","fillStyle":"solid","strokeColor":"#ef4444"},
|
|
410
|
+
{"type":"rectangle","id":"s0","x":60,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
411
|
+
{"type":"rectangle","id":"s1","x":88,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
412
|
+
{"type":"rectangle","id":"s2","x":116,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
413
|
+
{"type":"rectangle","id":"s3","x":144,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
414
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":1,"y":0},
|
|
415
|
+
{"type":"rectangle","id":"s4","x":172,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
416
|
+
{"type":"delete","ids":"s0"},
|
|
417
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":0,"y":1},
|
|
418
|
+
{"type":"rectangle","id":"s5","x":200,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
419
|
+
{"type":"delete","ids":"s1"},
|
|
420
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":1,"y":0},
|
|
421
|
+
{"type":"rectangle","id":"s6","x":228,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
422
|
+
{"type":"delete","ids":"s2"},
|
|
423
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":0,"y":0},
|
|
424
|
+
{"type":"rectangle","id":"s7","x":256,"y":130,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
425
|
+
{"type":"delete","ids":"s3"},
|
|
426
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":1,"y":1},
|
|
427
|
+
{"type":"rectangle","id":"s8","x":256,"y":102,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
428
|
+
{"type":"delete","ids":"s4"},
|
|
429
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":0,"y":0},
|
|
430
|
+
{"type":"rectangle","id":"s9","x":256,"y":74,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
431
|
+
{"type":"delete","ids":"ap"},
|
|
432
|
+
{"type":"cameraUpdate","width":400,"height":300,"x":1,"y":0},
|
|
433
|
+
{"type":"rectangle","id":"s10","x":256,"y":46,"width":28,"height":28,"backgroundColor":"#22c55e","fillStyle":"solid","strokeColor":"#15803d","strokeWidth":1},
|
|
434
|
+
{"type":"delete","ids":"s5"}
|
|
435
|
+
]
|
|
436
|
+
\`\`\`
|
|
437
|
+
|
|
438
|
+
Key techniques:
|
|
439
|
+
- Add head + delete tail each frame = snake movement illusion
|
|
440
|
+
- On eat: delete apple instead of tail = snake grows by one
|
|
441
|
+
- Post-eat frame resumes normal add-head/delete-tail, proving the snake is now longer
|
|
442
|
+
- Camera nudges (0,0 \u2192 1,0 \u2192 0,1 \u2192 ...) add subtle motion between frames
|
|
443
|
+
- Always use NEW ids for added segments (s0\u2192s4\u2192s5\u2192...); never reuse deleted ids
|
|
444
|
+
|
|
445
|
+
## Dark Mode
|
|
446
|
+
|
|
447
|
+
If the user asks for a dark theme/mode diagram, use a massive dark background rectangle as the FIRST element (before cameraUpdate). Make it 10x the camera size so it covers the entire viewport even when panning:
|
|
448
|
+
|
|
449
|
+
\`{"type":"rectangle","id":"darkbg","x":-4000,"y":-3000,"width":10000,"height":7500,"backgroundColor":"#1e1e2e","fillStyle":"solid","strokeColor":"transparent","strokeWidth":0}\`
|
|
450
|
+
|
|
451
|
+
Then use these colors on the dark background:
|
|
452
|
+
|
|
453
|
+
**Text colors (on dark):**
|
|
454
|
+
| Color | Hex | Use |
|
|
455
|
+
|-------|-----|-----|
|
|
456
|
+
| White | \`#e5e5e5\` | Primary text, titles |
|
|
457
|
+
| Muted | \`#a0a0a0\` | Secondary text, annotations |
|
|
458
|
+
| NEVER | \`#555\` or darker | Invisible on dark bg! |
|
|
459
|
+
|
|
460
|
+
**Shape fills (on dark):**
|
|
461
|
+
| Color | Hex | Good For |
|
|
462
|
+
|-------|-----|----------|
|
|
463
|
+
| Dark Blue | \`#1e3a5f\` | Primary nodes |
|
|
464
|
+
| Dark Green | \`#1a4d2e\` | Success, output |
|
|
465
|
+
| Dark Purple | \`#2d1b69\` | Processing, special |
|
|
466
|
+
| Dark Orange | \`#5c3d1a\` | Warning, pending |
|
|
467
|
+
| Dark Red | \`#5c1a1a\` | Error, critical |
|
|
468
|
+
| Dark Teal | \`#1a4d4d\` | Storage, data |
|
|
469
|
+
|
|
470
|
+
**Stroke/arrow colors (on dark):**
|
|
471
|
+
Use the Primary Colors from above \u2014 they're bright enough on dark backgrounds. For shape borders, use slightly lighter variants or \`#555555\` for subtle outlines.
|
|
472
|
+
|
|
473
|
+
## Tips
|
|
474
|
+
- Do NOT call read_me again \u2014 you already have everything you need
|
|
475
|
+
- Use the color palette consistently
|
|
476
|
+
- **Text contrast is CRITICAL** \u2014 never use light gray (#b0b0b0, #999) on white backgrounds. Minimum text color on white: #757575. For colored text on light fills, use dark variants (#15803d not #22c55e, #2563eb not #4a9eed). White text needs dark backgrounds (#9a5030 not #c4795b)
|
|
477
|
+
- Do NOT use emoji in text \u2014 they don't render in Excalidraw's font
|
|
478
|
+
- cameraUpdate is MAGICAL and users love it! please use it a lot to guide the user's attention as you draw. It makes a huge difference in readability and engagement.
|
|
479
|
+
`;
|
|
480
|
+
PSEUDO_TYPES = /* @__PURE__ */ new Set(["cameraUpdate", "delete", "restoreCheckpoint"]);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// src/svg-renderer.ts
|
|
485
|
+
var svg_renderer_exports = {};
|
|
486
|
+
__export(svg_renderer_exports, {
|
|
487
|
+
renderSvg: () => renderSvg
|
|
488
|
+
});
|
|
489
|
+
async function ensureInitialized() {
|
|
490
|
+
if (initialized) return;
|
|
491
|
+
const { JSDOM } = await import("jsdom");
|
|
492
|
+
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
|
493
|
+
url: "http://localhost",
|
|
494
|
+
pretendToBeVisual: true
|
|
495
|
+
});
|
|
496
|
+
const g = globalThis;
|
|
497
|
+
if (!g.window) g.window = dom.window;
|
|
498
|
+
if (!g.document) g.document = dom.window.document;
|
|
499
|
+
if (!g.navigator) g.navigator = dom.window.navigator;
|
|
500
|
+
if (!g.HTMLElement) g.HTMLElement = dom.window.HTMLElement;
|
|
501
|
+
if (!g.SVGElement) g.SVGElement = dom.window.SVGElement;
|
|
502
|
+
if (!g.Element) g.Element = dom.window.Element;
|
|
503
|
+
if (!g.DOMParser) g.DOMParser = dom.window.DOMParser;
|
|
504
|
+
if (!g.XMLSerializer) g.XMLSerializer = dom.window.XMLSerializer;
|
|
505
|
+
if (!g.FontFace) {
|
|
506
|
+
g.FontFace = class FontFace {
|
|
507
|
+
family;
|
|
508
|
+
constructor(family, _source) {
|
|
509
|
+
this.family = family;
|
|
510
|
+
}
|
|
511
|
+
async load() {
|
|
512
|
+
return this;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (!g.document.fonts) {
|
|
517
|
+
g.document.fonts = {
|
|
518
|
+
add: () => {
|
|
519
|
+
},
|
|
520
|
+
check: () => true,
|
|
521
|
+
load: async () => [],
|
|
522
|
+
ready: Promise.resolve(),
|
|
523
|
+
forEach: () => {
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const excalidraw = await import("@excalidraw/excalidraw");
|
|
529
|
+
exportToSvgFn = excalidraw.exportToSvg;
|
|
530
|
+
convertToExcalidrawElementsFn = excalidraw.convertToExcalidrawElements;
|
|
531
|
+
initialized = true;
|
|
532
|
+
} catch (err) {
|
|
533
|
+
console.error("Failed to initialize Excalidraw for server-side rendering:", err);
|
|
534
|
+
initialized = true;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function extractViewport(elements) {
|
|
538
|
+
let viewport = null;
|
|
539
|
+
for (const el of elements) {
|
|
540
|
+
if (el.type === "cameraUpdate") {
|
|
541
|
+
viewport = { x: el.x, y: el.y, width: el.width, height: el.height };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return viewport;
|
|
545
|
+
}
|
|
546
|
+
function computeSceneBounds(elements) {
|
|
547
|
+
let minX = Infinity;
|
|
548
|
+
let minY = Infinity;
|
|
549
|
+
for (const el of elements) {
|
|
550
|
+
if (el.x != null) {
|
|
551
|
+
minX = Math.min(minX, el.x);
|
|
552
|
+
minY = Math.min(minY, el.y);
|
|
553
|
+
if (el.points && Array.isArray(el.points)) {
|
|
554
|
+
for (const pt of el.points) {
|
|
555
|
+
minX = Math.min(minX, el.x + pt[0]);
|
|
556
|
+
minY = Math.min(minY, el.y + pt[1]);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return { minX: isFinite(minX) ? minX : 0, minY: isFinite(minY) ? minY : 0 };
|
|
562
|
+
}
|
|
563
|
+
async function renderSvg(elements) {
|
|
564
|
+
await ensureInitialized();
|
|
565
|
+
const viewport = extractViewport(elements);
|
|
566
|
+
const realElements = elements.filter((el) => !PSEUDO_TYPES.has(el.type));
|
|
567
|
+
if (realElements.length === 0) {
|
|
568
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><text x="200" y="150" text-anchor="middle" fill="#999">Empty diagram</text></svg>';
|
|
569
|
+
}
|
|
570
|
+
if (!exportToSvgFn || !convertToExcalidrawElementsFn) {
|
|
571
|
+
return generateFallbackSvg(realElements, viewport);
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const withDefaults = realElements.map(
|
|
575
|
+
(el) => el.label ? { ...el, label: { textAlign: "center", verticalAlign: "middle", ...el.label } } : el
|
|
576
|
+
);
|
|
577
|
+
const converted = convertToExcalidrawElementsFn(withDefaults, {
|
|
578
|
+
regenerateIds: false
|
|
579
|
+
}).map((el) => el.type === "text" ? { ...el, fontFamily: 1 } : el);
|
|
580
|
+
const svg = await exportToSvgFn({
|
|
581
|
+
elements: converted,
|
|
582
|
+
appState: {
|
|
583
|
+
viewBackgroundColor: "#ffffff",
|
|
584
|
+
exportBackground: true
|
|
585
|
+
},
|
|
586
|
+
files: null,
|
|
587
|
+
exportPadding: EXPORT_PADDING,
|
|
588
|
+
skipInliningFonts: true
|
|
589
|
+
});
|
|
590
|
+
if (!svg) {
|
|
591
|
+
return generateFallbackSvg(realElements, viewport);
|
|
592
|
+
}
|
|
593
|
+
if (viewport) {
|
|
594
|
+
const { minX, minY } = computeSceneBounds(converted);
|
|
595
|
+
const vbX = viewport.x - minX + EXPORT_PADDING;
|
|
596
|
+
const vbY = viewport.y - minY + EXPORT_PADDING;
|
|
597
|
+
svg.setAttribute("viewBox", `${vbX} ${vbY} ${viewport.width} ${viewport.height}`);
|
|
598
|
+
}
|
|
599
|
+
return svg.outerHTML;
|
|
600
|
+
} catch (err) {
|
|
601
|
+
console.error("exportToSvg failed, using fallback:", err);
|
|
602
|
+
return generateFallbackSvg(realElements, viewport);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function generateFallbackSvg(elements, viewport) {
|
|
606
|
+
const vp = viewport ?? { x: 0, y: 0, width: 800, height: 600 };
|
|
607
|
+
const parts = [];
|
|
608
|
+
parts.push(
|
|
609
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vp.x} ${vp.y} ${vp.width} ${vp.height}">`
|
|
610
|
+
);
|
|
611
|
+
parts.push(`<rect x="${vp.x}" y="${vp.y}" width="${vp.width}" height="${vp.height}" fill="#ffffff"/>`);
|
|
612
|
+
for (const el of elements) {
|
|
613
|
+
const fill = el.backgroundColor && el.backgroundColor !== "transparent" ? el.backgroundColor : "none";
|
|
614
|
+
const stroke = el.strokeColor ?? "#1e1e1e";
|
|
615
|
+
const sw = el.strokeWidth ?? 2;
|
|
616
|
+
switch (el.type) {
|
|
617
|
+
case "rectangle":
|
|
618
|
+
parts.push(
|
|
619
|
+
`<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" rx="${el.roundness ? 8 : 0}"/>`
|
|
620
|
+
);
|
|
621
|
+
if (el.label?.text) {
|
|
622
|
+
const cx = el.x + el.width / 2;
|
|
623
|
+
const cy = el.y + el.height / 2;
|
|
624
|
+
const fs3 = el.label.fontSize ?? 16;
|
|
625
|
+
parts.push(
|
|
626
|
+
`<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${fs3}" fill="${stroke}">${escapeXml(el.label.text)}</text>`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
break;
|
|
630
|
+
case "ellipse":
|
|
631
|
+
parts.push(
|
|
632
|
+
`<ellipse cx="${el.x + el.width / 2}" cy="${el.y + el.height / 2}" rx="${el.width / 2}" ry="${el.height / 2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"/>`
|
|
633
|
+
);
|
|
634
|
+
break;
|
|
635
|
+
case "diamond": {
|
|
636
|
+
const cx = el.x + el.width / 2;
|
|
637
|
+
const cy = el.y + el.height / 2;
|
|
638
|
+
parts.push(
|
|
639
|
+
`<polygon points="${cx},${el.y} ${el.x + el.width},${cy} ${cx},${el.y + el.height} ${el.x},${cy}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"/>`
|
|
640
|
+
);
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
case "text":
|
|
644
|
+
parts.push(
|
|
645
|
+
`<text x="${el.x}" y="${el.y + (el.fontSize ?? 16)}" font-size="${el.fontSize ?? 16}" fill="${stroke}">${escapeXml(el.text ?? "")}</text>`
|
|
646
|
+
);
|
|
647
|
+
break;
|
|
648
|
+
case "arrow":
|
|
649
|
+
if (el.points && el.points.length >= 2) {
|
|
650
|
+
const pts = el.points.map((p) => `${el.x + p[0]},${el.y + p[1]}`).join(" ");
|
|
651
|
+
parts.push(
|
|
652
|
+
`<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="${sw}" marker-end="${el.endArrowhead ? "url(#arrow)" : ""}"/>`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
parts.push('<defs><marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#1e1e1e"/></marker></defs>');
|
|
659
|
+
parts.push("</svg>");
|
|
660
|
+
return parts.join("\n");
|
|
661
|
+
}
|
|
662
|
+
function escapeXml(str) {
|
|
663
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
664
|
+
}
|
|
665
|
+
var EXPORT_PADDING, initialized, exportToSvgFn, convertToExcalidrawElementsFn;
|
|
666
|
+
var init_svg_renderer = __esm({
|
|
667
|
+
"src/svg-renderer.ts"() {
|
|
668
|
+
"use strict";
|
|
669
|
+
init_shared();
|
|
670
|
+
EXPORT_PADDING = 20;
|
|
671
|
+
initialized = false;
|
|
672
|
+
exportToSvgFn = null;
|
|
673
|
+
convertToExcalidrawElementsFn = null;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// src/main.ts
|
|
678
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
679
|
+
import { existsSync } from "node:fs";
|
|
680
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
681
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
682
|
+
|
|
683
|
+
// src/checkpoint-store.ts
|
|
684
|
+
import fs from "node:fs";
|
|
685
|
+
import path from "node:path";
|
|
686
|
+
import os from "node:os";
|
|
687
|
+
var MAX_CHECKPOINT_BYTES = 5 * 1024 * 1024;
|
|
688
|
+
var MAX_FILE_CHECKPOINTS = 100;
|
|
689
|
+
function validateCheckpointId(id) {
|
|
690
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
|
|
691
|
+
throw new Error(`Invalid checkpoint id: must be alphanumeric, hyphens, or underscores`);
|
|
692
|
+
}
|
|
693
|
+
if (id.length > 64) {
|
|
694
|
+
throw new Error(`Invalid checkpoint id: exceeds 64 character limit`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
var FileCheckpointStore = class {
|
|
698
|
+
dir;
|
|
699
|
+
constructor() {
|
|
700
|
+
this.dir = path.join(os.tmpdir(), "excalidraw-mcp-checkpoints");
|
|
701
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
702
|
+
}
|
|
703
|
+
async save(id, data) {
|
|
704
|
+
validateCheckpointId(id);
|
|
705
|
+
const serialized = JSON.stringify(data);
|
|
706
|
+
if (serialized.length > MAX_CHECKPOINT_BYTES) {
|
|
707
|
+
throw new Error(`Checkpoint data exceeds ${MAX_CHECKPOINT_BYTES} byte limit`);
|
|
708
|
+
}
|
|
709
|
+
const filePath = path.join(this.dir, `${id}.json`);
|
|
710
|
+
if (!path.resolve(filePath).startsWith(path.resolve(this.dir) + path.sep)) {
|
|
711
|
+
throw new Error("Invalid checkpoint path");
|
|
712
|
+
}
|
|
713
|
+
await fs.promises.writeFile(filePath, serialized);
|
|
714
|
+
await this.pruneOldCheckpoints();
|
|
715
|
+
}
|
|
716
|
+
async load(id) {
|
|
717
|
+
validateCheckpointId(id);
|
|
718
|
+
const filePath = path.join(this.dir, `${id}.json`);
|
|
719
|
+
if (!path.resolve(filePath).startsWith(path.resolve(this.dir) + path.sep)) {
|
|
720
|
+
throw new Error("Invalid checkpoint path");
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
const raw = await fs.promises.readFile(filePath, "utf-8");
|
|
724
|
+
return JSON.parse(raw);
|
|
725
|
+
} catch {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/** Remove oldest checkpoints when count exceeds the limit. */
|
|
730
|
+
async pruneOldCheckpoints() {
|
|
731
|
+
try {
|
|
732
|
+
const entries = await fs.promises.readdir(this.dir);
|
|
733
|
+
const jsonFiles = entries.filter((f) => f.endsWith(".json"));
|
|
734
|
+
if (jsonFiles.length <= MAX_FILE_CHECKPOINTS) return;
|
|
735
|
+
const stats = await Promise.all(
|
|
736
|
+
jsonFiles.map(async (f) => ({
|
|
737
|
+
name: f,
|
|
738
|
+
mtime: (await fs.promises.stat(path.join(this.dir, f))).mtimeMs
|
|
739
|
+
}))
|
|
740
|
+
);
|
|
741
|
+
stats.sort((a, b) => a.mtime - b.mtime);
|
|
742
|
+
const toRemove = stats.slice(0, stats.length - MAX_FILE_CHECKPOINTS);
|
|
743
|
+
await Promise.all(
|
|
744
|
+
toRemove.map((f) => fs.promises.unlink(path.join(this.dir, f.name)).catch(() => {
|
|
745
|
+
}))
|
|
746
|
+
);
|
|
747
|
+
} catch {
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
var REDIS_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
752
|
+
|
|
753
|
+
// src/cli.ts
|
|
754
|
+
function parseCliArgs(argv) {
|
|
755
|
+
const args = {
|
|
756
|
+
stdio: false,
|
|
757
|
+
port: parseInt(process.env.PORT ?? "3001", 10),
|
|
758
|
+
help: false,
|
|
759
|
+
version: false
|
|
760
|
+
};
|
|
761
|
+
for (let i = 0; i < argv.length; i++) {
|
|
762
|
+
switch (argv[i]) {
|
|
763
|
+
case "--stdio":
|
|
764
|
+
args.stdio = true;
|
|
765
|
+
break;
|
|
766
|
+
case "--port":
|
|
767
|
+
args.port = parseInt(argv[++i], 10);
|
|
768
|
+
break;
|
|
769
|
+
case "--base-url":
|
|
770
|
+
args.baseUrl = argv[++i]?.replace(/\/+$/, "");
|
|
771
|
+
break;
|
|
772
|
+
case "--help":
|
|
773
|
+
args.help = true;
|
|
774
|
+
break;
|
|
775
|
+
case "--version":
|
|
776
|
+
args.version = true;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return args;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/http-app.ts
|
|
784
|
+
import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
|
|
785
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
786
|
+
import cors from "cors";
|
|
787
|
+
import express from "express";
|
|
788
|
+
import { resolve } from "node:path";
|
|
789
|
+
function renderLandingPage(baseUrl) {
|
|
790
|
+
return `<!DOCTYPE html>
|
|
791
|
+
<html lang="en">
|
|
792
|
+
<head>
|
|
793
|
+
<meta charset="UTF-8">
|
|
794
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
795
|
+
<title>Excalidraw Sidecar MCP</title>
|
|
796
|
+
<style>
|
|
797
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
798
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
799
|
+
background: #fafafa; color: #1e1e1e; min-height: 100vh;
|
|
800
|
+
display: flex; align-items: center; justify-content: center; }
|
|
801
|
+
.container { max-width: 640px; padding: 40px; }
|
|
802
|
+
h1 { font-size: 28px; margin-bottom: 8px; }
|
|
803
|
+
.subtitle { color: #6b7280; font-size: 16px; margin-bottom: 32px; }
|
|
804
|
+
.status { display: flex; align-items: center; gap: 8px; margin-bottom: 24px;
|
|
805
|
+
padding: 12px 16px; background: #ecfdf5; border-radius: 8px; border: 1px solid #d1fae5; }
|
|
806
|
+
.dot { width: 10px; height: 10px; border-radius: 50%; background: #22c55e; }
|
|
807
|
+
.status span { color: #15803d; font-size: 14px; font-weight: 500; }
|
|
808
|
+
.section { margin-bottom: 24px; }
|
|
809
|
+
.section h2 { font-size: 16px; color: #374151; margin-bottom: 12px; }
|
|
810
|
+
.endpoint { display: flex; justify-content: space-between; align-items: center;
|
|
811
|
+
padding: 10px 14px; background: #fff; border: 1px solid #e5e7eb;
|
|
812
|
+
border-radius: 6px; margin-bottom: 8px; font-size: 14px; }
|
|
813
|
+
.endpoint .path { font-family: 'SF Mono', Monaco, monospace; color: #4a9eed; font-weight: 500; }
|
|
814
|
+
.endpoint .desc { color: #6b7280; }
|
|
815
|
+
.config { background: #1e1e1e; color: #e5e7eb; padding: 16px; border-radius: 8px;
|
|
816
|
+
font-family: 'SF Mono', Monaco, monospace; font-size: 13px;
|
|
817
|
+
line-height: 1.6; overflow-x: auto; white-space: pre; }
|
|
818
|
+
.config .key { color: #7dd3fc; }
|
|
819
|
+
.config .str { color: #86efac; }
|
|
820
|
+
a { color: #4a9eed; text-decoration: none; }
|
|
821
|
+
a:hover { text-decoration: underline; }
|
|
822
|
+
.footer { margin-top: 32px; color: #9ca3af; font-size: 13px; text-align: center; }
|
|
823
|
+
</style>
|
|
824
|
+
</head>
|
|
825
|
+
<body>
|
|
826
|
+
<div class="container">
|
|
827
|
+
<h1>Excalidraw Sidecar MCP</h1>
|
|
828
|
+
<p class="subtitle">Remote MCP server for diagram creation by external LLMs</p>
|
|
829
|
+
|
|
830
|
+
<div class="status"><div class="dot"></div><span>Server running</span></div>
|
|
831
|
+
|
|
832
|
+
<div class="section">
|
|
833
|
+
<h2>Endpoints</h2>
|
|
834
|
+
<div class="endpoint"><span class="path">POST /mcp</span><span class="desc">MCP Streamable HTTP</span></div>
|
|
835
|
+
<div class="endpoint"><span class="path">GET /api/sessions/:key</span><span class="desc">Session metadata</span></div>
|
|
836
|
+
<div class="endpoint"><span class="path">GET /api/sessions/:key/elements</span><span class="desc">Elements JSON</span></div>
|
|
837
|
+
<div class="endpoint"><span class="path">PUT /api/sessions/:key/elements</span><span class="desc">Update elements</span></div>
|
|
838
|
+
<div class="endpoint"><span class="path">GET /api/sessions/:key/svg</span><span class="desc">Rendered SVG</span></div>
|
|
839
|
+
<div class="endpoint"><span class="path">/view/:key</span><span class="desc">Viewer + editor page</span></div>
|
|
840
|
+
</div>
|
|
841
|
+
|
|
842
|
+
<div class="section">
|
|
843
|
+
<h2>Connect from Claude Desktop</h2>
|
|
844
|
+
<div class="config">{
|
|
845
|
+
<span class="key">"mcpServers"</span>: {
|
|
846
|
+
<span class="key">"excalidraw"</span>: {
|
|
847
|
+
<span class="key">"url"</span>: <span class="str">"${baseUrl}/mcp"</span>
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}</div>
|
|
851
|
+
</div>
|
|
852
|
+
|
|
853
|
+
<div class="section">
|
|
854
|
+
<h2>Connect via CLI</h2>
|
|
855
|
+
<div class="config">node mcp-client.mjs --server ${baseUrl} create-session</div>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<div class="footer">
|
|
859
|
+
<a href="https://github.com/anyin233/excalidraw-sidecar-mcp">GitHub</a>
|
|
860
|
+
· No LLM configuration needed — this server provides tools, external LLMs connect to it.
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
</body>
|
|
864
|
+
</html>`;
|
|
865
|
+
}
|
|
866
|
+
function detectBaseUrl(req, fallbackPort) {
|
|
867
|
+
if (process.env.BASE_URL) return process.env.BASE_URL;
|
|
868
|
+
const fwdHost = req.headers["x-forwarded-host"];
|
|
869
|
+
const host = (Array.isArray(fwdHost) ? fwdHost[0] : fwdHost) ?? req.headers.host;
|
|
870
|
+
if (host) {
|
|
871
|
+
const fwdProto = req.headers["x-forwarded-proto"];
|
|
872
|
+
const proto = (Array.isArray(fwdProto) ? fwdProto[0] : fwdProto) ?? "http";
|
|
873
|
+
return `${proto}://${host}`;
|
|
874
|
+
}
|
|
875
|
+
return `http://localhost:${fallbackPort}`;
|
|
876
|
+
}
|
|
877
|
+
function createApp(createServerFn, sessionStore, viewerDir, port = 3001) {
|
|
878
|
+
const app = express();
|
|
879
|
+
app.use(express.json());
|
|
880
|
+
const allowedHosts = ["localhost", "127.0.0.1", "::1"];
|
|
881
|
+
if (process.env.BASE_URL) {
|
|
882
|
+
try {
|
|
883
|
+
allowedHosts.push(new URL(process.env.BASE_URL).hostname);
|
|
884
|
+
} catch {
|
|
885
|
+
}
|
|
886
|
+
app.use(hostHeaderValidation(allowedHosts));
|
|
887
|
+
}
|
|
888
|
+
app.use(cors());
|
|
889
|
+
app.use("/api/sessions", express.json({ limit: "5mb" }));
|
|
890
|
+
app.all("/mcp", async (req, res) => {
|
|
891
|
+
const server = createServerFn(detectBaseUrl(req, port));
|
|
892
|
+
const transport = new StreamableHTTPServerTransport({
|
|
893
|
+
sessionIdGenerator: void 0
|
|
894
|
+
});
|
|
895
|
+
res.on("close", () => {
|
|
896
|
+
transport.close().catch(() => {
|
|
897
|
+
});
|
|
898
|
+
server.close().catch(() => {
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
try {
|
|
902
|
+
await server.connect(transport);
|
|
903
|
+
await transport.handleRequest(req, res, req.body);
|
|
904
|
+
} catch (error) {
|
|
905
|
+
console.error("MCP error:", error);
|
|
906
|
+
if (!res.headersSent) {
|
|
907
|
+
res.status(500).json({
|
|
908
|
+
jsonrpc: "2.0",
|
|
909
|
+
error: { code: -32603, message: "Internal server error" },
|
|
910
|
+
id: null
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
916
|
+
function getKey(req, res) {
|
|
917
|
+
const k = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
|
|
918
|
+
if (!UUID_RE.test(k)) {
|
|
919
|
+
res.status(400).json({ error: "Invalid session key format" });
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
return k;
|
|
923
|
+
}
|
|
924
|
+
app.get("/api/sessions/:key", (req, res) => {
|
|
925
|
+
const key = getKey(req, res);
|
|
926
|
+
if (!key) return;
|
|
927
|
+
const session = sessionStore.getSession(key);
|
|
928
|
+
if (!session) {
|
|
929
|
+
res.status(404).json({ error: "Session not found or expired" });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
res.json({
|
|
933
|
+
sessionKey: session.sessionKey,
|
|
934
|
+
expiresAt: session.expiresAt.toISOString(),
|
|
935
|
+
hasElements: session.elements.length > 0
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
app.get("/api/sessions/:key/elements", (req, res) => {
|
|
939
|
+
const key = getKey(req, res);
|
|
940
|
+
if (!key) return;
|
|
941
|
+
const session = sessionStore.getSession(key);
|
|
942
|
+
if (!session) {
|
|
943
|
+
res.status(404).json({ error: "Session not found or expired" });
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
res.json({ elements: session.elements });
|
|
947
|
+
});
|
|
948
|
+
app.put("/api/sessions/:key/elements", (req, res) => {
|
|
949
|
+
const key = getKey(req, res);
|
|
950
|
+
if (!key) return;
|
|
951
|
+
const { elements } = req.body;
|
|
952
|
+
if (!Array.isArray(elements)) {
|
|
953
|
+
res.status(400).json({ error: "Request body must contain an 'elements' array" });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const updated = sessionStore.updateElements(key, elements);
|
|
957
|
+
if (!updated) {
|
|
958
|
+
res.status(404).json({ error: "Session not found or expired" });
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
res.json({ ok: true });
|
|
962
|
+
});
|
|
963
|
+
app.get("/api/sessions/:key/svg", async (req, res) => {
|
|
964
|
+
const key = getKey(req, res);
|
|
965
|
+
if (!key) return;
|
|
966
|
+
const session = sessionStore.getSession(key);
|
|
967
|
+
if (!session) {
|
|
968
|
+
res.status(404).json({ error: "Session not found or expired" });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (session.elements.length === 0) {
|
|
972
|
+
res.status(404).json({ error: "Session has no diagram yet" });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
let svg = session.svgCache;
|
|
976
|
+
if (!svg) {
|
|
977
|
+
try {
|
|
978
|
+
const { renderSvg: renderSvg2 } = await Promise.resolve().then(() => (init_svg_renderer(), svg_renderer_exports));
|
|
979
|
+
svg = await renderSvg2(session.elements);
|
|
980
|
+
sessionStore.updateSvgCache(key, svg);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
console.error("SVG rendering error:", err);
|
|
983
|
+
res.status(500).json({ error: "SVG rendering failed" });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
res.setHeader("Content-Type", "image/svg+xml");
|
|
988
|
+
res.send(svg);
|
|
989
|
+
});
|
|
990
|
+
app.get("/", (_req, res) => {
|
|
991
|
+
const baseUrl = process.env.BASE_URL ?? `http://localhost:${port}`;
|
|
992
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
993
|
+
res.send(renderLandingPage(baseUrl));
|
|
994
|
+
});
|
|
995
|
+
if (viewerDir) {
|
|
996
|
+
const absDir = resolve(viewerDir);
|
|
997
|
+
app.use(express.static(absDir, { index: false }));
|
|
998
|
+
app.get("/{*path}", (_req, res) => {
|
|
999
|
+
res.sendFile("index.html", { root: absDir });
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
return app;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// src/remote-server.ts
|
|
1006
|
+
init_shared();
|
|
1007
|
+
init_svg_renderer();
|
|
1008
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1009
|
+
import { z } from "zod/v4";
|
|
1010
|
+
function createRemoteServer(sessionStore, checkpointStore, baseUrl) {
|
|
1011
|
+
const server = new McpServer({
|
|
1012
|
+
name: "Interactive Drawer Remote",
|
|
1013
|
+
version: "1.0.0"
|
|
1014
|
+
});
|
|
1015
|
+
server.registerTool(
|
|
1016
|
+
"create_session",
|
|
1017
|
+
{
|
|
1018
|
+
description: "Create a new drawing session. Returns a session key and viewer URL. Sessions expire after 24 hours.",
|
|
1019
|
+
annotations: { readOnlyHint: false }
|
|
1020
|
+
},
|
|
1021
|
+
async () => {
|
|
1022
|
+
const session = sessionStore.createSession();
|
|
1023
|
+
const viewerUrl = `${baseUrl}/view/${session.sessionKey}`;
|
|
1024
|
+
return {
|
|
1025
|
+
content: [
|
|
1026
|
+
{
|
|
1027
|
+
type: "text",
|
|
1028
|
+
text: `Session created!
|
|
1029
|
+
Session key: "${session.sessionKey}"
|
|
1030
|
+
Viewer URL: ${viewerUrl}
|
|
1031
|
+
Expires at: ${session.expiresAt.toISOString()}
|
|
1032
|
+
|
|
1033
|
+
Use this session key with create_view to draw diagrams. Share the viewer URL so users can see and edit the diagram in their browser.`
|
|
1034
|
+
}
|
|
1035
|
+
]
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
);
|
|
1039
|
+
server.registerTool(
|
|
1040
|
+
"read_me",
|
|
1041
|
+
{
|
|
1042
|
+
description: "Returns the Excalidraw element format reference with color palettes, examples, and tips. Call this BEFORE using create_view for the first time.",
|
|
1043
|
+
annotations: { readOnlyHint: true }
|
|
1044
|
+
},
|
|
1045
|
+
async () => {
|
|
1046
|
+
const preamble = `**Server base URL: ${baseUrl}**
|
|
1047
|
+
All viewer links in this session use this base URL. When you call create_session, the returned viewer URL will start with ${baseUrl}/view/...
|
|
1048
|
+
|
|
1049
|
+
`;
|
|
1050
|
+
return { content: [{ type: "text", text: preamble + RECALL_CHEAT_SHEET }] };
|
|
1051
|
+
}
|
|
1052
|
+
);
|
|
1053
|
+
server.registerTool(
|
|
1054
|
+
"create_view",
|
|
1055
|
+
{
|
|
1056
|
+
description: `Renders a diagram using Excalidraw elements and returns an SVG image + viewer link.
|
|
1057
|
+
Call read_me first to learn the element format. Requires a session_key from create_session.`,
|
|
1058
|
+
inputSchema: {
|
|
1059
|
+
session_key: z.string().describe("Session key from create_session."),
|
|
1060
|
+
elements: z.string().describe(
|
|
1061
|
+
"JSON array string of Excalidraw elements. Must be valid JSON \u2014 no comments, no trailing commas. Call read_me first for format reference."
|
|
1062
|
+
)
|
|
1063
|
+
},
|
|
1064
|
+
annotations: { readOnlyHint: false }
|
|
1065
|
+
},
|
|
1066
|
+
async ({ session_key, elements }) => {
|
|
1067
|
+
const session = sessionStore.getSession(session_key);
|
|
1068
|
+
if (!session) {
|
|
1069
|
+
return {
|
|
1070
|
+
content: [
|
|
1071
|
+
{
|
|
1072
|
+
type: "text",
|
|
1073
|
+
text: "Session not found or expired. Create a new session with create_session."
|
|
1074
|
+
}
|
|
1075
|
+
],
|
|
1076
|
+
isError: true
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
if (elements.length > MAX_INPUT_BYTES) {
|
|
1080
|
+
return {
|
|
1081
|
+
content: [
|
|
1082
|
+
{
|
|
1083
|
+
type: "text",
|
|
1084
|
+
text: `Elements input exceeds ${MAX_INPUT_BYTES} byte limit. Reduce the number of elements or use checkpoints to build incrementally.`
|
|
1085
|
+
}
|
|
1086
|
+
],
|
|
1087
|
+
isError: true
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
let parsed;
|
|
1091
|
+
try {
|
|
1092
|
+
parsed = JSON.parse(elements);
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
return {
|
|
1095
|
+
content: [
|
|
1096
|
+
{
|
|
1097
|
+
type: "text",
|
|
1098
|
+
text: `Invalid JSON in elements: ${e.message}. Ensure no comments, no trailing commas, and proper quoting.`
|
|
1099
|
+
}
|
|
1100
|
+
],
|
|
1101
|
+
isError: true
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
const result = await resolveElements(parsed, checkpointStore);
|
|
1105
|
+
if (!result.ok) {
|
|
1106
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
1107
|
+
}
|
|
1108
|
+
const { resolvedElements, ratioHint } = result;
|
|
1109
|
+
sessionStore.updateElements(session_key, resolvedElements);
|
|
1110
|
+
const checkpointId = generateCheckpointId();
|
|
1111
|
+
await checkpointStore.save(checkpointId, { elements: resolvedElements });
|
|
1112
|
+
let svgString;
|
|
1113
|
+
try {
|
|
1114
|
+
svgString = await renderSvg(resolvedElements);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
svgString = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><text x="200" y="150" text-anchor="middle" fill="#999">SVG rendering failed</text></svg>';
|
|
1117
|
+
console.error("SVG rendering error:", err);
|
|
1118
|
+
}
|
|
1119
|
+
sessionStore.updateSvgCache(session_key, svgString);
|
|
1120
|
+
const viewerUrl = `${baseUrl}/view/${session_key}`;
|
|
1121
|
+
const svgBase64 = Buffer.from(svgString).toString("base64");
|
|
1122
|
+
return {
|
|
1123
|
+
content: [
|
|
1124
|
+
{
|
|
1125
|
+
type: "text",
|
|
1126
|
+
text: `Diagram rendered! Checkpoint id: "${checkpointId}".
|
|
1127
|
+
Viewer URL: ${viewerUrl}
|
|
1128
|
+
|
|
1129
|
+
To edit this diagram, use restoreCheckpoint:
|
|
1130
|
+
[{"type":"restoreCheckpoint","id":"${checkpointId}"}, ...your new elements...]
|
|
1131
|
+
|
|
1132
|
+
To remove elements: {"type":"delete","ids":"<id1>,<id2>"}${ratioHint}`
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
type: "image",
|
|
1136
|
+
data: svgBase64,
|
|
1137
|
+
mimeType: "image/svg+xml"
|
|
1138
|
+
}
|
|
1139
|
+
]
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
);
|
|
1143
|
+
server.registerTool(
|
|
1144
|
+
"get_current_view",
|
|
1145
|
+
{
|
|
1146
|
+
description: "Get the current diagram view for a session. Returns the latest SVG (including any user edits made via the viewer page).",
|
|
1147
|
+
inputSchema: {
|
|
1148
|
+
session_key: z.string().describe("Session key from create_session.")
|
|
1149
|
+
},
|
|
1150
|
+
annotations: { readOnlyHint: true }
|
|
1151
|
+
},
|
|
1152
|
+
async ({ session_key }) => {
|
|
1153
|
+
const session = sessionStore.getSession(session_key);
|
|
1154
|
+
if (!session) {
|
|
1155
|
+
return {
|
|
1156
|
+
content: [
|
|
1157
|
+
{
|
|
1158
|
+
type: "text",
|
|
1159
|
+
text: "Session not found or expired. Create a new session with create_session."
|
|
1160
|
+
}
|
|
1161
|
+
],
|
|
1162
|
+
isError: true
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (session.elements.length === 0) {
|
|
1166
|
+
return {
|
|
1167
|
+
content: [
|
|
1168
|
+
{
|
|
1169
|
+
type: "text",
|
|
1170
|
+
text: "Session has no diagram yet. Use create_view to draw one first."
|
|
1171
|
+
}
|
|
1172
|
+
],
|
|
1173
|
+
isError: true
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
let svgString = session.svgCache;
|
|
1177
|
+
if (!svgString) {
|
|
1178
|
+
try {
|
|
1179
|
+
svgString = await renderSvg(session.elements);
|
|
1180
|
+
sessionStore.updateSvgCache(session_key, svgString);
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
svgString = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><text x="200" y="150" text-anchor="middle" fill="#999">SVG rendering failed</text></svg>';
|
|
1183
|
+
console.error("SVG rendering error:", err);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
const viewerUrl = `${baseUrl}/view/${session_key}`;
|
|
1187
|
+
const svgBase64 = Buffer.from(svgString).toString("base64");
|
|
1188
|
+
return {
|
|
1189
|
+
content: [
|
|
1190
|
+
{
|
|
1191
|
+
type: "text",
|
|
1192
|
+
text: `Current diagram view.
|
|
1193
|
+
Viewer URL: ${viewerUrl}`
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
type: "image",
|
|
1197
|
+
data: svgBase64,
|
|
1198
|
+
mimeType: "image/svg+xml"
|
|
1199
|
+
}
|
|
1200
|
+
]
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
);
|
|
1204
|
+
return server;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// src/server.ts
|
|
1208
|
+
init_shared();
|
|
1209
|
+
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
1210
|
+
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1211
|
+
import fs2 from "node:fs/promises";
|
|
1212
|
+
import path2 from "node:path";
|
|
1213
|
+
import { fileURLToPath } from "node:url";
|
|
1214
|
+
import { deflateSync } from "node:zlib";
|
|
1215
|
+
import { z as z2 } from "zod/v4";
|
|
1216
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
1217
|
+
var __dirname = path2.dirname(__filename);
|
|
1218
|
+
var DIST_DIR = __filename.endsWith(".ts") ? path2.join(__dirname, "..", "dist") : __dirname;
|
|
1219
|
+
function registerTools(server, distDir, store) {
|
|
1220
|
+
const resourceUri = "ui://excalidraw/mcp-app.html";
|
|
1221
|
+
server.registerTool(
|
|
1222
|
+
"read_me",
|
|
1223
|
+
{
|
|
1224
|
+
description: "Returns the Excalidraw element format reference with color palettes, examples, and tips. Call this BEFORE using create_view for the first time.",
|
|
1225
|
+
annotations: { readOnlyHint: true }
|
|
1226
|
+
},
|
|
1227
|
+
async () => {
|
|
1228
|
+
return { content: [{ type: "text", text: RECALL_CHEAT_SHEET }] };
|
|
1229
|
+
}
|
|
1230
|
+
);
|
|
1231
|
+
registerAppTool(
|
|
1232
|
+
server,
|
|
1233
|
+
"create_view",
|
|
1234
|
+
{
|
|
1235
|
+
title: "Draw Diagram",
|
|
1236
|
+
description: `Renders a hand-drawn diagram using Excalidraw elements.
|
|
1237
|
+
Elements stream in one by one with draw-on animations.
|
|
1238
|
+
Call read_me first to learn the element format.`,
|
|
1239
|
+
inputSchema: z2.object({
|
|
1240
|
+
elements: z2.string().describe(
|
|
1241
|
+
"JSON array string of Excalidraw elements. Must be valid JSON \u2014 no comments, no trailing commas. Keep compact. Call read_me first for format reference."
|
|
1242
|
+
)
|
|
1243
|
+
}),
|
|
1244
|
+
annotations: { readOnlyHint: true },
|
|
1245
|
+
_meta: { ui: { resourceUri } }
|
|
1246
|
+
},
|
|
1247
|
+
async ({ elements }) => {
|
|
1248
|
+
if (elements.length > MAX_INPUT_BYTES) {
|
|
1249
|
+
return {
|
|
1250
|
+
content: [{ type: "text", text: `Elements input exceeds ${MAX_INPUT_BYTES} byte limit. Reduce the number of elements or use checkpoints to build incrementally.` }],
|
|
1251
|
+
isError: true
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
let parsed;
|
|
1255
|
+
try {
|
|
1256
|
+
parsed = JSON.parse(elements);
|
|
1257
|
+
} catch (e) {
|
|
1258
|
+
return {
|
|
1259
|
+
content: [{ type: "text", text: `Invalid JSON in elements: ${e.message}. Ensure no comments, no trailing commas, and proper quoting.` }],
|
|
1260
|
+
isError: true
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
const result = await resolveElements(parsed, store);
|
|
1264
|
+
if (!result.ok) {
|
|
1265
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
1266
|
+
}
|
|
1267
|
+
const { resolvedElements, ratioHint } = result;
|
|
1268
|
+
const checkpointId = generateCheckpointId();
|
|
1269
|
+
await store.save(checkpointId, { elements: resolvedElements });
|
|
1270
|
+
return {
|
|
1271
|
+
content: [{ type: "text", text: `Diagram displayed! Checkpoint id: "${checkpointId}".
|
|
1272
|
+
If user asks to create a new diagram - simply create a new one from scratch.
|
|
1273
|
+
However, if the user wants to edit something on this diagram "${checkpointId}", take these steps:
|
|
1274
|
+
1) read widget context (using read_widget_context tool) to check if user made any manual edits first
|
|
1275
|
+
2) decide whether you want to make new diagram from scratch OR - use this one as starting checkpoint:
|
|
1276
|
+
simply start from the first element [{"type":"restoreCheckpoint","id":"${checkpointId}"}, ...your new elements...]
|
|
1277
|
+
this will use same diagram state as the user currently sees, including any manual edits they made in fullscreen, allowing you to add elements on top.
|
|
1278
|
+
To remove elements, use: {"type":"delete","ids":"<id1>,<id2>"}${ratioHint}` }],
|
|
1279
|
+
structuredContent: { checkpointId }
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
);
|
|
1283
|
+
registerAppTool(
|
|
1284
|
+
server,
|
|
1285
|
+
"export_to_excalidraw",
|
|
1286
|
+
{
|
|
1287
|
+
description: "Upload diagram to excalidraw.com and return shareable URL.",
|
|
1288
|
+
inputSchema: { json: z2.string().describe("Serialized Excalidraw JSON") },
|
|
1289
|
+
_meta: { ui: { visibility: ["app"] } }
|
|
1290
|
+
},
|
|
1291
|
+
async ({ json }) => {
|
|
1292
|
+
if (json.length > MAX_INPUT_BYTES) {
|
|
1293
|
+
return {
|
|
1294
|
+
content: [{ type: "text", text: `Export data exceeds ${MAX_INPUT_BYTES} byte limit.` }],
|
|
1295
|
+
isError: true
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const remappedJson = json;
|
|
1300
|
+
const concatBuffers = (...bufs) => {
|
|
1301
|
+
let total = 4;
|
|
1302
|
+
for (const b of bufs) total += 4 + b.length;
|
|
1303
|
+
const out = new Uint8Array(total);
|
|
1304
|
+
const dv = new DataView(out.buffer);
|
|
1305
|
+
dv.setUint32(0, 1);
|
|
1306
|
+
let off = 4;
|
|
1307
|
+
for (const b of bufs) {
|
|
1308
|
+
dv.setUint32(off, b.length);
|
|
1309
|
+
off += 4;
|
|
1310
|
+
out.set(b, off);
|
|
1311
|
+
off += b.length;
|
|
1312
|
+
}
|
|
1313
|
+
return out;
|
|
1314
|
+
};
|
|
1315
|
+
const te = new TextEncoder();
|
|
1316
|
+
const fileMetadata = te.encode(JSON.stringify({}));
|
|
1317
|
+
const dataBytes = te.encode(remappedJson);
|
|
1318
|
+
const innerPayload = concatBuffers(fileMetadata, dataBytes);
|
|
1319
|
+
const compressed = deflateSync(Buffer.from(innerPayload));
|
|
1320
|
+
const cryptoKey = await globalThis.crypto.subtle.generateKey(
|
|
1321
|
+
{ name: "AES-GCM", length: 128 },
|
|
1322
|
+
true,
|
|
1323
|
+
["encrypt"]
|
|
1324
|
+
);
|
|
1325
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
1326
|
+
const encrypted = await globalThis.crypto.subtle.encrypt(
|
|
1327
|
+
{ name: "AES-GCM", iv },
|
|
1328
|
+
cryptoKey,
|
|
1329
|
+
compressed
|
|
1330
|
+
);
|
|
1331
|
+
const encodingMeta = te.encode(JSON.stringify({
|
|
1332
|
+
version: 2,
|
|
1333
|
+
compression: "pako@1",
|
|
1334
|
+
encryption: "AES-GCM"
|
|
1335
|
+
}));
|
|
1336
|
+
const payload = Buffer.from(concatBuffers(encodingMeta, iv, new Uint8Array(encrypted)));
|
|
1337
|
+
const res = await fetch("https://json.excalidraw.com/api/v2/post/", {
|
|
1338
|
+
method: "POST",
|
|
1339
|
+
body: payload
|
|
1340
|
+
});
|
|
1341
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
|
1342
|
+
const { id } = await res.json();
|
|
1343
|
+
const jwk = await globalThis.crypto.subtle.exportKey("jwk", cryptoKey);
|
|
1344
|
+
const url = `https://excalidraw.com/#json=${id},${jwk.k}`;
|
|
1345
|
+
return { content: [{ type: "text", text: url }] };
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
return {
|
|
1348
|
+
content: [{ type: "text", text: `Export failed: ${err.message}` }],
|
|
1349
|
+
isError: true
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
);
|
|
1354
|
+
registerAppTool(
|
|
1355
|
+
server,
|
|
1356
|
+
"save_checkpoint",
|
|
1357
|
+
{
|
|
1358
|
+
description: "Update checkpoint with user-edited state.",
|
|
1359
|
+
inputSchema: { id: z2.string(), data: z2.string() },
|
|
1360
|
+
_meta: { ui: { visibility: ["app"] } }
|
|
1361
|
+
},
|
|
1362
|
+
async ({ id, data }) => {
|
|
1363
|
+
if (data.length > MAX_INPUT_BYTES) {
|
|
1364
|
+
return {
|
|
1365
|
+
content: [{ type: "text", text: `Checkpoint data exceeds ${MAX_INPUT_BYTES} byte limit.` }],
|
|
1366
|
+
isError: true
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
try {
|
|
1370
|
+
await store.save(id, JSON.parse(data));
|
|
1371
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
return { content: [{ type: "text", text: `save failed: ${err.message}` }], isError: true };
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
);
|
|
1377
|
+
registerAppTool(
|
|
1378
|
+
server,
|
|
1379
|
+
"read_checkpoint",
|
|
1380
|
+
{
|
|
1381
|
+
description: "Read checkpoint state for restore.",
|
|
1382
|
+
inputSchema: { id: z2.string() },
|
|
1383
|
+
_meta: { ui: { visibility: ["app"] } }
|
|
1384
|
+
},
|
|
1385
|
+
async ({ id }) => {
|
|
1386
|
+
try {
|
|
1387
|
+
const data = await store.load(id);
|
|
1388
|
+
if (!data) return { content: [{ type: "text", text: "" }] };
|
|
1389
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
1390
|
+
} catch (err) {
|
|
1391
|
+
return { content: [{ type: "text", text: `read failed: ${err.message}` }], isError: true };
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
const cspMeta = {
|
|
1396
|
+
ui: {
|
|
1397
|
+
csp: {
|
|
1398
|
+
resourceDomains: ["https://esm.sh"],
|
|
1399
|
+
connectDomains: ["https://esm.sh"]
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
registerAppResource(
|
|
1404
|
+
server,
|
|
1405
|
+
resourceUri,
|
|
1406
|
+
resourceUri,
|
|
1407
|
+
{ mimeType: RESOURCE_MIME_TYPE },
|
|
1408
|
+
async () => {
|
|
1409
|
+
const html = await fs2.readFile(path2.join(distDir, "mcp-app.html"), "utf-8");
|
|
1410
|
+
return {
|
|
1411
|
+
contents: [{
|
|
1412
|
+
uri: resourceUri,
|
|
1413
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
1414
|
+
text: html,
|
|
1415
|
+
_meta: {
|
|
1416
|
+
ui: {
|
|
1417
|
+
...cspMeta.ui,
|
|
1418
|
+
prefersBorder: true,
|
|
1419
|
+
permissions: { clipboardWrite: {} }
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}]
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
function createServer(store) {
|
|
1428
|
+
const server = new McpServer2({
|
|
1429
|
+
name: "Excalidraw",
|
|
1430
|
+
version: "1.0.0"
|
|
1431
|
+
});
|
|
1432
|
+
registerTools(server, DIST_DIR, store);
|
|
1433
|
+
return server;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/session-store.ts
|
|
1437
|
+
import crypto2 from "node:crypto";
|
|
1438
|
+
var SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1439
|
+
var CLEANUP_INTERVAL_MS = 10 * 60 * 1e3;
|
|
1440
|
+
var MAX_SESSIONS = 100;
|
|
1441
|
+
var SessionStore = class {
|
|
1442
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1443
|
+
cleanupTimer;
|
|
1444
|
+
constructor() {
|
|
1445
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Create a new drawing session with 24h TTL.
|
|
1449
|
+
*
|
|
1450
|
+
* @returns The newly created session.
|
|
1451
|
+
*/
|
|
1452
|
+
createSession() {
|
|
1453
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
1454
|
+
let oldestKey = null;
|
|
1455
|
+
let oldestTime = Infinity;
|
|
1456
|
+
for (const [key, session2] of this.sessions) {
|
|
1457
|
+
if (session2.createdAt.getTime() < oldestTime) {
|
|
1458
|
+
oldestTime = session2.createdAt.getTime();
|
|
1459
|
+
oldestKey = key;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (oldestKey) this.sessions.delete(oldestKey);
|
|
1463
|
+
}
|
|
1464
|
+
const sessionKey = crypto2.randomUUID();
|
|
1465
|
+
const now = /* @__PURE__ */ new Date();
|
|
1466
|
+
const session = {
|
|
1467
|
+
sessionKey,
|
|
1468
|
+
elements: [],
|
|
1469
|
+
svgCache: null,
|
|
1470
|
+
createdAt: now,
|
|
1471
|
+
expiresAt: new Date(now.getTime() + SESSION_TTL_MS)
|
|
1472
|
+
};
|
|
1473
|
+
this.sessions.set(sessionKey, session);
|
|
1474
|
+
return session;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Retrieve a session by key. Returns null if not found or expired.
|
|
1478
|
+
*
|
|
1479
|
+
* @param key - Session UUID.
|
|
1480
|
+
* @returns The session, or null if not found/expired.
|
|
1481
|
+
*/
|
|
1482
|
+
getSession(key) {
|
|
1483
|
+
const session = this.sessions.get(key);
|
|
1484
|
+
if (!session) return null;
|
|
1485
|
+
if (/* @__PURE__ */ new Date() > session.expiresAt) {
|
|
1486
|
+
this.sessions.delete(key);
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
return session;
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Update the elements for a session. Invalidates SVG cache.
|
|
1493
|
+
*
|
|
1494
|
+
* @param key - Session UUID.
|
|
1495
|
+
* @param elements - New resolved elements array.
|
|
1496
|
+
* @returns True if the session was found and updated.
|
|
1497
|
+
*/
|
|
1498
|
+
updateElements(key, elements) {
|
|
1499
|
+
const session = this.getSession(key);
|
|
1500
|
+
if (!session) return false;
|
|
1501
|
+
session.elements = elements;
|
|
1502
|
+
session.svgCache = null;
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Cache rendered SVG for a session.
|
|
1507
|
+
*
|
|
1508
|
+
* @param key - Session UUID.
|
|
1509
|
+
* @param svg - SVG string to cache.
|
|
1510
|
+
* @returns True if the session was found and updated.
|
|
1511
|
+
*/
|
|
1512
|
+
updateSvgCache(key, svg) {
|
|
1513
|
+
const session = this.getSession(key);
|
|
1514
|
+
if (!session) return false;
|
|
1515
|
+
session.svgCache = svg;
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
/** Remove all expired sessions. Called periodically by setInterval. */
|
|
1519
|
+
cleanup() {
|
|
1520
|
+
const now = /* @__PURE__ */ new Date();
|
|
1521
|
+
for (const [key, session] of this.sessions) {
|
|
1522
|
+
if (now > session.expiresAt) {
|
|
1523
|
+
this.sessions.delete(key);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
/** Stop the cleanup timer. Call on shutdown. */
|
|
1528
|
+
destroy() {
|
|
1529
|
+
clearInterval(this.cleanupTimer);
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
// src/main.ts
|
|
1534
|
+
async function startStdioServer(createServerFn) {
|
|
1535
|
+
await createServerFn().connect(new StdioServerTransport());
|
|
1536
|
+
}
|
|
1537
|
+
async function startStreamableHTTPServer(createServerFn, sessionStore, viewerDir, port = 3001) {
|
|
1538
|
+
const app = createApp(createServerFn, sessionStore, viewerDir, port);
|
|
1539
|
+
const httpServer = app.listen(port, (err) => {
|
|
1540
|
+
if (err) {
|
|
1541
|
+
console.error("Failed to start server:", err);
|
|
1542
|
+
process.exit(1);
|
|
1543
|
+
}
|
|
1544
|
+
console.log(`MCP server listening on http://localhost:${port}/mcp`);
|
|
1545
|
+
console.log(`Session API available at http://localhost:${port}/api/sessions/`);
|
|
1546
|
+
if (viewerDir) {
|
|
1547
|
+
console.log(`Viewer available at http://localhost:${port}/`);
|
|
1548
|
+
}
|
|
1549
|
+
if (process.env.BASE_URL) {
|
|
1550
|
+
console.log(`Viewer links will use base URL: ${process.env.BASE_URL}`);
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
const shutdown = () => {
|
|
1554
|
+
console.log("\nShutting down...");
|
|
1555
|
+
sessionStore.destroy();
|
|
1556
|
+
httpServer.close(() => process.exit(0));
|
|
1557
|
+
};
|
|
1558
|
+
process.on("SIGINT", shutdown);
|
|
1559
|
+
process.on("SIGTERM", shutdown);
|
|
1560
|
+
}
|
|
1561
|
+
async function main() {
|
|
1562
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
1563
|
+
if (args.help) {
|
|
1564
|
+
console.log(`Usage: interactive-drawer [options]
|
|
1565
|
+
|
|
1566
|
+
Options:
|
|
1567
|
+
--stdio Studio mode (stdin/stdout MCP transport)
|
|
1568
|
+
--port <number> HTTP server port (default: 3001, web mode only)
|
|
1569
|
+
--base-url <url> Public URL for viewer links (web mode only)
|
|
1570
|
+
--help Show this help
|
|
1571
|
+
--version Show version
|
|
1572
|
+
|
|
1573
|
+
Environment Variables:
|
|
1574
|
+
PORT Same as --port
|
|
1575
|
+
BASE_URL Same as --base-url`);
|
|
1576
|
+
process.exit(0);
|
|
1577
|
+
}
|
|
1578
|
+
if (args.version) {
|
|
1579
|
+
console.log("0.4.0");
|
|
1580
|
+
process.exit(0);
|
|
1581
|
+
}
|
|
1582
|
+
const checkpointStore = new FileCheckpointStore();
|
|
1583
|
+
if (args.stdio) {
|
|
1584
|
+
const factory = () => createServer(checkpointStore);
|
|
1585
|
+
await startStdioServer(factory);
|
|
1586
|
+
} else {
|
|
1587
|
+
const sessionStore = new SessionStore();
|
|
1588
|
+
if (args.baseUrl) process.env.BASE_URL = args.baseUrl;
|
|
1589
|
+
const __dirname2 = dirname(fileURLToPath2(import.meta.url));
|
|
1590
|
+
const viewerDir = resolve2(__dirname2, "viewer");
|
|
1591
|
+
const hasViewer = existsSync(resolve2(viewerDir, "index.html"));
|
|
1592
|
+
if (!process.env.BASE_URL && hasViewer) {
|
|
1593
|
+
process.env.BASE_URL = `http://localhost:${args.port}`;
|
|
1594
|
+
}
|
|
1595
|
+
const factory = (baseUrl) => createRemoteServer(sessionStore, checkpointStore, baseUrl);
|
|
1596
|
+
await startStreamableHTTPServer(factory, sessionStore, hasViewer ? viewerDir : void 0, args.port);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
main().catch((e) => {
|
|
1600
|
+
console.error(e);
|
|
1601
|
+
process.exit(1);
|
|
1602
|
+
});
|
|
1603
|
+
export {
|
|
1604
|
+
startStdioServer,
|
|
1605
|
+
startStreamableHTTPServer
|
|
1606
|
+
};
|