@elixpo/lixsketch 5.6.2 → 5.6.4
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 +44 -0
- package/dist/mcp/index.js +467 -8
- package/dist/mcp/index.js.map +4 -4
- package/dist/mcp/node.js.map +2 -2
- package/dist/mcp/stdio.js +488 -21
- package/dist/mcp/stdio.js.map +4 -4
- package/package.json +4 -1
- package/src/mcp/index.js +2 -0
- package/src/mcp/lixscript.js +93 -0
- package/src/mcp/remoteStore.js +99 -0
- package/src/mcp/scene.js +12 -0
- package/src/mcp/server.js +20 -2
- package/src/mcp/stdio.js +10 -4
package/README.md
CHANGED
|
@@ -185,8 +185,50 @@ The CLI can also be started directly:
|
|
|
185
185
|
npx @elixpo/lixsketch --scene ./architecture.lixjson
|
|
186
186
|
```
|
|
187
187
|
|
|
188
|
+
### Remote encrypted workspace
|
|
189
|
+
|
|
190
|
+
Signed-in workspace owners can create a scoped grant from **Profile → Workspaces → Remote MCP**. Copy the configuration when the grant is created; its token is shown once. Remote secrets are environment variables so they do not appear in the process argument list:
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"mcpServers": {
|
|
195
|
+
"lixsketch": {
|
|
196
|
+
"command": "npx",
|
|
197
|
+
"args": ["-y", "@elixpo/lixsketch", "--remote", "https://sketch.elixpo.com", "--workspace", "lx-..."],
|
|
198
|
+
"env": {
|
|
199
|
+
"LIXSKETCH_AGENT_TOKEN": "lixmcp_...",
|
|
200
|
+
"LIXSKETCH_ENCRYPTION_KEY": "..."
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The server authorizes the grant but never receives the encryption key. Decryption and encryption happen inside the local package process. Remote writes use conditional revisions, update an active collaboration room immediately, and are detected by an otherwise-open canvas through encrypted revision polling.
|
|
208
|
+
|
|
209
|
+
Deployment requires migration `0010_mcp_workspace_grants.sql`, a shared `MCP_RELAY_SECRET` on both the Pages and collaboration Worker deployments, and `MCP_RELAY_URL` on Pages pointing to the collaboration Worker origin.
|
|
210
|
+
|
|
188
211
|
The stdio channel is reserved for MCP JSON-RPC. Server status is written to stderr.
|
|
189
212
|
|
|
213
|
+
Before configuring a client, verify the executable from the same terminal used to
|
|
214
|
+
launch that client:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
type -a codex
|
|
218
|
+
node --version
|
|
219
|
+
command -v npx
|
|
220
|
+
npx -y @elixpo/lixsketch@latest --help
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Node.js 20 or newer is required. If a desktop or sandboxed client cannot resolve
|
|
224
|
+
`npx`, set its MCP `command` to the absolute path returned by `command -v npx`.
|
|
225
|
+
The generated website configuration uses the portable `npx` command because a
|
|
226
|
+
website cannot inspect local executable paths. When multiple Codex installations
|
|
227
|
+
exist, use the first installation that shares the working Node.js environment.
|
|
228
|
+
For Codex TOML configurations, set `startup_timeout_sec = 30` so the first package
|
|
229
|
+
download has enough time to complete. `codex mcp list` confirms registration;
|
|
230
|
+
restart Codex and use `/mcp` in a fresh session to confirm the handshake.
|
|
231
|
+
|
|
190
232
|
### MCP tools
|
|
191
233
|
|
|
192
234
|
| Tool | Purpose |
|
|
@@ -196,10 +238,12 @@ The stdio channel is reserved for MCP JSON-RPC. Server status is written to stde
|
|
|
196
238
|
| `canvas_validate` | Validate format, geometry, IDs, and limits |
|
|
197
239
|
| `canvas_preview` | Produce a lightweight SVG preview |
|
|
198
240
|
| `canvas_new` | Create a blank canvas after explicit confirmation |
|
|
241
|
+
| `lixscript_apply` | Compile LixScript into a validated atomic scene patch |
|
|
199
242
|
| `templates_search` | Search public marketplace templates |
|
|
200
243
|
| `template_insert` | Insert a template with remapped shape and relationship IDs |
|
|
201
244
|
|
|
202
245
|
Mutations accept `expectedRevision` for conflict detection. `canvas_apply_patch` and `template_insert` support `dryRun: true`. A single patch is either fully stored or not stored at all.
|
|
246
|
+
`lixscript_apply` supports the same revision and dry-run controls; LixScript is a compact macro over the patch engine rather than a separate mutation path.
|
|
203
247
|
|
|
204
248
|
Supported structured shape types are rectangle, circle, line, arrow, frame, freehand stroke, and text. Images and arbitrary SVG markup are intentionally excluded from direct MCP writes.
|
|
205
249
|
|
package/dist/mcp/index.js
CHANGED
|
@@ -185,12 +185,23 @@ function applyScenePatch(sceneInput, operations, { expectedRevision, dryRun = fa
|
|
|
185
185
|
scene.name = String(operation.name || "").trim().slice(0, 72) || scene.name;
|
|
186
186
|
} else throw new Error(`Unsupported operation "${operation.op}"`);
|
|
187
187
|
}
|
|
188
|
+
reconcileFrameContainment(scene);
|
|
188
189
|
scene.mcpRevision = revision + 1;
|
|
189
190
|
scene.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
190
191
|
const result = validateScene(scene);
|
|
191
192
|
if (!result.valid) throw new Error(`Patch produced an invalid scene: ${result.errors.join("; ")}`);
|
|
192
193
|
return { scene, revision: scene.mcpRevision, dryRun: Boolean(dryRun), changedShapeIDs: [...changedIds] };
|
|
193
194
|
}
|
|
195
|
+
function reconcileFrameContainment(scene) {
|
|
196
|
+
const frames = new Map(scene.shapes.filter((shape) => shape.type === "frame").map((shape) => [shape.shapeID, shape]));
|
|
197
|
+
for (const frame of frames.values()) frame.containedShapeIDs = [];
|
|
198
|
+
for (const shape of scene.shapes) {
|
|
199
|
+
if (!shape.parentFrame) continue;
|
|
200
|
+
const frame = frames.get(shape.parentFrame);
|
|
201
|
+
if (!frame) throw new Error(`Shape "${shape.shapeID}" references missing frame "${shape.parentFrame}"`);
|
|
202
|
+
frame.containedShapeIDs.push(shape.shapeID);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
194
205
|
function applyShapeChanges(shape, changes) {
|
|
195
206
|
if (!changes || typeof changes !== "object" || Array.isArray(changes)) throw new Error("Shape changes must be an object");
|
|
196
207
|
const allowed = {
|
|
@@ -261,9 +272,9 @@ function mergeTemplateScene(sceneInput, templateInput, { x, y } = {}) {
|
|
|
261
272
|
const validation = validateScene(template);
|
|
262
273
|
if (!validation.valid) throw new Error(`Template scene is invalid: ${validation.errors.join("; ")}`);
|
|
263
274
|
if (scene.shapes.length + template.shapes.length > MAX_SHAPES) throw new Error(`Imported template would exceed ${MAX_SHAPES} shapes`);
|
|
264
|
-
const
|
|
275
|
+
const bounds2 = getSceneBounds(template) || { x: 0, y: 0 };
|
|
265
276
|
const targetX = finite(x, scene.viewport?.x || 0), targetY = finite(y, scene.viewport?.y || 0);
|
|
266
|
-
const dx = targetX -
|
|
277
|
+
const dx = targetX - bounds2.x, dy = targetY - bounds2.y;
|
|
267
278
|
const idMap = new Map(template.shapes.map((shape) => [shape.shapeID, `${shape.type}-${crypto.randomUUID()}`]));
|
|
268
279
|
const imported = template.shapes.map((shape) => {
|
|
269
280
|
const moved = translateShape(shape, dx, dy);
|
|
@@ -355,9 +366,9 @@ function renderShape(shape) {
|
|
|
355
366
|
return "";
|
|
356
367
|
}
|
|
357
368
|
function renderSceneSvg(scene, { background = "#15111f", padding = 40 } = {}) {
|
|
358
|
-
const
|
|
369
|
+
const bounds2 = getSceneBounds(scene) || { x: 0, y: 0, width: 1280, height: 720 };
|
|
359
370
|
const pad = Math.max(0, Math.min(200, Number(padding) || 0));
|
|
360
|
-
const viewBox = { x:
|
|
371
|
+
const viewBox = { x: bounds2.x - pad, y: bounds2.y - pad, width: Math.max(1, bounds2.width + pad * 2), height: Math.max(1, bounds2.height + pad * 2) };
|
|
361
372
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}" width="${Math.ceil(viewBox.width)}" height="${Math.ceil(viewBox.height)}"><defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,10 3.5,0 7" fill="#8b76d6"/></marker></defs><rect x="${viewBox.x}" y="${viewBox.y}" width="${viewBox.width}" height="${viewBox.height}" fill="${color(background, "#15111f")}"/>${(scene.shapes || []).map(renderShape).join("")}</svg>`;
|
|
362
373
|
if (new TextEncoder().encode(svg).byteLength > MAX_PREVIEW_BYTES) {
|
|
363
374
|
throw new Error("Canvas preview exceeds the 5 MB output limit");
|
|
@@ -365,9 +376,349 @@ function renderSceneSvg(scene, { background = "#15111f", padding = 40 } = {}) {
|
|
|
365
376
|
return svg;
|
|
366
377
|
}
|
|
367
378
|
|
|
379
|
+
// src/core/LixScriptParser.js
|
|
380
|
+
function tokenize(source) {
|
|
381
|
+
const tokens = [];
|
|
382
|
+
const lines = source.split("\n");
|
|
383
|
+
for (let i = 0; i < lines.length; i++) {
|
|
384
|
+
const raw = lines[i];
|
|
385
|
+
const lineNum = i + 1;
|
|
386
|
+
const commentIdx = raw.indexOf("//");
|
|
387
|
+
const line = commentIdx !== -1 ? raw.slice(0, commentIdx) : raw;
|
|
388
|
+
const trimmed = line.trim();
|
|
389
|
+
if (!trimmed) continue;
|
|
390
|
+
tokens.push({ type: "LINE", value: trimmed, line: lineNum });
|
|
391
|
+
}
|
|
392
|
+
return tokens;
|
|
393
|
+
}
|
|
394
|
+
function parseLixScript(source) {
|
|
395
|
+
const tokens = tokenize(source);
|
|
396
|
+
const variables = {};
|
|
397
|
+
const shapes = [];
|
|
398
|
+
const errors = [];
|
|
399
|
+
let i = 0;
|
|
400
|
+
while (i < tokens.length) {
|
|
401
|
+
const token = tokens[i];
|
|
402
|
+
const line = token.value;
|
|
403
|
+
const lineNum = token.line;
|
|
404
|
+
try {
|
|
405
|
+
if (line.startsWith("$")) {
|
|
406
|
+
const match = line.match(/^\$(\w+)\s*=\s*(.+)$/);
|
|
407
|
+
if (match) {
|
|
408
|
+
variables[match[1]] = match[2].trim();
|
|
409
|
+
} else {
|
|
410
|
+
errors.push({ line: lineNum, message: `Invalid variable syntax: ${line}` });
|
|
411
|
+
}
|
|
412
|
+
i++;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const shapeMatch = line.match(/^(rect|circle|ellipse|arrow|line|text|frame|freehand|image|icon)\s+(\w+)\s+(.+)$/);
|
|
416
|
+
if (shapeMatch) {
|
|
417
|
+
const [, type, id, rest] = shapeMatch;
|
|
418
|
+
const shape = parseShapeDeclaration(type, id, rest, lineNum, errors, variables);
|
|
419
|
+
if (rest.includes("{") && !rest.includes("}")) {
|
|
420
|
+
i++;
|
|
421
|
+
const props = [];
|
|
422
|
+
while (i < tokens.length && !tokens[i].value.startsWith("}")) {
|
|
423
|
+
props.push(tokens[i].value);
|
|
424
|
+
i++;
|
|
425
|
+
}
|
|
426
|
+
if (i < tokens.length) i++;
|
|
427
|
+
parseProperties(shape, props, variables, errors);
|
|
428
|
+
} else if (rest.includes("{") && rest.includes("}")) {
|
|
429
|
+
const blockMatch = rest.match(/\{([^}]*)\}/);
|
|
430
|
+
if (blockMatch) {
|
|
431
|
+
const props = blockMatch[1].split(/[,;]/).map((s) => s.trim()).filter(Boolean);
|
|
432
|
+
parseProperties(shape, props, variables, errors);
|
|
433
|
+
}
|
|
434
|
+
i++;
|
|
435
|
+
} else {
|
|
436
|
+
i++;
|
|
437
|
+
}
|
|
438
|
+
shapes.push(shape);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
errors.push({ line: lineNum, message: `Unrecognized syntax: ${line}` });
|
|
442
|
+
i++;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
errors.push({ line: lineNum, message: err.message });
|
|
445
|
+
i++;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { variables, shapes, errors };
|
|
449
|
+
}
|
|
450
|
+
function parseShapeDeclaration(type, id, rest, lineNum, errors, variables) {
|
|
451
|
+
const shape = { type, id, line: lineNum, props: {} };
|
|
452
|
+
if (type === "arrow" || type === "line") {
|
|
453
|
+
const connMatch = rest.match(/from\s+(.+?)\s+to\s+(.+?)(?:\s*\{|$)/);
|
|
454
|
+
if (connMatch) {
|
|
455
|
+
shape.from = parsePointOrRef(connMatch[1].trim(), variables);
|
|
456
|
+
shape.to = parsePointOrRef(connMatch[2].trim(), variables);
|
|
457
|
+
} else {
|
|
458
|
+
errors.push({ line: lineNum, message: `${type} requires 'from ... to ...' syntax` });
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
const atMatch = rest.match(/at\s+([\w$.+\-*\s]+?),\s*([\w$.+\-*\s]+?)(?:\s+size|\s*\{|$)/);
|
|
462
|
+
if (atMatch) {
|
|
463
|
+
shape.x = parseExpr(atMatch[1].trim(), variables);
|
|
464
|
+
shape.y = parseExpr(atMatch[2].trim(), variables);
|
|
465
|
+
} else if (type !== "frame") {
|
|
466
|
+
errors.push({ line: lineNum, message: `${type} requires 'at X, Y' syntax` });
|
|
467
|
+
}
|
|
468
|
+
const sizeMatch = rest.match(/size\s+([\w$.+\-*]+)\s*x\s*([\w$.+\-*]+)/);
|
|
469
|
+
if (sizeMatch) {
|
|
470
|
+
shape.width = parseExpr(sizeMatch[1].trim(), variables);
|
|
471
|
+
shape.height = parseExpr(sizeMatch[2].trim(), variables);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return shape;
|
|
475
|
+
}
|
|
476
|
+
function parsePointOrRef(str, variables) {
|
|
477
|
+
const refMatch = str.match(/^(\w+)\.(\w+)(?:\s*([+-])\s*([\d.]+))?$/);
|
|
478
|
+
if (refMatch) {
|
|
479
|
+
return {
|
|
480
|
+
ref: refMatch[1],
|
|
481
|
+
side: refMatch[2],
|
|
482
|
+
offset: refMatch[3] ? parseFloat((refMatch[3] === "-" ? "-" : "") + refMatch[4]) : 0
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const coordMatch = str.match(/^([\d.]+)\s*,?\s*([\d.]+)$/);
|
|
486
|
+
if (coordMatch) {
|
|
487
|
+
return { x: parseFloat(coordMatch[1]), y: parseFloat(coordMatch[2]) };
|
|
488
|
+
}
|
|
489
|
+
if (/^\w+$/.test(str)) {
|
|
490
|
+
return { ref: str, side: "center", offset: 0 };
|
|
491
|
+
}
|
|
492
|
+
return { x: 0, y: 0 };
|
|
493
|
+
}
|
|
494
|
+
function parseExpr(str, variables) {
|
|
495
|
+
let resolved = str.replace(/\$(\w+)/g, (_, name) => {
|
|
496
|
+
return variables[name] !== void 0 ? variables[name] : "0";
|
|
497
|
+
});
|
|
498
|
+
const num = parseFloat(resolved);
|
|
499
|
+
if (!isNaN(num) && String(num) === resolved.trim()) {
|
|
500
|
+
return num;
|
|
501
|
+
}
|
|
502
|
+
if (/\w+\.\w+/.test(resolved)) {
|
|
503
|
+
return { expr: resolved };
|
|
504
|
+
}
|
|
505
|
+
const arithMatch = resolved.match(/^([\d.]+)\s*([+-])\s*([\d.]+)$/);
|
|
506
|
+
if (arithMatch) {
|
|
507
|
+
const a = parseFloat(arithMatch[1]);
|
|
508
|
+
const b = parseFloat(arithMatch[3]);
|
|
509
|
+
return arithMatch[2] === "+" ? a + b : a - b;
|
|
510
|
+
}
|
|
511
|
+
return isNaN(num) ? 0 : num;
|
|
512
|
+
}
|
|
513
|
+
function parseProperties(shape, lines, variables, errors) {
|
|
514
|
+
for (const line of lines) {
|
|
515
|
+
const propMatch = line.match(/^(\w+)\s*:\s*(.+)$/);
|
|
516
|
+
if (!propMatch) continue;
|
|
517
|
+
let [, key, value] = propMatch;
|
|
518
|
+
value = value.trim();
|
|
519
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
520
|
+
value = value.slice(1, -1);
|
|
521
|
+
}
|
|
522
|
+
value = value.replace(/\$(\w+)/g, (_, name) => {
|
|
523
|
+
return variables[name] !== void 0 ? variables[name] : value;
|
|
524
|
+
});
|
|
525
|
+
const num = parseFloat(value);
|
|
526
|
+
if (!isNaN(num) && String(num) === value) {
|
|
527
|
+
shape.props[key] = num;
|
|
528
|
+
} else if (value === "true") {
|
|
529
|
+
shape.props[key] = true;
|
|
530
|
+
} else if (value === "false") {
|
|
531
|
+
shape.props[key] = false;
|
|
532
|
+
} else {
|
|
533
|
+
shape.props[key] = value;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function resolveShapeRefs(shapes) {
|
|
538
|
+
const shapeMap = /* @__PURE__ */ new Map();
|
|
539
|
+
const MAX_PASSES = 10;
|
|
540
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
541
|
+
let progress = false;
|
|
542
|
+
for (const s of shapes) {
|
|
543
|
+
if (typeof s.x === "number" && typeof s.y === "number" && !shapeMap.has(s.id)) {
|
|
544
|
+
shapeMap.set(s.id, s);
|
|
545
|
+
progress = true;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
let anyUnresolved = false;
|
|
549
|
+
for (const s of shapes) {
|
|
550
|
+
if (s.x && typeof s.x === "object" && s.x.expr) {
|
|
551
|
+
const resolved = resolveExpr(s.x.expr, shapeMap);
|
|
552
|
+
if (typeof resolved === "number" && !isNaN(resolved)) {
|
|
553
|
+
s.x = resolved;
|
|
554
|
+
progress = true;
|
|
555
|
+
} else {
|
|
556
|
+
anyUnresolved = true;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (s.y && typeof s.y === "object" && s.y.expr) {
|
|
560
|
+
const resolved = resolveExpr(s.y.expr, shapeMap);
|
|
561
|
+
if (typeof resolved === "number" && !isNaN(resolved)) {
|
|
562
|
+
s.y = resolved;
|
|
563
|
+
progress = true;
|
|
564
|
+
} else {
|
|
565
|
+
anyUnresolved = true;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (!anyUnresolved || !progress) break;
|
|
570
|
+
}
|
|
571
|
+
for (const s of shapes) {
|
|
572
|
+
if (s.x && typeof s.x === "object" && s.x.expr) s.x = 0;
|
|
573
|
+
if (s.y && typeof s.y === "object" && s.y.expr) s.y = 0;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function resolveExpr(expr, shapeMap) {
|
|
577
|
+
const m = expr.match(/^(\w+)\.(\w+)(?:\s*([+-])\s*([\d.]+))?$/);
|
|
578
|
+
if (!m) return NaN;
|
|
579
|
+
const [, ref, prop, op, offsetStr] = m;
|
|
580
|
+
const shape = shapeMap.get(ref);
|
|
581
|
+
if (!shape) return NaN;
|
|
582
|
+
let val = 0;
|
|
583
|
+
switch (prop) {
|
|
584
|
+
case "x":
|
|
585
|
+
val = shape.x || 0;
|
|
586
|
+
break;
|
|
587
|
+
case "y":
|
|
588
|
+
val = shape.y || 0;
|
|
589
|
+
break;
|
|
590
|
+
case "right":
|
|
591
|
+
val = (shape.x || 0) + (shape.width || 0);
|
|
592
|
+
break;
|
|
593
|
+
case "left":
|
|
594
|
+
val = shape.x || 0;
|
|
595
|
+
break;
|
|
596
|
+
case "top":
|
|
597
|
+
val = shape.y || 0;
|
|
598
|
+
break;
|
|
599
|
+
case "bottom":
|
|
600
|
+
val = (shape.y || 0) + (shape.height || 0);
|
|
601
|
+
break;
|
|
602
|
+
case "centerX":
|
|
603
|
+
val = (shape.x || 0) + (shape.width || 0) / 2;
|
|
604
|
+
break;
|
|
605
|
+
case "centerY":
|
|
606
|
+
val = (shape.y || 0) + (shape.height || 0) / 2;
|
|
607
|
+
break;
|
|
608
|
+
case "width":
|
|
609
|
+
val = shape.width || 0;
|
|
610
|
+
break;
|
|
611
|
+
case "height":
|
|
612
|
+
val = shape.height || 0;
|
|
613
|
+
break;
|
|
614
|
+
default:
|
|
615
|
+
val = 0;
|
|
616
|
+
}
|
|
617
|
+
const offset = offsetStr ? parseFloat(offsetStr) : 0;
|
|
618
|
+
return op === "-" ? val - offset : val + offset;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/mcp/lixscript.js
|
|
622
|
+
var MAX_SOURCE_LENGTH = 1e5;
|
|
623
|
+
function options2(def) {
|
|
624
|
+
return {
|
|
625
|
+
stroke: def.props.stroke || def.props.color || "#8b76d6",
|
|
626
|
+
strokeWidth: Number(def.props.strokeWidth) || 2,
|
|
627
|
+
fill: def.props.fill || "transparent",
|
|
628
|
+
fillStyle: def.props.fillStyle || "solid",
|
|
629
|
+
roughness: def.props.roughness === void 0 ? 1.2 : Number(def.props.roughness),
|
|
630
|
+
opacity: def.props.opacity === void 0 ? 1 : Number(def.props.opacity)
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
function bounds(def) {
|
|
634
|
+
const width = Number(def.width) || (def.type === "frame" ? 600 : def.type === "rect" ? 160 : 80);
|
|
635
|
+
const height = Number(def.height) || (def.type === "frame" ? 400 : def.type === "rect" ? 60 : 80);
|
|
636
|
+
return { x: Number(def.x) || 0, y: Number(def.y) || 0, width, height };
|
|
637
|
+
}
|
|
638
|
+
function endpoint(point2, definitions) {
|
|
639
|
+
if (Number.isFinite(point2?.x) && Number.isFinite(point2?.y)) return { x: point2.x, y: point2.y };
|
|
640
|
+
const target = definitions.get(point2?.ref);
|
|
641
|
+
if (!target) throw new Error(`Cannot resolve LixScript connection target "${point2?.ref || ""}"`);
|
|
642
|
+
const box = bounds(target);
|
|
643
|
+
const offset = Number(point2.offset) || 0;
|
|
644
|
+
const side = point2.side || "center";
|
|
645
|
+
if (side === "top") return { x: box.x + box.width / 2 + offset, y: box.y };
|
|
646
|
+
if (side === "bottom") return { x: box.x + box.width / 2 + offset, y: box.y + box.height };
|
|
647
|
+
if (side === "left") return { x: box.x, y: box.y + box.height / 2 + offset };
|
|
648
|
+
if (side === "right") return { x: box.x + box.width, y: box.y + box.height / 2 + offset };
|
|
649
|
+
return { x: box.x + box.width / 2 + offset, y: box.y + box.height / 2 };
|
|
650
|
+
}
|
|
651
|
+
function labelShape(def, shapeID, parentFrame) {
|
|
652
|
+
if (!def.props.label) return null;
|
|
653
|
+
const box = bounds(def);
|
|
654
|
+
return {
|
|
655
|
+
type: "text",
|
|
656
|
+
shapeID: `${shapeID}-label`,
|
|
657
|
+
x: box.x + box.width / 2,
|
|
658
|
+
y: box.y + box.height / 2,
|
|
659
|
+
text: String(def.props.label),
|
|
660
|
+
fontSize: Number(def.props.labelFontSize) || 14,
|
|
661
|
+
color: def.props.labelColor || "#e8e3f3",
|
|
662
|
+
parentFrame
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
function compileLixScript(source, { x = 0, y = 0 } = {}) {
|
|
666
|
+
const input = String(source || "");
|
|
667
|
+
if (!input.trim()) throw new Error("LixScript source is required");
|
|
668
|
+
if (input.length > MAX_SOURCE_LENGTH) throw new Error("LixScript source exceeds 100 KB");
|
|
669
|
+
const parsed = parseLixScript(input);
|
|
670
|
+
if (parsed.errors.length) throw new Error(`LixScript parse failed: ${parsed.errors.map((entry) => `line ${entry.line}: ${entry.message}`).join("; ")}`);
|
|
671
|
+
resolveShapeRefs(parsed.shapes);
|
|
672
|
+
const prefix = `lix-${crypto.randomUUID().slice(0, 8)}`;
|
|
673
|
+
const shapeId = (id) => `${prefix}-${id}`;
|
|
674
|
+
const definitions = new Map(parsed.shapes.map((shape) => [shape.id, shape]));
|
|
675
|
+
const frame = parsed.shapes.find((shape) => shape.type === "frame");
|
|
676
|
+
const frameId = frame ? shapeId(frame.id) : `${prefix}-frame`;
|
|
677
|
+
const frameMembers = frame?.props.contains ? new Set(String(frame.props.contains).split(",").map((value) => value.trim()).filter(Boolean)) : null;
|
|
678
|
+
const shapes = [];
|
|
679
|
+
for (const def of parsed.shapes) {
|
|
680
|
+
const box = bounds(def);
|
|
681
|
+
const parentFrame = def === frame || frameMembers && !frameMembers.has(def.id) ? null : frameId;
|
|
682
|
+
let shape;
|
|
683
|
+
const id = shapeId(def.id);
|
|
684
|
+
if (def.type === "rect") shape = { type: "rectangle", shapeID: id, x: box.x + x, y: box.y + y, width: box.width, height: box.height, rotation: Number(def.props.rotation) || 0, options: options2(def), parentFrame };
|
|
685
|
+
else if (def.type === "circle" || def.type === "ellipse") shape = { type: "circle", shapeID: id, x: box.x + box.width / 2 + x, y: box.y + box.height / 2 + y, rx: box.width / 2, ry: box.height / 2, rotation: Number(def.props.rotation) || 0, options: options2(def), parentFrame };
|
|
686
|
+
else if (def.type === "text") shape = { type: "text", shapeID: id, x: box.x + x, y: box.y + y, text: String(def.props.content || def.props.text || "Text"), fontSize: Number(def.props.fontSize) || 16, color: def.props.color || def.props.fill || "#e8e3f3", fontFamily: def.props.fontFamily || "lixFont", parentFrame };
|
|
687
|
+
else if (def.type === "frame") shape = { type: "frame", shapeID: id, x: box.x + x, y: box.y + y, width: box.width, height: box.height, frameName: String(def.props.frameName || def.props.name || def.id), fillStyle: def.props.fillStyle || "transparent", fillColor: def.props.fillColor || def.props.fill || "#1e1e28", options: options2(def) };
|
|
688
|
+
else if (def.type === "freehand") {
|
|
689
|
+
const points = String(def.props.points || "").split(";").map((value) => value.split(",").map(Number)).filter((point2) => point2.length >= 2 && point2.every(Number.isFinite)).map(([px, py, pressure = 0.5]) => [px + x, py + y, pressure]);
|
|
690
|
+
shape = { type: "freehandStroke", shapeID: id, points, options: options2(def), parentFrame };
|
|
691
|
+
} else if (def.type === "line" || def.type === "arrow") {
|
|
692
|
+
const startPoint = endpoint(def.from, definitions), endPoint = endpoint(def.to, definitions);
|
|
693
|
+
startPoint.x += x;
|
|
694
|
+
startPoint.y += y;
|
|
695
|
+
endPoint.x += x;
|
|
696
|
+
endPoint.y += y;
|
|
697
|
+
shape = def.type === "line" ? { type: "line", shapeID: id, startPoint, endPoint, isCurved: def.props.curve === true || def.props.curve === "true", options: options2(def), parentFrame } : { type: "arrow", shapeID: id, startPoint, endPoint, arrowHeadStyle: def.props.head || "triangle", arrowOutlineStyle: def.props.style || "solid", arrowCurved: def.props.curve && def.props.curve !== "straight", arrowCurveAmount: Number(def.props.curveAmount) || 0.2, options: options2(def), parentFrame };
|
|
698
|
+
} else throw new Error(`LixScript ${def.type} is not writable through MCP`);
|
|
699
|
+
shapes.push(shape);
|
|
700
|
+
const label = labelShape(def, id, parentFrame);
|
|
701
|
+
if (label) {
|
|
702
|
+
label.x += x;
|
|
703
|
+
label.y += y;
|
|
704
|
+
shapes.push(label);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (!frame && shapes.length) {
|
|
708
|
+
const boxes = parsed.shapes.filter((def) => !["arrow", "line"].includes(def.type)).map(bounds);
|
|
709
|
+
const pointShapes = shapes.filter((shape) => shape.startPoint && shape.endPoint);
|
|
710
|
+
const minX = boxes.length ? Math.min(...boxes.map((box) => box.x)) + x : Math.min(...pointShapes.flatMap((shape) => [shape.startPoint.x, shape.endPoint.x]));
|
|
711
|
+
const minY = boxes.length ? Math.min(...boxes.map((box) => box.y)) + y : Math.min(...pointShapes.flatMap((shape) => [shape.startPoint.y, shape.endPoint.y]));
|
|
712
|
+
const maxX = boxes.length ? Math.max(...boxes.map((box) => box.x + box.width)) + x : Math.max(...pointShapes.flatMap((shape) => [shape.startPoint.x, shape.endPoint.x]));
|
|
713
|
+
const maxY = boxes.length ? Math.max(...boxes.map((box) => box.y + box.height)) + y : Math.max(...pointShapes.flatMap((shape) => [shape.startPoint.y, shape.endPoint.y]));
|
|
714
|
+
shapes.unshift({ type: "frame", shapeID: frameId, x: minX - 40, y: minY - 40, width: Math.max(80, maxX - minX + 80), height: Math.max(80, maxY - minY + 80), frameName: "LixScript", fillStyle: "transparent", fillColor: "#1e1e28" });
|
|
715
|
+
}
|
|
716
|
+
return { shapes, operations: shapes.map((shape) => ({ op: "add", shape })), sourceShapeCount: parsed.shapes.length };
|
|
717
|
+
}
|
|
718
|
+
|
|
368
719
|
// src/mcp/server.js
|
|
369
720
|
var SERVER_NAME = "lixsketch";
|
|
370
|
-
var SERVER_VERSION = "1.
|
|
721
|
+
var SERVER_VERSION = "1.1.0";
|
|
371
722
|
var PROTOCOL_VERSION = "2025-11-25";
|
|
372
723
|
var SUPPORTED_PROTOCOL_VERSIONS = /* @__PURE__ */ new Set([PROTOCOL_VERSION, "2025-06-18", "2024-11-05"]);
|
|
373
724
|
var PATCH_OPERATION_SCHEMA = {
|
|
@@ -415,6 +766,13 @@ var LIXSKETCH_MCP_TOOLS = Object.freeze([
|
|
|
415
766
|
inputSchema: { type: "object", required: ["confirm"], properties: { name: { type: "string", maxLength: 72 }, confirm: { const: true } }, additionalProperties: false },
|
|
416
767
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
|
|
417
768
|
},
|
|
769
|
+
{
|
|
770
|
+
name: "lixscript_apply",
|
|
771
|
+
title: "Apply LixScript diagram",
|
|
772
|
+
description: "Compile LixScript into the same validated atomic scene patch used by structured canvas edits. Supports revisions and dry runs.",
|
|
773
|
+
inputSchema: { type: "object", required: ["source"], properties: { source: { type: "string", maxLength: 1e5 }, x: { type: "number" }, y: { type: "number" }, expectedRevision: { type: "integer", minimum: 0 }, dryRun: { type: "boolean", default: false } }, additionalProperties: false },
|
|
774
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
|
|
775
|
+
},
|
|
418
776
|
{
|
|
419
777
|
name: "templates_search",
|
|
420
778
|
title: "Search LixSketch templates",
|
|
@@ -487,10 +845,20 @@ var LixSketchMcpServer = class {
|
|
|
487
845
|
case "canvas_new":
|
|
488
846
|
if (args.confirm !== true) throw new Error("canvas_new requires confirm=true");
|
|
489
847
|
return await this.enqueueMutation(async () => {
|
|
848
|
+
const current = await this.store.read();
|
|
490
849
|
const scene = createEmptyScene(args.name);
|
|
850
|
+
scene.mcpRevision = Number(current.mcpRevision || 0) + 1;
|
|
491
851
|
await this.store.write(scene);
|
|
492
852
|
return toolResult({ summary: getSceneSummary(scene) }, "Blank canvas created.");
|
|
493
853
|
});
|
|
854
|
+
case "lixscript_apply":
|
|
855
|
+
return await this.enqueueMutation(async () => {
|
|
856
|
+
const scene = await this.store.read();
|
|
857
|
+
const compiled = compileLixScript(args.source, args);
|
|
858
|
+
const result = applyScenePatch(scene, compiled.operations, args);
|
|
859
|
+
if (!args.dryRun) await this.store.write(result.scene);
|
|
860
|
+
return toolResult({ revision: result.revision, dryRun: result.dryRun, sourceShapeCount: compiled.sourceShapeCount, createdShapeIDs: result.changedShapeIDs, summary: getSceneSummary(result.scene) }, args.dryRun ? "LixScript is valid. No changes were saved." : `LixScript added ${result.changedShapeIDs.length} canvas elements.`);
|
|
861
|
+
});
|
|
494
862
|
case "template_insert":
|
|
495
863
|
return await this.enqueueMutation(async () => {
|
|
496
864
|
const scene = await this.store.read();
|
|
@@ -519,7 +887,7 @@ var LixSketchMcpServer = class {
|
|
|
519
887
|
if (method === "initialize") {
|
|
520
888
|
const requested = request.params?.protocolVersion;
|
|
521
889
|
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested) ? requested : PROTOCOL_VERSION;
|
|
522
|
-
return { protocolVersion, capabilities: { tools: { listChanged: false }, resources: { subscribe: false, listChanged: false } }, serverInfo: this.serverInfo, instructions: "
|
|
890
|
+
return { protocolVersion, capabilities: { tools: { listChanged: false }, resources: { subscribe: false, listChanged: false } }, serverInfo: this.serverInfo, instructions: "Before any mutation, call canvas_get and retain its revision. Preserve unrelated shapes. Send expectedRevision and dryRun: true first, inspect canvas_preview for layout changes, then commit the same validated operation with dryRun: false. Never clear or replace a canvas unless the user explicitly requests it. Prefer lixscript_apply for diagrams and template_insert for reusable component packs." };
|
|
523
891
|
}
|
|
524
892
|
if (method === "ping") return {};
|
|
525
893
|
if (method === "tools/list") return { tools: this.listTools() };
|
|
@@ -542,8 +910,8 @@ function encodeBase64(value) {
|
|
|
542
910
|
if (typeof btoa === "function") return btoa(unescape(encodeURIComponent(value)));
|
|
543
911
|
return Buffer.from(value, "utf8").toString("base64");
|
|
544
912
|
}
|
|
545
|
-
function createLixSketchMcpServer(
|
|
546
|
-
return new LixSketchMcpServer(
|
|
913
|
+
function createLixSketchMcpServer(options3) {
|
|
914
|
+
return new LixSketchMcpServer(options3);
|
|
547
915
|
}
|
|
548
916
|
|
|
549
917
|
// src/mcp/store.js
|
|
@@ -564,6 +932,93 @@ var MemorySceneStore = class {
|
|
|
564
932
|
return this.read();
|
|
565
933
|
}
|
|
566
934
|
};
|
|
935
|
+
|
|
936
|
+
// src/mcp/remoteStore.js
|
|
937
|
+
function decodeBase64Url2(value) {
|
|
938
|
+
const base64 = String(value).replaceAll("-", "+").replaceAll("_", "/");
|
|
939
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
940
|
+
const binary = atob(padded);
|
|
941
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
942
|
+
}
|
|
943
|
+
function encodeBase64Url(bytes) {
|
|
944
|
+
let binary = "";
|
|
945
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
946
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
947
|
+
}
|
|
948
|
+
async function importWorkspaceKey(keyValue, usages) {
|
|
949
|
+
const bytes = decodeBase64Url2(keyValue);
|
|
950
|
+
if (bytes.byteLength !== 32) throw new Error("The workspace encryption key is not AES-256");
|
|
951
|
+
return crypto.subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, usages);
|
|
952
|
+
}
|
|
953
|
+
async function decryptRemoteScene(ciphertext, keyValue) {
|
|
954
|
+
const combined = decodeBase64Url2(ciphertext);
|
|
955
|
+
if (combined.byteLength < 28) throw new Error("The encrypted workspace payload is invalid");
|
|
956
|
+
const key = await importWorkspaceKey(keyValue, ["decrypt"]);
|
|
957
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: combined.slice(0, 12) }, key, combined.slice(12));
|
|
958
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
959
|
+
}
|
|
960
|
+
async function encryptRemoteScene(scene, keyValue) {
|
|
961
|
+
const key = await importWorkspaceKey(keyValue, ["encrypt"]);
|
|
962
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
963
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(scene));
|
|
964
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext));
|
|
965
|
+
const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);
|
|
966
|
+
combined.set(iv);
|
|
967
|
+
combined.set(ciphertext, iv.byteLength);
|
|
968
|
+
return encodeBase64Url(combined);
|
|
969
|
+
}
|
|
970
|
+
var RemoteSceneStore = class {
|
|
971
|
+
constructor({ baseUrl = "https://sketch.elixpo.com", workspaceId, token, encryptionKey, fetchImpl = globalThis.fetch } = {}) {
|
|
972
|
+
if (!workspaceId) throw new Error("RemoteSceneStore requires workspaceId");
|
|
973
|
+
if (!token) throw new Error("RemoteSceneStore requires an agent grant token");
|
|
974
|
+
if (!encryptionKey) throw new Error("RemoteSceneStore requires the workspace encryption key");
|
|
975
|
+
if (typeof fetchImpl !== "function") throw new Error("RemoteSceneStore requires fetch");
|
|
976
|
+
this.url = new URL(`/api/mcp/workspaces/${encodeURIComponent(workspaceId)}`, String(baseUrl).replace(/\/$/, ""));
|
|
977
|
+
this.workspaceId = workspaceId;
|
|
978
|
+
this.token = token;
|
|
979
|
+
this.encryptionKey = encryptionKey;
|
|
980
|
+
this.fetch = fetchImpl;
|
|
981
|
+
this.remoteRevision = null;
|
|
982
|
+
}
|
|
983
|
+
async read() {
|
|
984
|
+
const response = await this.fetch(this.url, { headers: this.headers(), cache: "no-store" });
|
|
985
|
+
const body = await readJson(response);
|
|
986
|
+
if (!response.ok) throw remoteError(response, body);
|
|
987
|
+
const scene = await decryptRemoteScene(body.encryptedData, this.encryptionKey);
|
|
988
|
+
const validation = validateScene(scene);
|
|
989
|
+
if (!validation.valid) throw new Error(`Remote workspace is invalid: ${validation.errors.join("; ")}`);
|
|
990
|
+
this.remoteRevision = Number(body.revision || 0);
|
|
991
|
+
scene.mcpRevision = this.remoteRevision;
|
|
992
|
+
return scene;
|
|
993
|
+
}
|
|
994
|
+
async write(scene) {
|
|
995
|
+
const validation = validateScene(scene);
|
|
996
|
+
if (!validation.valid) throw new Error(`Refusing to store invalid remote scene: ${validation.errors.join("; ")}`);
|
|
997
|
+
if (!Number.isInteger(this.remoteRevision)) throw new Error("Read the remote workspace before writing it");
|
|
998
|
+
const encryptedData = await encryptRemoteScene(scene, this.encryptionKey);
|
|
999
|
+
const response = await this.fetch(this.url, {
|
|
1000
|
+
method: "PUT",
|
|
1001
|
+
headers: { ...this.headers(), "Content-Type": "application/json" },
|
|
1002
|
+
body: JSON.stringify({ encryptedData, expectedRevision: this.remoteRevision, workspaceName: scene.name })
|
|
1003
|
+
});
|
|
1004
|
+
const body = await readJson(response);
|
|
1005
|
+
if (!response.ok) throw remoteError(response, body);
|
|
1006
|
+
this.remoteRevision = Number(body.revision);
|
|
1007
|
+
return structuredClone({ ...scene, mcpRevision: this.remoteRevision });
|
|
1008
|
+
}
|
|
1009
|
+
headers() {
|
|
1010
|
+
return { Accept: "application/json", Authorization: `Bearer ${this.token}` };
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
async function readJson(response) {
|
|
1014
|
+
return response.json().catch(() => ({}));
|
|
1015
|
+
}
|
|
1016
|
+
function remoteError(response, body) {
|
|
1017
|
+
const error = new Error(body.error === "REVISION_CONFLICT" ? `Revision conflict: expected ${body.expectedRevision}, current ${body.currentRevision}` : body.error || `Remote workspace request failed (${response.status})`);
|
|
1018
|
+
error.status = response.status;
|
|
1019
|
+
error.details = body;
|
|
1020
|
+
return error;
|
|
1021
|
+
}
|
|
567
1022
|
export {
|
|
568
1023
|
PROTOCOL_VERSION as LIXSKETCH_MCP_PROTOCOL_VERSION,
|
|
569
1024
|
LIXSKETCH_MCP_TOOLS,
|
|
@@ -571,10 +1026,14 @@ export {
|
|
|
571
1026
|
MCP_LIMITS,
|
|
572
1027
|
MarketplaceTemplateProvider,
|
|
573
1028
|
MemorySceneStore,
|
|
1029
|
+
RemoteSceneStore,
|
|
574
1030
|
applyScenePatch,
|
|
1031
|
+
compileLixScript,
|
|
575
1032
|
createEmptyScene,
|
|
576
1033
|
createLixSketchMcpServer,
|
|
577
1034
|
decryptPublicTemplate,
|
|
1035
|
+
decryptRemoteScene,
|
|
1036
|
+
encryptRemoteScene,
|
|
578
1037
|
getSceneBounds,
|
|
579
1038
|
getSceneSummary,
|
|
580
1039
|
mergeTemplateScene,
|