@elixpo/lixsketch 5.6.1 → 5.6.2
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 +85 -6
- package/dist/mcp/index.js +585 -0
- package/dist/mcp/index.js.map +7 -0
- package/dist/mcp/node.js +184 -0
- package/dist/mcp/node.js.map +7 -0
- package/dist/mcp/stdio.js +723 -0
- package/dist/mcp/stdio.js.map +7 -0
- package/package.json +7 -1
- package/src/mcp/fileStore.js +70 -0
- package/src/mcp/index.js +5 -0
- package/src/mcp/node.js +2 -0
- package/src/mcp/preview.js +39 -0
- package/src/mcp/scene.js +275 -0
- package/src/mcp/server.js +195 -0
- package/src/mcp/stdio.js +41 -0
- package/src/mcp/stdioTransport.js +46 -0
- package/src/mcp/store.js +23 -0
- package/src/mcp/templates.js +51 -0
package/src/mcp/scene.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
const FORMAT = 'lixsketch';
|
|
2
|
+
const VERSION = 1;
|
|
3
|
+
const MAX_SHAPES = 5000;
|
|
4
|
+
const MAX_OPERATIONS = 500;
|
|
5
|
+
const SCENE_TYPES = new Set(['rectangle', 'circle', 'line', 'arrow', 'freehandStroke', 'frame', 'text', 'code', 'image', 'icon']);
|
|
6
|
+
const WRITABLE_TYPES = new Set(['rectangle', 'circle', 'line', 'arrow', 'freehandStroke', 'frame', 'text']);
|
|
7
|
+
|
|
8
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
9
|
+
const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
10
|
+
const positive = (value, fallback = 1) => Math.max(1, finite(value, fallback));
|
|
11
|
+
|
|
12
|
+
export function createEmptyScene(name = 'MCP Canvas') {
|
|
13
|
+
return {
|
|
14
|
+
format: FORMAT,
|
|
15
|
+
version: VERSION,
|
|
16
|
+
sessionID: `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
17
|
+
name: String(name || 'MCP Canvas').trim().slice(0, 72),
|
|
18
|
+
createdAt: new Date().toISOString(),
|
|
19
|
+
viewport: { x: 0, y: 0, width: 1280, height: 720 },
|
|
20
|
+
zoom: 1,
|
|
21
|
+
mcpRevision: 0,
|
|
22
|
+
shapes: [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateScene(scene) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
if (!scene || typeof scene !== 'object') return { valid: false, errors: ['Scene must be an object'] };
|
|
29
|
+
if (scene.format !== FORMAT) errors.push(`Scene format must be "${FORMAT}"`);
|
|
30
|
+
if (scene.version !== VERSION) errors.push(`Scene version must be ${VERSION}`);
|
|
31
|
+
if (!Array.isArray(scene.shapes)) errors.push('Scene shapes must be an array');
|
|
32
|
+
if (Array.isArray(scene.shapes) && scene.shapes.length > MAX_SHAPES) errors.push(`Scene exceeds ${MAX_SHAPES} shapes`);
|
|
33
|
+
const ids = new Set();
|
|
34
|
+
for (const [index, shape] of (scene.shapes || []).entries()) {
|
|
35
|
+
if (!shape || typeof shape !== 'object') { errors.push(`Shape ${index} must be an object`); continue; }
|
|
36
|
+
if (!SCENE_TYPES.has(shape.type)) errors.push(`Shape ${index} has unsupported type "${shape.type}"`);
|
|
37
|
+
if (!shape.shapeID || typeof shape.shapeID !== 'string') errors.push(`Shape ${index} is missing shapeID`);
|
|
38
|
+
else if (ids.has(shape.shapeID)) errors.push(`Duplicate shapeID "${shape.shapeID}"`);
|
|
39
|
+
else ids.add(shape.shapeID);
|
|
40
|
+
validateShapeGeometry(shape, index, errors);
|
|
41
|
+
}
|
|
42
|
+
return { valid: errors.length === 0, errors };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function escapeXml(value) {
|
|
46
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeOptions(value = {}) {
|
|
50
|
+
return {
|
|
51
|
+
roughness: Math.max(0, Math.min(3, finite(value.roughness, 1.2))),
|
|
52
|
+
stroke: typeof value.stroke === 'string' ? value.stroke : '#8b76d6',
|
|
53
|
+
strokeWidth: Math.max(0.5, Math.min(20, finite(value.strokeWidth, 2))),
|
|
54
|
+
fill: typeof value.fill === 'string' ? value.fill : 'transparent',
|
|
55
|
+
fillStyle: typeof value.fillStyle === 'string' ? value.fillStyle : 'solid',
|
|
56
|
+
opacity: Math.max(0, Math.min(1, finite(value.opacity, 1))),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createTextShape(input, shapeID) {
|
|
61
|
+
const x = finite(input.x), y = finite(input.y), rotation = finite(input.rotation);
|
|
62
|
+
const text = String(input.text || '').slice(0, 10000);
|
|
63
|
+
const fontSize = Math.max(8, Math.min(160, finite(input.fontSize, 20)));
|
|
64
|
+
const color = typeof input.color === 'string' ? input.color : '#e8e3f3';
|
|
65
|
+
const family = typeof input.fontFamily === 'string' ? input.fontFamily.slice(0, 80) : 'lixFont';
|
|
66
|
+
const transform = `translate(${x}, ${y})${rotation ? ` rotate(${rotation}, 0, 0)` : ''}`;
|
|
67
|
+
const lines = text.split('\n').map((line, index) => `<tspan x="0" dy="${index === 0 ? 0 : '1.2em'}">${escapeXml(line || ' ')}</tspan>`).join('');
|
|
68
|
+
return {
|
|
69
|
+
shapeID, type: 'text', x, y, rotation,
|
|
70
|
+
mcpText: text, mcpFontSize: fontSize, mcpColor: color, mcpFontFamily: family,
|
|
71
|
+
groupHTML: `<g id="${escapeXml(shapeID)}" data-type="text-group" data-x="${x}" data-y="${y}" transform="${transform}"><text id="${escapeXml(shapeID)}-text" x="0" y="0" fill="${escapeXml(color)}" font-size="${fontSize}" font-family="${escapeXml(family)}" dominant-baseline="hanging" white-space="pre" pointer-events="painted" data-type="text" data-initial-size="${fontSize}" data-initial-font="${escapeXml(family)}" data-initial-color="${escapeXml(color)}">${lines}</text></g>`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeShape(input, existingIds = new Set()) {
|
|
76
|
+
if (!input || typeof input !== 'object') throw new Error('Shape must be an object');
|
|
77
|
+
if (!WRITABLE_TYPES.has(input.type)) throw new Error(`Shape type "${input.type}" is read-only through MCP`);
|
|
78
|
+
let shapeID = String(input.shapeID || `${input.type}-${crypto.randomUUID()}`).slice(0, 120);
|
|
79
|
+
while (existingIds.has(shapeID)) shapeID = `${input.type}-${crypto.randomUUID()}`;
|
|
80
|
+
const base = { shapeID, type: input.type, rotation: finite(input.rotation), options: normalizeOptions(input.options), groupId: input.groupId || null, parentFrame: input.parentFrame || null, docBlockIds: [] };
|
|
81
|
+
switch (input.type) {
|
|
82
|
+
case 'rectangle': return { ...base, x: finite(input.x), y: finite(input.y), width: positive(input.width, 160), height: positive(input.height, 90) };
|
|
83
|
+
case 'circle': return { ...base, x: finite(input.x), y: finite(input.y), rx: positive(input.rx, 60), ry: positive(input.ry, 60) };
|
|
84
|
+
case 'line': return { ...base, startPoint: point(input.startPoint), endPoint: point(input.endPoint, 120), isCurved: Boolean(input.isCurved), controlPoint: input.controlPoint ? point(input.controlPoint) : null };
|
|
85
|
+
case 'arrow': return { ...base, startPoint: point(input.startPoint), endPoint: point(input.endPoint, 120), arrowHeadStyle: input.arrowHeadStyle || 'triangle', arrowOutlineStyle: input.arrowOutlineStyle || 'solid', arrowCurved: Boolean(input.arrowCurved), arrowCurveAmount: finite(input.arrowCurveAmount, 0.2) };
|
|
86
|
+
case 'freehandStroke': {
|
|
87
|
+
const points = (Array.isArray(input.points) ? input.points : []).slice(0, 4096).map((entry) => [finite(entry?.[0]), finite(entry?.[1]), finite(entry?.[2], 0.5)]);
|
|
88
|
+
if (points.length < 2) throw new Error('freehandStroke requires at least two points');
|
|
89
|
+
return { ...base, points };
|
|
90
|
+
}
|
|
91
|
+
case 'frame': return { ...base, x: finite(input.x), y: finite(input.y), width: positive(input.width, 640), height: positive(input.height, 360), frameName: String(input.frameName || 'Frame').slice(0, 80), fillStyle: input.fillStyle || 'transparent', fillColor: input.fillColor || '#1e1e28', gridSize: positive(input.gridSize, 20), containedShapeIDs: [] };
|
|
92
|
+
case 'text': return { ...base, ...createTextShape(input, shapeID) };
|
|
93
|
+
default: throw new Error(`Unsupported shape type "${input.type}"`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function point(value, fallbackX = 0) {
|
|
98
|
+
return { x: finite(value?.x, fallbackX), y: finite(value?.y) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function translateShape(shape, dx, dy) {
|
|
102
|
+
const moved = clone(shape);
|
|
103
|
+
if (moved.startPoint) { moved.startPoint.x += dx; moved.startPoint.y += dy; }
|
|
104
|
+
if (moved.endPoint) { moved.endPoint.x += dx; moved.endPoint.y += dy; }
|
|
105
|
+
if (moved.controlPoint) { moved.controlPoint.x += dx; moved.controlPoint.y += dy; }
|
|
106
|
+
if (moved.controlPoint1) { moved.controlPoint1.x += dx; moved.controlPoint1.y += dy; }
|
|
107
|
+
if (moved.controlPoint2) { moved.controlPoint2.x += dx; moved.controlPoint2.y += dy; }
|
|
108
|
+
if (Array.isArray(moved.points)) moved.points = moved.points.map((p) => [p[0] + dx, p[1] + dy, ...p.slice(2)]);
|
|
109
|
+
if (Number.isFinite(moved.x)) moved.x += dx;
|
|
110
|
+
if (Number.isFinite(moved.y)) moved.y += dy;
|
|
111
|
+
if (moved.type === 'text' && moved.groupHTML) {
|
|
112
|
+
moved.groupHTML = moved.groupHTML
|
|
113
|
+
.replace(/data-x="[^"]*"/, `data-x="${moved.x}"`)
|
|
114
|
+
.replace(/data-y="[^"]*"/, `data-y="${moved.y}"`)
|
|
115
|
+
.replace(/transform="translate\([^)]*\)/, `transform="translate(${moved.x}, ${moved.y})`);
|
|
116
|
+
}
|
|
117
|
+
if (moved.type === 'code' && moved.groupHTML) {
|
|
118
|
+
moved.groupHTML = moved.groupHTML
|
|
119
|
+
.replace(/data-x="[^"]*"/, `data-x="${moved.x}"`)
|
|
120
|
+
.replace(/data-y="[^"]*"/, `data-y="${moved.y}"`)
|
|
121
|
+
.replace(/transform="translate\([^)]*\)/, `transform="translate(${moved.x}, ${moved.y})`);
|
|
122
|
+
}
|
|
123
|
+
if (moved.type === 'icon' && moved.elementHTML) {
|
|
124
|
+
moved.elementHTML = moved.elementHTML
|
|
125
|
+
.replace(/\bx="[^"]*"/, `x="${moved.x}"`)
|
|
126
|
+
.replace(/\by="[^"]*"/, `y="${moved.y}"`);
|
|
127
|
+
}
|
|
128
|
+
return moved;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function applyScenePatch(sceneInput, operations, { expectedRevision, dryRun = false } = {}) {
|
|
132
|
+
const scene = clone(sceneInput);
|
|
133
|
+
const check = validateScene(scene);
|
|
134
|
+
if (!check.valid) throw new Error(`Invalid scene: ${check.errors.join('; ')}`);
|
|
135
|
+
if (!Array.isArray(operations) || operations.length === 0) throw new Error('At least one operation is required');
|
|
136
|
+
if (operations.length > MAX_OPERATIONS) throw new Error(`Patch exceeds ${MAX_OPERATIONS} operations`);
|
|
137
|
+
const revision = Number(scene.mcpRevision || 0);
|
|
138
|
+
if (expectedRevision !== undefined && Number(expectedRevision) !== revision) throw new Error(`Revision conflict: expected ${expectedRevision}, current ${revision}`);
|
|
139
|
+
const changedIds = new Set();
|
|
140
|
+
for (const operation of operations) {
|
|
141
|
+
if (!operation || typeof operation !== 'object') throw new Error('Each operation must be an object');
|
|
142
|
+
if (operation.op === 'add') {
|
|
143
|
+
if (scene.shapes.length >= MAX_SHAPES) throw new Error(`Scene exceeds ${MAX_SHAPES} shapes`);
|
|
144
|
+
const ids = new Set(scene.shapes.map((shape) => shape.shapeID));
|
|
145
|
+
const shape = normalizeShape(operation.shape, ids);
|
|
146
|
+
scene.shapes.push(shape); changedIds.add(shape.shapeID);
|
|
147
|
+
} else if (operation.op === 'update') {
|
|
148
|
+
const index = scene.shapes.findIndex((shape) => shape.shapeID === operation.shapeID);
|
|
149
|
+
if (index < 0) throw new Error(`Shape "${operation.shapeID}" was not found`);
|
|
150
|
+
const immutable = scene.shapes[index];
|
|
151
|
+
scene.shapes[index] = applyShapeChanges(immutable, operation.changes || {}); changedIds.add(immutable.shapeID);
|
|
152
|
+
} else if (operation.op === 'delete') {
|
|
153
|
+
const ids = new Set(Array.isArray(operation.shapeIDs) ? operation.shapeIDs : [operation.shapeID]);
|
|
154
|
+
const before = scene.shapes.length;
|
|
155
|
+
scene.shapes = scene.shapes.filter((shape) => !ids.has(shape.shapeID));
|
|
156
|
+
if (scene.shapes.length === before) throw new Error('No requested shapes were found');
|
|
157
|
+
scene.shapes.forEach((shape) => {
|
|
158
|
+
if (ids.has(shape.parentFrame)) shape.parentFrame = null;
|
|
159
|
+
if (Array.isArray(shape.containedShapeIDs)) shape.containedShapeIDs = shape.containedShapeIDs.filter((id) => !ids.has(id));
|
|
160
|
+
});
|
|
161
|
+
ids.forEach((id) => changedIds.add(id));
|
|
162
|
+
} else if (operation.op === 'translate') {
|
|
163
|
+
const ids = new Set(operation.shapeIDs || []), dx = finite(operation.dx), dy = finite(operation.dy);
|
|
164
|
+
if (!ids.size) throw new Error('translate requires shapeIDs');
|
|
165
|
+
scene.shapes = scene.shapes.map((shape) => ids.has(shape.shapeID) ? translateShape(shape, dx, dy) : shape);
|
|
166
|
+
ids.forEach((id) => changedIds.add(id));
|
|
167
|
+
} else if (operation.op === 'rename_canvas') {
|
|
168
|
+
scene.name = String(operation.name || '').trim().slice(0, 72) || scene.name;
|
|
169
|
+
} else throw new Error(`Unsupported operation "${operation.op}"`);
|
|
170
|
+
}
|
|
171
|
+
scene.mcpRevision = revision + 1;
|
|
172
|
+
scene.updatedAt = new Date().toISOString();
|
|
173
|
+
const result = validateScene(scene);
|
|
174
|
+
if (!result.valid) throw new Error(`Patch produced an invalid scene: ${result.errors.join('; ')}`);
|
|
175
|
+
return { scene, revision: scene.mcpRevision, dryRun: Boolean(dryRun), changedShapeIDs: [...changedIds] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function applyShapeChanges(shape, changes) {
|
|
179
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw new Error('Shape changes must be an object');
|
|
180
|
+
const allowed = {
|
|
181
|
+
rectangle: ['x', 'y', 'width', 'height', 'rotation', 'options', 'groupId', 'parentFrame'],
|
|
182
|
+
circle: ['x', 'y', 'rx', 'ry', 'rotation', 'options', 'groupId', 'parentFrame'],
|
|
183
|
+
line: ['startPoint', 'endPoint', 'controlPoint', 'isCurved', 'options', 'groupId', 'parentFrame'],
|
|
184
|
+
arrow: ['startPoint', 'endPoint', 'controlPoint1', 'controlPoint2', 'arrowHeadStyle', 'arrowOutlineStyle', 'arrowCurved', 'arrowCurveAmount', 'options', 'groupId', 'parentFrame'],
|
|
185
|
+
freehandStroke: ['points', 'rotation', 'options', 'groupId', 'parentFrame'],
|
|
186
|
+
frame: ['x', 'y', 'width', 'height', 'rotation', 'frameName', 'fillStyle', 'fillColor', 'gridSize', 'options', 'groupId', 'parentFrame'],
|
|
187
|
+
text: ['x', 'y', 'rotation', 'text', 'fontSize', 'color', 'fontFamily', 'groupId', 'parentFrame'],
|
|
188
|
+
}[shape.type] || [];
|
|
189
|
+
const rejected = Object.keys(changes).filter((key) => !allowed.includes(key));
|
|
190
|
+
if (rejected.length) throw new Error(`Cannot update ${shape.type} fields: ${rejected.join(', ')}`);
|
|
191
|
+
if (shape.type === 'text') {
|
|
192
|
+
const text = changes.text ?? shape.mcpText ?? extractText(shape.groupHTML);
|
|
193
|
+
return { ...shape, ...createTextShape({ x: changes.x ?? shape.x, y: changes.y ?? shape.y, rotation: changes.rotation ?? shape.rotation, text, fontSize: changes.fontSize ?? shape.mcpFontSize, color: changes.color ?? shape.mcpColor, fontFamily: changes.fontFamily ?? shape.mcpFontFamily }, shape.shapeID), groupId: changes.groupId ?? shape.groupId, parentFrame: changes.parentFrame ?? shape.parentFrame };
|
|
194
|
+
}
|
|
195
|
+
const copy = { ...clone(shape), ...clone(changes), shapeID: shape.shapeID, type: shape.type };
|
|
196
|
+
if (changes.options) copy.options = { ...(shape.options || {}), ...normalizeOptions({ ...(shape.options || {}), ...changes.options }) };
|
|
197
|
+
return copy;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractText(groupHTML = '') {
|
|
201
|
+
return String(groupHTML).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function validateShapeGeometry(shape, index, errors) {
|
|
205
|
+
const numbers = [];
|
|
206
|
+
if (['rectangle', 'frame', 'text', 'code', 'image', 'icon'].includes(shape.type)) numbers.push(['x', shape.x], ['y', shape.y]);
|
|
207
|
+
if (['rectangle', 'frame', 'image', 'icon'].includes(shape.type)) numbers.push(['width', shape.width], ['height', shape.height]);
|
|
208
|
+
if (shape.type === 'circle') numbers.push(['x', shape.x], ['y', shape.y], ['rx', shape.rx], ['ry', shape.ry]);
|
|
209
|
+
if (shape.startPoint) numbers.push(['startPoint.x', shape.startPoint.x], ['startPoint.y', shape.startPoint.y]);
|
|
210
|
+
if (shape.endPoint) numbers.push(['endPoint.x', shape.endPoint.x], ['endPoint.y', shape.endPoint.y]);
|
|
211
|
+
for (const [field, value] of numbers) if (!Number.isFinite(Number(value))) errors.push(`Shape ${index} has invalid ${field}`);
|
|
212
|
+
if (['rectangle', 'frame', 'image', 'icon'].includes(shape.type) && (Number(shape.width) <= 0 || Number(shape.height) <= 0)) errors.push(`Shape ${index} must have positive dimensions`);
|
|
213
|
+
if (shape.type === 'circle' && (Number(shape.rx) <= 0 || Number(shape.ry) <= 0)) errors.push(`Shape ${index} must have positive radii`);
|
|
214
|
+
if (shape.type === 'freehandStroke' && (!Array.isArray(shape.points) || shape.points.length < 2 || shape.points.length > 4096)) errors.push(`Shape ${index} has invalid freehand points`);
|
|
215
|
+
if (shape.type === 'text' && typeof shape.groupHTML !== 'string') errors.push(`Shape ${index} is missing text markup`);
|
|
216
|
+
if (shape.type === 'code' && typeof shape.groupHTML !== 'string') errors.push(`Shape ${index} is missing code markup`);
|
|
217
|
+
if (shape.type === 'image' && typeof shape.href !== 'string') errors.push(`Shape ${index} is missing image href`);
|
|
218
|
+
if (shape.type === 'icon' && typeof shape.elementHTML !== 'string') errors.push(`Shape ${index} is missing icon markup`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function getSceneSummary(scene) {
|
|
222
|
+
const counts = {};
|
|
223
|
+
for (const shape of scene.shapes || []) counts[shape.type] = (counts[shape.type] || 0) + 1;
|
|
224
|
+
return { name: scene.name, format: scene.format, version: scene.version, revision: Number(scene.mcpRevision || 0), shapeCount: scene.shapes?.length || 0, counts, bounds: getSceneBounds(scene) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function getSceneBounds(scene) {
|
|
228
|
+
const boxes = (scene.shapes || []).map(shapeBounds).filter(Boolean);
|
|
229
|
+
if (!boxes.length) return null;
|
|
230
|
+
const minX = Math.min(...boxes.map((b) => b.x)), minY = Math.min(...boxes.map((b) => b.y));
|
|
231
|
+
const maxX = Math.max(...boxes.map((b) => b.x + b.width)), maxY = Math.max(...boxes.map((b) => b.y + b.height));
|
|
232
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function shapeBounds(shape) {
|
|
236
|
+
if (shape.type === 'circle') return { x: shape.x - shape.rx, y: shape.y - shape.ry, width: shape.rx * 2, height: shape.ry * 2 };
|
|
237
|
+
if (shape.startPoint && shape.endPoint) {
|
|
238
|
+
const x = Math.min(shape.startPoint.x, shape.endPoint.x), y = Math.min(shape.startPoint.y, shape.endPoint.y);
|
|
239
|
+
return { x, y, width: Math.abs(shape.endPoint.x - shape.startPoint.x), height: Math.abs(shape.endPoint.y - shape.startPoint.y) };
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(shape.points) && shape.points.length) {
|
|
242
|
+
const xs = shape.points.map((p) => p[0]), ys = shape.points.map((p) => p[1]);
|
|
243
|
+
return { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
|
|
244
|
+
}
|
|
245
|
+
if (shape.type === 'text') return { x: finite(shape.x), y: finite(shape.y), width: 160, height: 32 };
|
|
246
|
+
return { x: finite(shape.x), y: finite(shape.y), width: positive(shape.width), height: positive(shape.height) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function mergeTemplateScene(sceneInput, templateInput, { x, y } = {}) {
|
|
250
|
+
const scene = clone(sceneInput), template = clone(templateInput);
|
|
251
|
+
const validation = validateScene(template);
|
|
252
|
+
if (!validation.valid) throw new Error(`Template scene is invalid: ${validation.errors.join('; ')}`);
|
|
253
|
+
if (scene.shapes.length + template.shapes.length > MAX_SHAPES) throw new Error(`Imported template would exceed ${MAX_SHAPES} shapes`);
|
|
254
|
+
const bounds = getSceneBounds(template) || { x: 0, y: 0 };
|
|
255
|
+
const targetX = finite(x, scene.viewport?.x || 0), targetY = finite(y, scene.viewport?.y || 0);
|
|
256
|
+
const dx = targetX - bounds.x, dy = targetY - bounds.y;
|
|
257
|
+
const idMap = new Map(template.shapes.map((shape) => [shape.shapeID, `${shape.type}-${crypto.randomUUID()}`]));
|
|
258
|
+
const imported = template.shapes.map((shape) => {
|
|
259
|
+
const moved = translateShape(shape, dx, dy);
|
|
260
|
+
moved.shapeID = idMap.get(shape.shapeID);
|
|
261
|
+
if (moved.parentFrame) moved.parentFrame = idMap.get(moved.parentFrame) || null;
|
|
262
|
+
if (Array.isArray(moved.containedShapeIDs)) moved.containedShapeIDs = moved.containedShapeIDs.map((id) => idMap.get(id)).filter(Boolean);
|
|
263
|
+
if (moved.startAttachmentID) moved.startAttachmentID = idMap.get(moved.startAttachmentID) || null;
|
|
264
|
+
if (moved.endAttachmentID) moved.endAttachmentID = idMap.get(moved.endAttachmentID) || null;
|
|
265
|
+
if (moved.groupHTML) moved.groupHTML = moved.groupHTML.split(shape.shapeID).join(moved.shapeID);
|
|
266
|
+
if (moved.elementHTML) moved.elementHTML = moved.elementHTML.split(shape.shapeID).join(moved.shapeID);
|
|
267
|
+
return moved;
|
|
268
|
+
});
|
|
269
|
+
scene.shapes.push(...imported);
|
|
270
|
+
scene.mcpRevision = Number(scene.mcpRevision || 0) + 1;
|
|
271
|
+
scene.updatedAt = new Date().toISOString();
|
|
272
|
+
return { scene, revision: scene.mcpRevision, importedShapeIDs: imported.map((shape) => shape.shapeID) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export const MCP_LIMITS = Object.freeze({ maxShapes: MAX_SHAPES, maxOperations: MAX_OPERATIONS });
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { applyScenePatch, createEmptyScene, getSceneSummary, mergeTemplateScene, validateScene, MCP_LIMITS } from './scene.js';
|
|
2
|
+
import { MarketplaceTemplateProvider } from './templates.js';
|
|
3
|
+
import { renderSceneSvg } from './preview.js';
|
|
4
|
+
|
|
5
|
+
const SERVER_NAME = 'lixsketch';
|
|
6
|
+
const SERVER_VERSION = '1.0.0';
|
|
7
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
8
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set([PROTOCOL_VERSION, '2025-06-18', '2024-11-05']);
|
|
9
|
+
|
|
10
|
+
const PATCH_OPERATION_SCHEMA = {
|
|
11
|
+
oneOf: [
|
|
12
|
+
{ type: 'object', required: ['op', 'shape'], properties: { op: { const: 'add' }, shape: { type: 'object', description: 'A rectangle, circle, line, arrow, frame, freehandStroke, or text shape.' } } },
|
|
13
|
+
{ type: 'object', required: ['op', 'shapeID', 'changes'], properties: { op: { const: 'update' }, shapeID: { type: 'string' }, changes: { type: 'object' } } },
|
|
14
|
+
{ type: 'object', required: ['op'], properties: { op: { const: 'delete' }, shapeID: { type: 'string' }, shapeIDs: { type: 'array', items: { type: 'string' } } } },
|
|
15
|
+
{ type: 'object', required: ['op', 'shapeIDs', 'dx', 'dy'], properties: { op: { const: 'translate' }, shapeIDs: { type: 'array', items: { type: 'string' } }, dx: { type: 'number' }, dy: { type: 'number' } } },
|
|
16
|
+
{ type: 'object', required: ['op', 'name'], properties: { op: { const: 'rename_canvas' }, name: { type: 'string', maxLength: 72 } } },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const LIXSKETCH_MCP_TOOLS = Object.freeze([
|
|
21
|
+
{
|
|
22
|
+
name: 'canvas_get',
|
|
23
|
+
title: 'Read LixSketch canvas',
|
|
24
|
+
description: 'Return the canvas summary and optionally its editable scene shapes. Read this before mutation to obtain the current revision.',
|
|
25
|
+
inputSchema: { type: 'object', properties: { includeShapes: { type: 'boolean', default: false }, shapeIDs: { type: 'array', maxItems: 500, items: { type: 'string' } } }, additionalProperties: false },
|
|
26
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'canvas_apply_patch',
|
|
30
|
+
title: 'Apply atomic canvas patch',
|
|
31
|
+
description: `Atomically add, update, translate, or delete shapes. Supports ${MCP_LIMITS.maxOperations} operations per call, optimistic revision checks, and dry runs.`,
|
|
32
|
+
inputSchema: { type: 'object', required: ['operations'], properties: { expectedRevision: { type: 'integer', minimum: 0 }, dryRun: { type: 'boolean', default: false }, operations: { type: 'array', minItems: 1, maxItems: MCP_LIMITS.maxOperations, items: PATCH_OPERATION_SCHEMA } }, additionalProperties: false },
|
|
33
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'canvas_validate',
|
|
37
|
+
title: 'Validate LixSketch canvas',
|
|
38
|
+
description: 'Validate the current scene format, supported shapes, unique IDs, and package limits.',
|
|
39
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
40
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'canvas_preview',
|
|
44
|
+
title: 'Render canvas preview',
|
|
45
|
+
description: 'Render a lightweight SVG preview of the current scene for visual inspection before or after edits.',
|
|
46
|
+
inputSchema: { type: 'object', properties: { background: { type: 'string', pattern: '^#[0-9a-fA-F]{3,8}$' }, padding: { type: 'number', minimum: 0, maximum: 200, default: 40 } }, additionalProperties: false },
|
|
47
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'canvas_new',
|
|
51
|
+
title: 'Create blank LixSketch canvas',
|
|
52
|
+
description: 'Replace the current scene with a blank canvas. Requires explicit confirmation.',
|
|
53
|
+
inputSchema: { type: 'object', required: ['confirm'], properties: { name: { type: 'string', maxLength: 72 }, confirm: { const: true } }, additionalProperties: false },
|
|
54
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'templates_search',
|
|
58
|
+
title: 'Search LixSketch templates',
|
|
59
|
+
description: 'Search published workspace and component templates in the LixSketch marketplace.',
|
|
60
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string', maxLength: 80 }, tag: { type: 'string', maxLength: 24 }, limit: { type: 'integer', minimum: 1, maximum: 24, default: 12 } }, additionalProperties: false },
|
|
61
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'template_insert',
|
|
65
|
+
title: 'Insert LixSketch template',
|
|
66
|
+
description: 'Insert a published template into the current canvas, remapping every shape and relationship ID. The operation is atomic and supports a dry run.',
|
|
67
|
+
inputSchema: { type: 'object', required: ['slug'], properties: { slug: { type: 'string', pattern: '^[a-z0-9-]{1,80}$' }, x: { type: 'number' }, y: { type: 'number' }, expectedRevision: { type: 'integer', minimum: 0 }, dryRun: { type: 'boolean', default: false } }, additionalProperties: false },
|
|
68
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function toolResult(value, message) {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: 'text', text: message || JSON.stringify(value, null, 2) }],
|
|
75
|
+
structuredContent: value,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toolError(error) {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return { isError: true, content: [{ type: 'text', text: message }], structuredContent: { error: message } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class LixSketchMcpServer {
|
|
85
|
+
constructor({ store, templateProvider = new MarketplaceTemplateProvider(), serverInfo = {} } = {}) {
|
|
86
|
+
if (!store?.read || !store?.write) throw new Error('createLixSketchMcpServer requires a scene store with read() and write()');
|
|
87
|
+
this.store = store;
|
|
88
|
+
this.templateProvider = templateProvider;
|
|
89
|
+
this.serverInfo = { name: SERVER_NAME, version: SERVER_VERSION, ...serverInfo };
|
|
90
|
+
this.mutationChain = Promise.resolve();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listTools() {
|
|
94
|
+
return LIXSKETCH_MCP_TOOLS;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async callTool(name, args = {}) {
|
|
98
|
+
try {
|
|
99
|
+
switch (name) {
|
|
100
|
+
case 'canvas_get': {
|
|
101
|
+
const scene = await this.store.read();
|
|
102
|
+
let shapes;
|
|
103
|
+
if (args.includeShapes) {
|
|
104
|
+
const ids = new Set(args.shapeIDs || []);
|
|
105
|
+
shapes = ids.size ? scene.shapes.filter((shape) => ids.has(shape.shapeID)) : scene.shapes;
|
|
106
|
+
}
|
|
107
|
+
return toolResult({ summary: getSceneSummary(scene), ...(shapes ? { shapes } : {}) });
|
|
108
|
+
}
|
|
109
|
+
case 'canvas_validate': {
|
|
110
|
+
const scene = await this.store.read();
|
|
111
|
+
const validation = validateScene(scene);
|
|
112
|
+
return toolResult({ ...validation, summary: getSceneSummary(scene), limits: MCP_LIMITS });
|
|
113
|
+
}
|
|
114
|
+
case 'canvas_preview': {
|
|
115
|
+
const scene = await this.store.read();
|
|
116
|
+
const svg = renderSceneSvg(scene, args);
|
|
117
|
+
return toolResult({ svg, dataUrl: `data:image/svg+xml;base64,${encodeBase64(svg)}`, summary: getSceneSummary(scene) }, svg);
|
|
118
|
+
}
|
|
119
|
+
case 'templates_search': {
|
|
120
|
+
const templates = await this.templateProvider.search(args);
|
|
121
|
+
return toolResult({ templates: templates.map(safeTemplateMetadata) });
|
|
122
|
+
}
|
|
123
|
+
case 'canvas_apply_patch':
|
|
124
|
+
return await this.enqueueMutation(async () => {
|
|
125
|
+
const scene = await this.store.read();
|
|
126
|
+
const result = applyScenePatch(scene, args.operations, args);
|
|
127
|
+
if (!args.dryRun) await this.store.write(result.scene);
|
|
128
|
+
return toolResult({ revision: result.revision, dryRun: result.dryRun, changedShapeIDs: result.changedShapeIDs, summary: getSceneSummary(result.scene) }, args.dryRun ? 'Canvas patch is valid. No changes were saved.' : `Canvas patch saved at revision ${result.revision}.`);
|
|
129
|
+
});
|
|
130
|
+
case 'canvas_new':
|
|
131
|
+
if (args.confirm !== true) throw new Error('canvas_new requires confirm=true');
|
|
132
|
+
return await this.enqueueMutation(async () => {
|
|
133
|
+
const scene = createEmptyScene(args.name);
|
|
134
|
+
await this.store.write(scene);
|
|
135
|
+
return toolResult({ summary: getSceneSummary(scene) }, 'Blank canvas created.');
|
|
136
|
+
});
|
|
137
|
+
case 'template_insert':
|
|
138
|
+
return await this.enqueueMutation(async () => {
|
|
139
|
+
const scene = await this.store.read();
|
|
140
|
+
const revision = Number(scene.mcpRevision || 0);
|
|
141
|
+
if (args.expectedRevision !== undefined && Number(args.expectedRevision) !== revision) throw new Error(`Revision conflict: expected ${args.expectedRevision}, current ${revision}`);
|
|
142
|
+
const template = await this.templateProvider.load(args.slug);
|
|
143
|
+
const result = mergeTemplateScene(scene, template.scene, args);
|
|
144
|
+
if (!args.dryRun) await this.store.write(result.scene);
|
|
145
|
+
return toolResult({ template: safeTemplateMetadata(template.metadata), revision: result.revision, dryRun: Boolean(args.dryRun), importedShapeIDs: result.importedShapeIDs, summary: getSceneSummary(result.scene) }, args.dryRun ? 'Template import is valid. No changes were saved.' : `Template inserted with ${result.importedShapeIDs.length} shapes.`);
|
|
146
|
+
});
|
|
147
|
+
default: throw new Error(`Unknown tool "${name}"`);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return toolError(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
enqueueMutation(operation) {
|
|
155
|
+
const pending = this.mutationChain.then(operation, operation);
|
|
156
|
+
this.mutationChain = pending.catch(() => {});
|
|
157
|
+
return pending;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async handleRequest(request) {
|
|
161
|
+
const method = request?.method;
|
|
162
|
+
if (method === 'initialize') {
|
|
163
|
+
const requested = request.params?.protocolVersion;
|
|
164
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested) ? requested : PROTOCOL_VERSION;
|
|
165
|
+
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.' };
|
|
166
|
+
}
|
|
167
|
+
if (method === 'ping') return {};
|
|
168
|
+
if (method === 'tools/list') return { tools: this.listTools() };
|
|
169
|
+
if (method === 'tools/call') return this.callTool(request.params?.name, request.params?.arguments || {});
|
|
170
|
+
if (method === 'resources/list') return { resources: [{ uri: 'lixsketch://canvas', name: 'Current LixSketch canvas', description: 'The active editable .lixjson scene', mimeType: 'application/vnd.lixsketch+json' }, { uri: 'lixsketch://canvas/preview.svg', name: 'Current canvas preview', description: 'A lightweight SVG preview of the current scene', mimeType: 'image/svg+xml' }] };
|
|
171
|
+
if (method === 'resources/read') {
|
|
172
|
+
const scene = await this.store.read();
|
|
173
|
+
if (request.params?.uri === 'lixsketch://canvas') return { contents: [{ uri: 'lixsketch://canvas', mimeType: 'application/vnd.lixsketch+json', text: JSON.stringify(scene) }] };
|
|
174
|
+
if (request.params?.uri === 'lixsketch://canvas/preview.svg') return { contents: [{ uri: 'lixsketch://canvas/preview.svg', mimeType: 'image/svg+xml', text: renderSceneSvg(scene) }] };
|
|
175
|
+
throw Object.assign(new Error('Resource not found'), { code: -32002 });
|
|
176
|
+
}
|
|
177
|
+
if (method?.startsWith('notifications/')) return undefined;
|
|
178
|
+
throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function safeTemplateMetadata(template = {}) {
|
|
183
|
+
return { id: template.id, slug: template.slug, title: template.title, description: template.description || '', tags: template.tags || [], publisher: template.publisher, views: template.views, forks: template.forks, clones: template.clones, publishedAt: template.publishedAt, updatedAt: template.updatedAt };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function encodeBase64(value) {
|
|
187
|
+
if (typeof btoa === 'function') return btoa(unescape(encodeURIComponent(value)));
|
|
188
|
+
return Buffer.from(value, 'utf8').toString('base64');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createLixSketchMcpServer(options) {
|
|
192
|
+
return new LixSketchMcpServer(options);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export { PROTOCOL_VERSION as LIXSKETCH_MCP_PROTOCOL_VERSION };
|
package/src/mcp/stdio.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createLixSketchMcpServer } from './server.js';
|
|
4
|
+
import { FileSceneStore } from './fileStore.js';
|
|
5
|
+
import { MarketplaceTemplateProvider } from './templates.js';
|
|
6
|
+
import { serveLixSketchStdio } from './stdioTransport.js';
|
|
7
|
+
|
|
8
|
+
function parseArguments(argv) {
|
|
9
|
+
const options = { scene: process.env.LIXSKETCH_SCENE_FILE || './lixsketch-mcp.lixjson', marketplaceUrl: process.env.LIXSKETCH_MARKETPLACE_URL || 'https://sketch.elixpo.com' };
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
const argument = argv[index];
|
|
12
|
+
if (argument === '--scene') options.scene = argv[++index];
|
|
13
|
+
else if (argument === '--marketplace-url') options.marketplaceUrl = argv[++index];
|
|
14
|
+
else if (argument === '--help' || argument === '-h') options.help = true;
|
|
15
|
+
else throw new Error(`Unknown argument: ${argument}`);
|
|
16
|
+
}
|
|
17
|
+
if (!options.scene) throw new Error('--scene requires a file path');
|
|
18
|
+
return options;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printHelp() {
|
|
22
|
+
process.stderr.write(`LixSketch MCP server\n\nUsage:\n lixsketch-mcp --scene ./diagram.lixjson\n\nOptions:\n --scene <path> Atomic .lixjson scene file (default: ./lixsketch-mcp.lixjson)\n --marketplace-url <url> Template marketplace origin\n -h, --help Show this help\n\nEnvironment:\n LIXSKETCH_SCENE_FILE\n LIXSKETCH_MARKETPLACE_URL\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const options = parseArguments(process.argv.slice(2));
|
|
27
|
+
if (options.help) { printHelp(); return; }
|
|
28
|
+
const store = new FileSceneStore(options.scene);
|
|
29
|
+
const server = createLixSketchMcpServer({ store, templateProvider: new MarketplaceTemplateProvider({ baseUrl: options.marketplaceUrl }) });
|
|
30
|
+
const transport = serveLixSketchStdio(server);
|
|
31
|
+
const close = () => { void transport.close().finally(() => process.exit(0)); };
|
|
32
|
+
process.once('SIGINT', close);
|
|
33
|
+
process.once('SIGTERM', close);
|
|
34
|
+
process.stderr.write(`LixSketch MCP ready: ${store.filePath}\n`);
|
|
35
|
+
await transport.closed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().catch((error) => {
|
|
39
|
+
process.stderr.write(`LixSketch MCP failed: ${error?.message || error}\n`);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
const MAX_MESSAGE_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
export function serveLixSketchStdio(server, { input = process.stdin, output = process.stdout } = {}) {
|
|
6
|
+
if (!server?.handleRequest) throw new Error('A LixSketch MCP server is required');
|
|
7
|
+
const lines = createInterface({ input, crlfDelay: Infinity, terminal: false });
|
|
8
|
+
let processing = Promise.resolve();
|
|
9
|
+
let resolveClosed;
|
|
10
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
11
|
+
const send = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
12
|
+
|
|
13
|
+
lines.on('line', (line) => {
|
|
14
|
+
processing = processing.then(async () => {
|
|
15
|
+
if (!line.trim()) return;
|
|
16
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_MESSAGE_BYTES) {
|
|
17
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'MCP request exceeds the 10 MB limit' } });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
let request;
|
|
21
|
+
try { request = JSON.parse(line); }
|
|
22
|
+
catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); return; }
|
|
23
|
+
if (request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
|
|
24
|
+
if (request.id !== undefined) send({ jsonrpc: '2.0', id: request.id ?? null, error: { code: -32600, message: 'Invalid Request' } });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const result = await server.handleRequest(request);
|
|
29
|
+
if (request.id !== undefined && result !== undefined) send({ jsonrpc: '2.0', id: request.id, result });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (request.id !== undefined) send({ jsonrpc: '2.0', id: request.id, error: { code: Number(error?.code) || -32603, message: error?.message || 'Internal error' } });
|
|
32
|
+
}
|
|
33
|
+
}).catch((error) => {
|
|
34
|
+
process.stderr.write(`LixSketch MCP transport error: ${error?.message || error}\n`);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
lines.once('close', () => processing.finally(resolveClosed));
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
closed,
|
|
41
|
+
close: async () => {
|
|
42
|
+
lines.close();
|
|
43
|
+
await processing;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/mcp/store.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createEmptyScene, validateScene } from './scene.js';
|
|
2
|
+
|
|
3
|
+
export class MemorySceneStore {
|
|
4
|
+
#scene;
|
|
5
|
+
|
|
6
|
+
constructor(scene = createEmptyScene()) {
|
|
7
|
+
const validation = validateScene(scene);
|
|
8
|
+
if (!validation.valid) throw new Error(`Invalid initial scene: ${validation.errors.join('; ')}`);
|
|
9
|
+
this.#scene = structuredClone(scene);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async read() {
|
|
13
|
+
return structuredClone(this.#scene);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async write(scene) {
|
|
17
|
+
const validation = validateScene(scene);
|
|
18
|
+
if (!validation.valid) throw new Error(`Refusing to store invalid scene: ${validation.errors.join('; ')}`);
|
|
19
|
+
this.#scene = structuredClone(scene);
|
|
20
|
+
return this.read();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const DEFAULT_MARKETPLACE_URL = 'https://sketch.elixpo.com';
|
|
2
|
+
|
|
3
|
+
function decodeBase64Url(value) {
|
|
4
|
+
const base64 = String(value).replaceAll('-', '+').replaceAll('_', '/');
|
|
5
|
+
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
|
|
6
|
+
const binary = atob(padded);
|
|
7
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function decryptPublicTemplate(ciphertext, keyValue) {
|
|
11
|
+
const keyBytes = decodeBase64Url(keyValue);
|
|
12
|
+
if (keyBytes.byteLength !== 32) throw new Error('Template key is not AES-256');
|
|
13
|
+
const combined = decodeBase64Url(ciphertext);
|
|
14
|
+
if (combined.byteLength < 28) throw new Error('Template ciphertext is invalid');
|
|
15
|
+
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
|
|
16
|
+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: combined.slice(0, 12) }, key, combined.slice(12));
|
|
17
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class MarketplaceTemplateProvider {
|
|
21
|
+
constructor({ baseUrl = DEFAULT_MARKETPLACE_URL, fetchImpl = globalThis.fetch } = {}) {
|
|
22
|
+
if (typeof fetchImpl !== 'function') throw new Error('MarketplaceTemplateProvider requires fetch');
|
|
23
|
+
this.baseUrl = String(baseUrl).replace(/\/$/, '');
|
|
24
|
+
this.fetch = fetchImpl;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async search({ query = '', tag = '', limit = 12 } = {}) {
|
|
28
|
+
const url = new URL('/api/templates', this.baseUrl);
|
|
29
|
+
if (query) url.searchParams.set('q', String(query).slice(0, 80));
|
|
30
|
+
if (tag) url.searchParams.set('tag', String(tag).slice(0, 24));
|
|
31
|
+
url.searchParams.set('limit', String(Math.min(24, Math.max(1, Number(limit) || 12))));
|
|
32
|
+
const response = await this.fetch(url, { headers: { accept: 'application/json' } });
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
if (!response.ok) throw new Error(body.error || `Marketplace request failed (${response.status})`);
|
|
35
|
+
return body.templates || [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async load(slug) {
|
|
39
|
+
const safeSlug = String(slug || '').trim();
|
|
40
|
+
if (!/^[a-z0-9-]{1,80}$/.test(safeSlug)) throw new Error('Template slug is invalid');
|
|
41
|
+
const url = new URL(`/api/templates/${encodeURIComponent(safeSlug)}`, this.baseUrl);
|
|
42
|
+
url.searchParams.set('snapshot', '1');
|
|
43
|
+
const response = await this.fetch(url, { headers: { accept: 'application/json' } });
|
|
44
|
+
const body = await response.json();
|
|
45
|
+
if (!response.ok) throw new Error(body.error || `Template request failed (${response.status})`);
|
|
46
|
+
const template = body.template;
|
|
47
|
+
if (!template?.encryptedData || !template?.publicKey) throw new Error('Template snapshot is unavailable');
|
|
48
|
+
return { metadata: { ...template, encryptedData: undefined, publicKey: undefined, encryptedDocData: undefined }, scene: await decryptPublicTemplate(template.encryptedData, template.publicKey) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|