@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/README.md +25 -0
- package/dist/mcp/index.js +466 -7
- package/dist/mcp/index.js.map +4 -4
- package/dist/mcp/node.js.map +2 -2
- package/dist/mcp/stdio.js +487 -20
- package/dist/mcp/stdio.js.map +4 -4
- package/package.json +1 -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 +19 -1
- package/src/mcp/stdio.js +10 -4
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
|
|
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 -
|
|
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
|
|
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:
|
|
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.
|
|
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(
|
|
548
|
-
return new LixSketchMcpServer(
|
|
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
|
|
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")
|
|
677
|
-
else if (argument === "--
|
|
678
|
-
else if (argument === "--
|
|
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 (!
|
|
682
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
702
|
-
if (
|
|
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(
|
|
707
|
-
const server = createLixSketchMcpServer({ store, templateProvider: new MarketplaceTemplateProvider({ baseUrl:
|
|
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
|
}
|