@elixpo/lixsketch 5.6.2 → 5.6.3

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/dist/mcp/stdio.js CHANGED
@@ -187,12 +187,23 @@ function applyScenePatch(sceneInput, operations, { expectedRevision, dryRun = fa
187
187
  scene.name = String(operation.name || "").trim().slice(0, 72) || scene.name;
188
188
  } else throw new Error(`Unsupported operation "${operation.op}"`);
189
189
  }
190
+ reconcileFrameContainment(scene);
190
191
  scene.mcpRevision = revision + 1;
191
192
  scene.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
192
193
  const result = validateScene(scene);
193
194
  if (!result.valid) throw new Error(`Patch produced an invalid scene: ${result.errors.join("; ")}`);
194
195
  return { scene, revision: scene.mcpRevision, dryRun: Boolean(dryRun), changedShapeIDs: [...changedIds] };
195
196
  }
197
+ function reconcileFrameContainment(scene) {
198
+ const frames = new Map(scene.shapes.filter((shape) => shape.type === "frame").map((shape) => [shape.shapeID, shape]));
199
+ for (const frame of frames.values()) frame.containedShapeIDs = [];
200
+ for (const shape of scene.shapes) {
201
+ if (!shape.parentFrame) continue;
202
+ const frame = frames.get(shape.parentFrame);
203
+ if (!frame) throw new Error(`Shape "${shape.shapeID}" references missing frame "${shape.parentFrame}"`);
204
+ frame.containedShapeIDs.push(shape.shapeID);
205
+ }
206
+ }
196
207
  function applyShapeChanges(shape, changes) {
197
208
  if (!changes || typeof changes !== "object" || Array.isArray(changes)) throw new Error("Shape changes must be an object");
198
209
  const allowed = {
@@ -263,9 +274,9 @@ function mergeTemplateScene(sceneInput, templateInput, { x, y } = {}) {
263
274
  const validation = validateScene(template);
264
275
  if (!validation.valid) throw new Error(`Template scene is invalid: ${validation.errors.join("; ")}`);
265
276
  if (scene.shapes.length + template.shapes.length > MAX_SHAPES) throw new Error(`Imported template would exceed ${MAX_SHAPES} shapes`);
266
- const bounds = getSceneBounds(template) || { x: 0, y: 0 };
277
+ const bounds2 = getSceneBounds(template) || { x: 0, y: 0 };
267
278
  const targetX = finite(x, scene.viewport?.x || 0), targetY = finite(y, scene.viewport?.y || 0);
268
- const dx = targetX - bounds.x, dy = targetY - bounds.y;
279
+ const dx = targetX - bounds2.x, dy = targetY - bounds2.y;
269
280
  const idMap = new Map(template.shapes.map((shape) => [shape.shapeID, `${shape.type}-${crypto.randomUUID()}`]));
270
281
  const imported = template.shapes.map((shape) => {
271
282
  const moved = translateShape(shape, dx, dy);
@@ -357,9 +368,9 @@ function renderShape(shape) {
357
368
  return "";
358
369
  }
359
370
  function renderSceneSvg(scene, { background = "#15111f", padding = 40 } = {}) {
360
- const bounds = getSceneBounds(scene) || { x: 0, y: 0, width: 1280, height: 720 };
371
+ const bounds2 = getSceneBounds(scene) || { x: 0, y: 0, width: 1280, height: 720 };
361
372
  const pad = Math.max(0, Math.min(200, Number(padding) || 0));
362
- 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) };
373
+ 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) };
363
374
  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>`;
364
375
  if (new TextEncoder().encode(svg).byteLength > MAX_PREVIEW_BYTES) {
365
376
  throw new Error("Canvas preview exceeds the 5 MB output limit");
@@ -367,9 +378,349 @@ function renderSceneSvg(scene, { background = "#15111f", padding = 40 } = {}) {
367
378
  return svg;
368
379
  }
369
380
 
381
+ // src/core/LixScriptParser.js
382
+ function tokenize(source) {
383
+ const tokens = [];
384
+ const lines = source.split("\n");
385
+ for (let i = 0; i < lines.length; i++) {
386
+ const raw = lines[i];
387
+ const lineNum = i + 1;
388
+ const commentIdx = raw.indexOf("//");
389
+ const line = commentIdx !== -1 ? raw.slice(0, commentIdx) : raw;
390
+ const trimmed = line.trim();
391
+ if (!trimmed) continue;
392
+ tokens.push({ type: "LINE", value: trimmed, line: lineNum });
393
+ }
394
+ return tokens;
395
+ }
396
+ function parseLixScript(source) {
397
+ const tokens = tokenize(source);
398
+ const variables = {};
399
+ const shapes = [];
400
+ const errors = [];
401
+ let i = 0;
402
+ while (i < tokens.length) {
403
+ const token = tokens[i];
404
+ const line = token.value;
405
+ const lineNum = token.line;
406
+ try {
407
+ if (line.startsWith("$")) {
408
+ const match = line.match(/^\$(\w+)\s*=\s*(.+)$/);
409
+ if (match) {
410
+ variables[match[1]] = match[2].trim();
411
+ } else {
412
+ errors.push({ line: lineNum, message: `Invalid variable syntax: ${line}` });
413
+ }
414
+ i++;
415
+ continue;
416
+ }
417
+ const shapeMatch = line.match(/^(rect|circle|ellipse|arrow|line|text|frame|freehand|image|icon)\s+(\w+)\s+(.+)$/);
418
+ if (shapeMatch) {
419
+ const [, type, id, rest] = shapeMatch;
420
+ const shape = parseShapeDeclaration(type, id, rest, lineNum, errors, variables);
421
+ if (rest.includes("{") && !rest.includes("}")) {
422
+ i++;
423
+ const props = [];
424
+ while (i < tokens.length && !tokens[i].value.startsWith("}")) {
425
+ props.push(tokens[i].value);
426
+ i++;
427
+ }
428
+ if (i < tokens.length) i++;
429
+ parseProperties(shape, props, variables, errors);
430
+ } else if (rest.includes("{") && rest.includes("}")) {
431
+ const blockMatch = rest.match(/\{([^}]*)\}/);
432
+ if (blockMatch) {
433
+ const props = blockMatch[1].split(/[,;]/).map((s) => s.trim()).filter(Boolean);
434
+ parseProperties(shape, props, variables, errors);
435
+ }
436
+ i++;
437
+ } else {
438
+ i++;
439
+ }
440
+ shapes.push(shape);
441
+ continue;
442
+ }
443
+ errors.push({ line: lineNum, message: `Unrecognized syntax: ${line}` });
444
+ i++;
445
+ } catch (err) {
446
+ errors.push({ line: lineNum, message: err.message });
447
+ i++;
448
+ }
449
+ }
450
+ return { variables, shapes, errors };
451
+ }
452
+ function parseShapeDeclaration(type, id, rest, lineNum, errors, variables) {
453
+ const shape = { type, id, line: lineNum, props: {} };
454
+ if (type === "arrow" || type === "line") {
455
+ const connMatch = rest.match(/from\s+(.+?)\s+to\s+(.+?)(?:\s*\{|$)/);
456
+ if (connMatch) {
457
+ shape.from = parsePointOrRef(connMatch[1].trim(), variables);
458
+ shape.to = parsePointOrRef(connMatch[2].trim(), variables);
459
+ } else {
460
+ errors.push({ line: lineNum, message: `${type} requires 'from ... to ...' syntax` });
461
+ }
462
+ } else {
463
+ const atMatch = rest.match(/at\s+([\w$.+\-*\s]+?),\s*([\w$.+\-*\s]+?)(?:\s+size|\s*\{|$)/);
464
+ if (atMatch) {
465
+ shape.x = parseExpr(atMatch[1].trim(), variables);
466
+ shape.y = parseExpr(atMatch[2].trim(), variables);
467
+ } else if (type !== "frame") {
468
+ errors.push({ line: lineNum, message: `${type} requires 'at X, Y' syntax` });
469
+ }
470
+ const sizeMatch = rest.match(/size\s+([\w$.+\-*]+)\s*x\s*([\w$.+\-*]+)/);
471
+ if (sizeMatch) {
472
+ shape.width = parseExpr(sizeMatch[1].trim(), variables);
473
+ shape.height = parseExpr(sizeMatch[2].trim(), variables);
474
+ }
475
+ }
476
+ return shape;
477
+ }
478
+ function parsePointOrRef(str, variables) {
479
+ const refMatch = str.match(/^(\w+)\.(\w+)(?:\s*([+-])\s*([\d.]+))?$/);
480
+ if (refMatch) {
481
+ return {
482
+ ref: refMatch[1],
483
+ side: refMatch[2],
484
+ offset: refMatch[3] ? parseFloat((refMatch[3] === "-" ? "-" : "") + refMatch[4]) : 0
485
+ };
486
+ }
487
+ const coordMatch = str.match(/^([\d.]+)\s*,?\s*([\d.]+)$/);
488
+ if (coordMatch) {
489
+ return { x: parseFloat(coordMatch[1]), y: parseFloat(coordMatch[2]) };
490
+ }
491
+ if (/^\w+$/.test(str)) {
492
+ return { ref: str, side: "center", offset: 0 };
493
+ }
494
+ return { x: 0, y: 0 };
495
+ }
496
+ function parseExpr(str, variables) {
497
+ let resolved = str.replace(/\$(\w+)/g, (_, name) => {
498
+ return variables[name] !== void 0 ? variables[name] : "0";
499
+ });
500
+ const num = parseFloat(resolved);
501
+ if (!isNaN(num) && String(num) === resolved.trim()) {
502
+ return num;
503
+ }
504
+ if (/\w+\.\w+/.test(resolved)) {
505
+ return { expr: resolved };
506
+ }
507
+ const arithMatch = resolved.match(/^([\d.]+)\s*([+-])\s*([\d.]+)$/);
508
+ if (arithMatch) {
509
+ const a = parseFloat(arithMatch[1]);
510
+ const b = parseFloat(arithMatch[3]);
511
+ return arithMatch[2] === "+" ? a + b : a - b;
512
+ }
513
+ return isNaN(num) ? 0 : num;
514
+ }
515
+ function parseProperties(shape, lines, variables, errors) {
516
+ for (const line of lines) {
517
+ const propMatch = line.match(/^(\w+)\s*:\s*(.+)$/);
518
+ if (!propMatch) continue;
519
+ let [, key, value] = propMatch;
520
+ value = value.trim();
521
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
522
+ value = value.slice(1, -1);
523
+ }
524
+ value = value.replace(/\$(\w+)/g, (_, name) => {
525
+ return variables[name] !== void 0 ? variables[name] : value;
526
+ });
527
+ const num = parseFloat(value);
528
+ if (!isNaN(num) && String(num) === value) {
529
+ shape.props[key] = num;
530
+ } else if (value === "true") {
531
+ shape.props[key] = true;
532
+ } else if (value === "false") {
533
+ shape.props[key] = false;
534
+ } else {
535
+ shape.props[key] = value;
536
+ }
537
+ }
538
+ }
539
+ function resolveShapeRefs(shapes) {
540
+ const shapeMap = /* @__PURE__ */ new Map();
541
+ const MAX_PASSES = 10;
542
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
543
+ let progress = false;
544
+ for (const s of shapes) {
545
+ if (typeof s.x === "number" && typeof s.y === "number" && !shapeMap.has(s.id)) {
546
+ shapeMap.set(s.id, s);
547
+ progress = true;
548
+ }
549
+ }
550
+ let anyUnresolved = false;
551
+ for (const s of shapes) {
552
+ if (s.x && typeof s.x === "object" && s.x.expr) {
553
+ const resolved = resolveExpr(s.x.expr, shapeMap);
554
+ if (typeof resolved === "number" && !isNaN(resolved)) {
555
+ s.x = resolved;
556
+ progress = true;
557
+ } else {
558
+ anyUnresolved = true;
559
+ }
560
+ }
561
+ if (s.y && typeof s.y === "object" && s.y.expr) {
562
+ const resolved = resolveExpr(s.y.expr, shapeMap);
563
+ if (typeof resolved === "number" && !isNaN(resolved)) {
564
+ s.y = resolved;
565
+ progress = true;
566
+ } else {
567
+ anyUnresolved = true;
568
+ }
569
+ }
570
+ }
571
+ if (!anyUnresolved || !progress) break;
572
+ }
573
+ for (const s of shapes) {
574
+ if (s.x && typeof s.x === "object" && s.x.expr) s.x = 0;
575
+ if (s.y && typeof s.y === "object" && s.y.expr) s.y = 0;
576
+ }
577
+ }
578
+ function resolveExpr(expr, shapeMap) {
579
+ const m = expr.match(/^(\w+)\.(\w+)(?:\s*([+-])\s*([\d.]+))?$/);
580
+ if (!m) return NaN;
581
+ const [, ref, prop, op, offsetStr] = m;
582
+ const shape = shapeMap.get(ref);
583
+ if (!shape) return NaN;
584
+ let val = 0;
585
+ switch (prop) {
586
+ case "x":
587
+ val = shape.x || 0;
588
+ break;
589
+ case "y":
590
+ val = shape.y || 0;
591
+ break;
592
+ case "right":
593
+ val = (shape.x || 0) + (shape.width || 0);
594
+ break;
595
+ case "left":
596
+ val = shape.x || 0;
597
+ break;
598
+ case "top":
599
+ val = shape.y || 0;
600
+ break;
601
+ case "bottom":
602
+ val = (shape.y || 0) + (shape.height || 0);
603
+ break;
604
+ case "centerX":
605
+ val = (shape.x || 0) + (shape.width || 0) / 2;
606
+ break;
607
+ case "centerY":
608
+ val = (shape.y || 0) + (shape.height || 0) / 2;
609
+ break;
610
+ case "width":
611
+ val = shape.width || 0;
612
+ break;
613
+ case "height":
614
+ val = shape.height || 0;
615
+ break;
616
+ default:
617
+ val = 0;
618
+ }
619
+ const offset = offsetStr ? parseFloat(offsetStr) : 0;
620
+ return op === "-" ? val - offset : val + offset;
621
+ }
622
+
623
+ // src/mcp/lixscript.js
624
+ var MAX_SOURCE_LENGTH = 1e5;
625
+ function options2(def) {
626
+ return {
627
+ stroke: def.props.stroke || def.props.color || "#8b76d6",
628
+ strokeWidth: Number(def.props.strokeWidth) || 2,
629
+ fill: def.props.fill || "transparent",
630
+ fillStyle: def.props.fillStyle || "solid",
631
+ roughness: def.props.roughness === void 0 ? 1.2 : Number(def.props.roughness),
632
+ opacity: def.props.opacity === void 0 ? 1 : Number(def.props.opacity)
633
+ };
634
+ }
635
+ function bounds(def) {
636
+ const width = Number(def.width) || (def.type === "frame" ? 600 : def.type === "rect" ? 160 : 80);
637
+ const height = Number(def.height) || (def.type === "frame" ? 400 : def.type === "rect" ? 60 : 80);
638
+ return { x: Number(def.x) || 0, y: Number(def.y) || 0, width, height };
639
+ }
640
+ function endpoint(point2, definitions) {
641
+ if (Number.isFinite(point2?.x) && Number.isFinite(point2?.y)) return { x: point2.x, y: point2.y };
642
+ const target = definitions.get(point2?.ref);
643
+ if (!target) throw new Error(`Cannot resolve LixScript connection target "${point2?.ref || ""}"`);
644
+ const box = bounds(target);
645
+ const offset = Number(point2.offset) || 0;
646
+ const side = point2.side || "center";
647
+ if (side === "top") return { x: box.x + box.width / 2 + offset, y: box.y };
648
+ if (side === "bottom") return { x: box.x + box.width / 2 + offset, y: box.y + box.height };
649
+ if (side === "left") return { x: box.x, y: box.y + box.height / 2 + offset };
650
+ if (side === "right") return { x: box.x + box.width, y: box.y + box.height / 2 + offset };
651
+ return { x: box.x + box.width / 2 + offset, y: box.y + box.height / 2 };
652
+ }
653
+ function labelShape(def, shapeID, parentFrame) {
654
+ if (!def.props.label) return null;
655
+ const box = bounds(def);
656
+ return {
657
+ type: "text",
658
+ shapeID: `${shapeID}-label`,
659
+ x: box.x + box.width / 2,
660
+ y: box.y + box.height / 2,
661
+ text: String(def.props.label),
662
+ fontSize: Number(def.props.labelFontSize) || 14,
663
+ color: def.props.labelColor || "#e8e3f3",
664
+ parentFrame
665
+ };
666
+ }
667
+ function compileLixScript(source, { x = 0, y = 0 } = {}) {
668
+ const input = String(source || "");
669
+ if (!input.trim()) throw new Error("LixScript source is required");
670
+ if (input.length > MAX_SOURCE_LENGTH) throw new Error("LixScript source exceeds 100 KB");
671
+ const parsed = parseLixScript(input);
672
+ if (parsed.errors.length) throw new Error(`LixScript parse failed: ${parsed.errors.map((entry) => `line ${entry.line}: ${entry.message}`).join("; ")}`);
673
+ resolveShapeRefs(parsed.shapes);
674
+ const prefix = `lix-${crypto.randomUUID().slice(0, 8)}`;
675
+ const shapeId = (id) => `${prefix}-${id}`;
676
+ const definitions = new Map(parsed.shapes.map((shape) => [shape.id, shape]));
677
+ const frame = parsed.shapes.find((shape) => shape.type === "frame");
678
+ const frameId = frame ? shapeId(frame.id) : `${prefix}-frame`;
679
+ const frameMembers = frame?.props.contains ? new Set(String(frame.props.contains).split(",").map((value) => value.trim()).filter(Boolean)) : null;
680
+ const shapes = [];
681
+ for (const def of parsed.shapes) {
682
+ const box = bounds(def);
683
+ const parentFrame = def === frame || frameMembers && !frameMembers.has(def.id) ? null : frameId;
684
+ let shape;
685
+ const id = shapeId(def.id);
686
+ 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 };
687
+ 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 };
688
+ 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 };
689
+ 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) };
690
+ else if (def.type === "freehand") {
691
+ 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]);
692
+ shape = { type: "freehandStroke", shapeID: id, points, options: options2(def), parentFrame };
693
+ } else if (def.type === "line" || def.type === "arrow") {
694
+ const startPoint = endpoint(def.from, definitions), endPoint = endpoint(def.to, definitions);
695
+ startPoint.x += x;
696
+ startPoint.y += y;
697
+ endPoint.x += x;
698
+ endPoint.y += y;
699
+ 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 };
700
+ } else throw new Error(`LixScript ${def.type} is not writable through MCP`);
701
+ shapes.push(shape);
702
+ const label = labelShape(def, id, parentFrame);
703
+ if (label) {
704
+ label.x += x;
705
+ label.y += y;
706
+ shapes.push(label);
707
+ }
708
+ }
709
+ if (!frame && shapes.length) {
710
+ const boxes = parsed.shapes.filter((def) => !["arrow", "line"].includes(def.type)).map(bounds);
711
+ const pointShapes = shapes.filter((shape) => shape.startPoint && shape.endPoint);
712
+ const minX = boxes.length ? Math.min(...boxes.map((box) => box.x)) + x : Math.min(...pointShapes.flatMap((shape) => [shape.startPoint.x, shape.endPoint.x]));
713
+ const minY = boxes.length ? Math.min(...boxes.map((box) => box.y)) + y : Math.min(...pointShapes.flatMap((shape) => [shape.startPoint.y, shape.endPoint.y]));
714
+ 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]));
715
+ 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]));
716
+ 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" });
717
+ }
718
+ return { shapes, operations: shapes.map((shape) => ({ op: "add", shape })), sourceShapeCount: parsed.shapes.length };
719
+ }
720
+
370
721
  // src/mcp/server.js
371
722
  var SERVER_NAME = "lixsketch";
372
- var SERVER_VERSION = "1.0.0";
723
+ var SERVER_VERSION = "1.1.0";
373
724
  var PROTOCOL_VERSION = "2025-11-25";
374
725
  var SUPPORTED_PROTOCOL_VERSIONS = /* @__PURE__ */ new Set([PROTOCOL_VERSION, "2025-06-18", "2024-11-05"]);
375
726
  var PATCH_OPERATION_SCHEMA = {
@@ -417,6 +768,13 @@ var LIXSKETCH_MCP_TOOLS = Object.freeze([
417
768
  inputSchema: { type: "object", required: ["confirm"], properties: { name: { type: "string", maxLength: 72 }, confirm: { const: true } }, additionalProperties: false },
418
769
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
419
770
  },
771
+ {
772
+ name: "lixscript_apply",
773
+ title: "Apply LixScript diagram",
774
+ description: "Compile LixScript into the same validated atomic scene patch used by structured canvas edits. Supports revisions and dry runs.",
775
+ 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 },
776
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
777
+ },
420
778
  {
421
779
  name: "templates_search",
422
780
  title: "Search LixSketch templates",
@@ -489,10 +847,20 @@ var LixSketchMcpServer = class {
489
847
  case "canvas_new":
490
848
  if (args.confirm !== true) throw new Error("canvas_new requires confirm=true");
491
849
  return await this.enqueueMutation(async () => {
850
+ const current = await this.store.read();
492
851
  const scene = createEmptyScene(args.name);
852
+ scene.mcpRevision = Number(current.mcpRevision || 0) + 1;
493
853
  await this.store.write(scene);
494
854
  return toolResult({ summary: getSceneSummary(scene) }, "Blank canvas created.");
495
855
  });
856
+ case "lixscript_apply":
857
+ return await this.enqueueMutation(async () => {
858
+ const scene = await this.store.read();
859
+ const compiled = compileLixScript(args.source, args);
860
+ const result = applyScenePatch(scene, compiled.operations, args);
861
+ if (!args.dryRun) await this.store.write(result.scene);
862
+ 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.`);
863
+ });
496
864
  case "template_insert":
497
865
  return await this.enqueueMutation(async () => {
498
866
  const scene = await this.store.read();
@@ -544,8 +912,8 @@ function encodeBase64(value) {
544
912
  if (typeof btoa === "function") return btoa(unescape(encodeURIComponent(value)));
545
913
  return Buffer.from(value, "utf8").toString("base64");
546
914
  }
547
- function createLixSketchMcpServer(options2) {
548
- return new LixSketchMcpServer(options2);
915
+ function createLixSketchMcpServer(options3) {
916
+ return new LixSketchMcpServer(options3);
549
917
  }
550
918
 
551
919
  // src/mcp/fileStore.js
@@ -668,50 +1036,149 @@ function serveLixSketchStdio(server, { input = process.stdin, output = process.s
668
1036
  };
669
1037
  }
670
1038
 
1039
+ // src/mcp/remoteStore.js
1040
+ function decodeBase64Url2(value) {
1041
+ const base64 = String(value).replaceAll("-", "+").replaceAll("_", "/");
1042
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
1043
+ const binary = atob(padded);
1044
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
1045
+ }
1046
+ function encodeBase64Url(bytes) {
1047
+ let binary = "";
1048
+ for (const byte of bytes) binary += String.fromCharCode(byte);
1049
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
1050
+ }
1051
+ async function importWorkspaceKey(keyValue, usages) {
1052
+ const bytes = decodeBase64Url2(keyValue);
1053
+ if (bytes.byteLength !== 32) throw new Error("The workspace encryption key is not AES-256");
1054
+ return crypto.subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, usages);
1055
+ }
1056
+ async function decryptRemoteScene(ciphertext, keyValue) {
1057
+ const combined = decodeBase64Url2(ciphertext);
1058
+ if (combined.byteLength < 28) throw new Error("The encrypted workspace payload is invalid");
1059
+ const key = await importWorkspaceKey(keyValue, ["decrypt"]);
1060
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: combined.slice(0, 12) }, key, combined.slice(12));
1061
+ return JSON.parse(new TextDecoder().decode(plaintext));
1062
+ }
1063
+ async function encryptRemoteScene(scene, keyValue) {
1064
+ const key = await importWorkspaceKey(keyValue, ["encrypt"]);
1065
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1066
+ const plaintext = new TextEncoder().encode(JSON.stringify(scene));
1067
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext));
1068
+ const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);
1069
+ combined.set(iv);
1070
+ combined.set(ciphertext, iv.byteLength);
1071
+ return encodeBase64Url(combined);
1072
+ }
1073
+ var RemoteSceneStore = class {
1074
+ constructor({ baseUrl = "https://sketch.elixpo.com", workspaceId, token, encryptionKey, fetchImpl = globalThis.fetch } = {}) {
1075
+ if (!workspaceId) throw new Error("RemoteSceneStore requires workspaceId");
1076
+ if (!token) throw new Error("RemoteSceneStore requires an agent grant token");
1077
+ if (!encryptionKey) throw new Error("RemoteSceneStore requires the workspace encryption key");
1078
+ if (typeof fetchImpl !== "function") throw new Error("RemoteSceneStore requires fetch");
1079
+ this.url = new URL(`/api/mcp/workspaces/${encodeURIComponent(workspaceId)}`, String(baseUrl).replace(/\/$/, ""));
1080
+ this.workspaceId = workspaceId;
1081
+ this.token = token;
1082
+ this.encryptionKey = encryptionKey;
1083
+ this.fetch = fetchImpl;
1084
+ this.remoteRevision = null;
1085
+ }
1086
+ async read() {
1087
+ const response = await this.fetch(this.url, { headers: this.headers(), cache: "no-store" });
1088
+ const body = await readJson(response);
1089
+ if (!response.ok) throw remoteError(response, body);
1090
+ const scene = await decryptRemoteScene(body.encryptedData, this.encryptionKey);
1091
+ const validation = validateScene(scene);
1092
+ if (!validation.valid) throw new Error(`Remote workspace is invalid: ${validation.errors.join("; ")}`);
1093
+ this.remoteRevision = Number(body.revision || 0);
1094
+ scene.mcpRevision = this.remoteRevision;
1095
+ return scene;
1096
+ }
1097
+ async write(scene) {
1098
+ const validation = validateScene(scene);
1099
+ if (!validation.valid) throw new Error(`Refusing to store invalid remote scene: ${validation.errors.join("; ")}`);
1100
+ if (!Number.isInteger(this.remoteRevision)) throw new Error("Read the remote workspace before writing it");
1101
+ const encryptedData = await encryptRemoteScene(scene, this.encryptionKey);
1102
+ const response = await this.fetch(this.url, {
1103
+ method: "PUT",
1104
+ headers: { ...this.headers(), "Content-Type": "application/json" },
1105
+ body: JSON.stringify({ encryptedData, expectedRevision: this.remoteRevision, workspaceName: scene.name })
1106
+ });
1107
+ const body = await readJson(response);
1108
+ if (!response.ok) throw remoteError(response, body);
1109
+ this.remoteRevision = Number(body.revision);
1110
+ return structuredClone({ ...scene, mcpRevision: this.remoteRevision });
1111
+ }
1112
+ headers() {
1113
+ return { Accept: "application/json", Authorization: `Bearer ${this.token}` };
1114
+ }
1115
+ };
1116
+ async function readJson(response) {
1117
+ return response.json().catch(() => ({}));
1118
+ }
1119
+ function remoteError(response, body) {
1120
+ const error = new Error(body.error === "REVISION_CONFLICT" ? `Revision conflict: expected ${body.expectedRevision}, current ${body.currentRevision}` : body.error || `Remote workspace request failed (${response.status})`);
1121
+ error.status = response.status;
1122
+ error.details = body;
1123
+ return error;
1124
+ }
1125
+
671
1126
  // src/mcp/stdio.js
672
1127
  function parseArguments(argv) {
673
- const options2 = { scene: process.env.LIXSKETCH_SCENE_FILE || "./lixsketch-mcp.lixjson", marketplaceUrl: process.env.LIXSKETCH_MARKETPLACE_URL || "https://sketch.elixpo.com" };
1128
+ const options3 = { scene: process.env.LIXSKETCH_SCENE_FILE || "./lixsketch-mcp.lixjson", remote: process.env.LIXSKETCH_REMOTE_URL || "", workspace: process.env.LIXSKETCH_WORKSPACE_ID || "", marketplaceUrl: process.env.LIXSKETCH_MARKETPLACE_URL || "https://sketch.elixpo.com" };
674
1129
  for (let index = 0; index < argv.length; index += 1) {
675
1130
  const argument = argv[index];
676
- if (argument === "--scene") options2.scene = argv[++index];
677
- else if (argument === "--marketplace-url") options2.marketplaceUrl = argv[++index];
678
- else if (argument === "--help" || argument === "-h") options2.help = true;
1131
+ if (argument === "--scene") options3.scene = argv[++index];
1132
+ else if (argument === "--remote") options3.remote = argv[++index];
1133
+ else if (argument === "--workspace") options3.workspace = argv[++index];
1134
+ else if (argument === "--marketplace-url") options3.marketplaceUrl = argv[++index];
1135
+ else if (argument === "--help" || argument === "-h") options3.help = true;
679
1136
  else throw new Error(`Unknown argument: ${argument}`);
680
1137
  }
681
- if (!options2.scene) throw new Error("--scene requires a file path");
682
- return options2;
1138
+ if (!options3.scene) throw new Error("--scene requires a file path");
1139
+ if (options3.remote && !options3.workspace) throw new Error("--remote requires --workspace or LIXSKETCH_WORKSPACE_ID");
1140
+ return options3;
683
1141
  }
684
1142
  function printHelp() {
685
1143
  process.stderr.write(`LixSketch MCP server
686
1144
 
687
1145
  Usage:
688
1146
  lixsketch-mcp --scene ./diagram.lixjson
1147
+ lixsketch-mcp --remote https://sketch.elixpo.com --workspace lx-...
689
1148
 
690
1149
  Options:
691
- --scene <path> Atomic .lixjson scene file (default: ./lixsketch-mcp.lixjson)
1150
+ --scene <path> Atomic local .lixjson scene file
1151
+ --remote <origin> Remote LixSketch deployment origin
1152
+ --workspace <id> Remote workspace session ID
692
1153
  --marketplace-url <url> Template marketplace origin
693
1154
  -h, --help Show this help
694
1155
 
695
- Environment:
1156
+ Remote environment (never pass secrets as arguments):
1157
+ LIXSKETCH_AGENT_TOKEN
1158
+ LIXSKETCH_ENCRYPTION_KEY
1159
+
1160
+ Other environment:
696
1161
  LIXSKETCH_SCENE_FILE
1162
+ LIXSKETCH_REMOTE_URL
1163
+ LIXSKETCH_WORKSPACE_ID
697
1164
  LIXSKETCH_MARKETPLACE_URL
698
1165
  `);
699
1166
  }
700
1167
  async function main() {
701
- const options2 = parseArguments(process.argv.slice(2));
702
- if (options2.help) {
1168
+ const options3 = parseArguments(process.argv.slice(2));
1169
+ if (options3.help) {
703
1170
  printHelp();
704
1171
  return;
705
1172
  }
706
- const store = new FileSceneStore(options2.scene);
707
- const server = createLixSketchMcpServer({ store, templateProvider: new MarketplaceTemplateProvider({ baseUrl: options2.marketplaceUrl }) });
1173
+ const store = options3.remote ? new RemoteSceneStore({ baseUrl: options3.remote, workspaceId: options3.workspace, token: process.env.LIXSKETCH_AGENT_TOKEN, encryptionKey: process.env.LIXSKETCH_ENCRYPTION_KEY }) : new FileSceneStore(options3.scene);
1174
+ const server = createLixSketchMcpServer({ store, templateProvider: new MarketplaceTemplateProvider({ baseUrl: options3.marketplaceUrl }) });
708
1175
  const transport = serveLixSketchStdio(server);
709
1176
  const close = () => {
710
1177
  void transport.close().finally(() => process.exit(0));
711
1178
  };
712
1179
  process.once("SIGINT", close);
713
1180
  process.once("SIGTERM", close);
714
- process.stderr.write(`LixSketch MCP ready: ${store.filePath}
1181
+ process.stderr.write(`LixSketch MCP ready: ${store.filePath || `${options3.remote}/c/${options3.workspace}`}
715
1182
  `);
716
1183
  await transport.closed;
717
1184
  }