@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 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 bounds = getSceneBounds(template) || { x: 0, y: 0 };
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 - bounds.x, dy = targetY - bounds.y;
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 bounds = getSceneBounds(scene) || { x: 0, y: 0, width: 1280, height: 720 };
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: bounds.x - pad, y: bounds.y - pad, width: Math.max(1, bounds.width + pad * 2), height: Math.max(1, bounds.height + pad * 2) };
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.0.0";
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: "Read canvas_get before mutations. Use expectedRevision and dryRun for safe edits. Prefer template_insert for reusable component packs." };
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(options2) {
546
- return new LixSketchMcpServer(options2);
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,