@imenam/mcp-design 1.0.1
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 +231 -0
- package/dist/shared/node-defaults.d.ts +30 -0
- package/dist/shared/node-defaults.js +67 -0
- package/dist/shared/node-fields.d.ts +33 -0
- package/dist/shared/node-fields.js +40 -0
- package/dist/shared/render.d.ts +105 -0
- package/dist/shared/render.js +215 -0
- package/dist/shared/schemas/design.schema.d.ts +231 -0
- package/dist/shared/schemas/design.schema.js +98 -0
- package/dist/shared/tree.d.ts +93 -0
- package/dist/shared/tree.js +223 -0
- package/dist/src/cli/setup-mcp.d.ts +5 -0
- package/dist/src/cli/setup-mcp.js +87 -0
- package/dist/src/core/design-store.d.ts +61 -0
- package/dist/src/core/design-store.js +294 -0
- package/dist/src/core/diff.d.ts +22 -0
- package/dist/src/core/diff.js +97 -0
- package/dist/src/core/export.d.ts +6 -0
- package/dist/src/core/export.js +251 -0
- package/dist/src/core/hierarchy.d.ts +18 -0
- package/dist/src/core/hierarchy.js +68 -0
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +21 -0
- package/dist/src/core/log-dir.d.ts +6 -0
- package/dist/src/core/log-dir.js +12 -0
- package/dist/src/core/migrate.d.ts +18 -0
- package/dist/src/core/migrate.js +54 -0
- package/dist/src/core/node-factory.d.ts +19 -0
- package/dist/src/core/node-factory.js +103 -0
- package/dist/src/core/screenshot.d.ts +31 -0
- package/dist/src/core/screenshot.js +53 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +334 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +673 -0
- package/dist/src/tools/add-nodes.d.ts +7 -0
- package/dist/src/tools/add-nodes.js +26 -0
- package/dist/src/tools/create-design.d.ts +7 -0
- package/dist/src/tools/create-design.js +15 -0
- package/dist/src/tools/delete-design.d.ts +3 -0
- package/dist/src/tools/delete-design.js +7 -0
- package/dist/src/tools/delete-nodes.d.ts +3 -0
- package/dist/src/tools/delete-nodes.js +29 -0
- package/dist/src/tools/export-design.d.ts +3 -0
- package/dist/src/tools/export-design.js +21 -0
- package/dist/src/tools/get-changes.d.ts +11 -0
- package/dist/src/tools/get-changes.js +27 -0
- package/dist/src/tools/get-design.d.ts +7 -0
- package/dist/src/tools/get-design.js +29 -0
- package/dist/src/tools/group-nodes.d.ts +10 -0
- package/dist/src/tools/group-nodes.js +83 -0
- package/dist/src/tools/list-designs.d.ts +3 -0
- package/dist/src/tools/list-designs.js +13 -0
- package/dist/src/tools/mark-synced.d.ts +7 -0
- package/dist/src/tools/mark-synced.js +19 -0
- package/dist/src/tools/reorder-nodes.d.ts +7 -0
- package/dist/src/tools/reorder-nodes.js +60 -0
- package/dist/src/tools/screenshot-design.d.ts +3 -0
- package/dist/src/tools/screenshot-design.js +36 -0
- package/dist/src/tools/types.d.ts +25 -0
- package/dist/src/tools/types.js +13 -0
- package/dist/src/tools/ungroup-nodes.d.ts +7 -0
- package/dist/src/tools/ungroup-nodes.js +64 -0
- package/dist/src/tools/update-design.d.ts +4 -0
- package/dist/src/tools/update-design.js +33 -0
- package/dist/src/tools/update-nodes.d.ts +3 -0
- package/dist/src/tools/update-nodes.js +35 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +2 -0
- package/package.json +58 -0
- package/scripts/generate-version.js +20 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { GuiLauncher, createLogger, setupLogging } from "@imenam/mcp-gui-interface";
|
|
10
|
+
import { APP_VERSION } from "./version.js";
|
|
11
|
+
import { resolveLogDir } from "./core/log-dir.js";
|
|
12
|
+
import { DesignStore } from "./core/design-store.js";
|
|
13
|
+
import { NODE_DEFAULTS } from "./core/node-factory.js";
|
|
14
|
+
import { EXPORT_FORMATS } from "./core/export.js";
|
|
15
|
+
import { MIN_LINE_HEIGHT_PX } from "../shared/render.js";
|
|
16
|
+
import { NODE_FIELD_DOCS } from "../shared/node-fields.js";
|
|
17
|
+
import { REORDER_DIRECTIONS } from "../shared/tree.js";
|
|
18
|
+
import { listDesigns } from "./tools/list-designs.js";
|
|
19
|
+
import { createDesign } from "./tools/create-design.js";
|
|
20
|
+
import { getDesign } from "./tools/get-design.js";
|
|
21
|
+
import { updateDesign } from "./tools/update-design.js";
|
|
22
|
+
import { deleteDesign } from "./tools/delete-design.js";
|
|
23
|
+
import { addNodes } from "./tools/add-nodes.js";
|
|
24
|
+
import { updateNodes } from "./tools/update-nodes.js";
|
|
25
|
+
import { deleteNodes } from "./tools/delete-nodes.js";
|
|
26
|
+
import { groupNodes } from "./tools/group-nodes.js";
|
|
27
|
+
import { ungroupNodes } from "./tools/ungroup-nodes.js";
|
|
28
|
+
import { getChanges } from "./tools/get-changes.js";
|
|
29
|
+
import { markSynced } from "./tools/mark-synced.js";
|
|
30
|
+
import { exportDesignTool } from "./tools/export-design.js";
|
|
31
|
+
import { screenshotDesignTool } from "./tools/screenshot-design.js";
|
|
32
|
+
import { reorderNodesTool } from "./tools/reorder-nodes.js";
|
|
33
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
34
|
+
const __dirname = path.dirname(__filename);
|
|
35
|
+
// Les flags CLI sont traités avant toute initialisation : ils s'exécutent dans le
|
|
36
|
+
// répertoire de l'utilisateur, pas dans un serveur MCP.
|
|
37
|
+
if (process.argv.slice(2).includes("--claude-setup-mcp")) {
|
|
38
|
+
const { runSetupMcp } = await import("./cli/setup-mcp.js");
|
|
39
|
+
process.exit(runSetupMcp({ cwd: process.cwd(), force: process.argv.slice(2).includes("--force") }));
|
|
40
|
+
}
|
|
41
|
+
function findProjectRoot(startDir) {
|
|
42
|
+
let current = startDir;
|
|
43
|
+
while (current !== path.dirname(current)) {
|
|
44
|
+
if (fs.existsSync(path.join(current, "package.json")))
|
|
45
|
+
return current;
|
|
46
|
+
current = path.dirname(current);
|
|
47
|
+
}
|
|
48
|
+
return startDir;
|
|
49
|
+
}
|
|
50
|
+
const rootDir = findProjectRoot(__dirname);
|
|
51
|
+
const packageEnvPath = path.join(rootDir, ".env");
|
|
52
|
+
setupLogging({ processLabel: "mcp-design", logDir: resolveLogDir(rootDir) });
|
|
53
|
+
const logger = createLogger("mcp-design");
|
|
54
|
+
// Rechargé à chaque appel, avec override : le .env local doit primer sur les
|
|
55
|
+
// variables héritées du JSON de déclaration du MCP.
|
|
56
|
+
function getConfig() {
|
|
57
|
+
if (fs.existsSync(packageEnvPath))
|
|
58
|
+
dotenv.config({ path: packageEnvPath, override: true });
|
|
59
|
+
return { proxyUrl: process.env.PROXY_URL, version: APP_VERSION };
|
|
60
|
+
}
|
|
61
|
+
getConfig();
|
|
62
|
+
const store = new DesignStore(rootDir);
|
|
63
|
+
const server = new Server({ name: "mcp-design", version: APP_VERSION }, { capabilities: { tools: {} } });
|
|
64
|
+
// --- Cycle de vie de la GUI -------------------------------------------------
|
|
65
|
+
let guiLauncher;
|
|
66
|
+
let guiStatus = "disabled";
|
|
67
|
+
let guiUrl = null;
|
|
68
|
+
let guiStartWaiter = null;
|
|
69
|
+
function broadcastToGUI(message) {
|
|
70
|
+
const child = guiLauncher?.getProcess();
|
|
71
|
+
if (child?.connected) {
|
|
72
|
+
try {
|
|
73
|
+
child.send(message);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
logger.error(`Failed to send IPC message: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function reply(msg, type, data) {
|
|
81
|
+
broadcastToGUI({ type, correlationId: msg.correlationId, data, timestamp: new Date().toISOString() });
|
|
82
|
+
}
|
|
83
|
+
function replyError(msg, type, error) {
|
|
84
|
+
broadcastToGUI({ type, correlationId: msg.correlationId, error, timestamp: new Date().toISOString() });
|
|
85
|
+
}
|
|
86
|
+
/** Notifie la GUI qu'un design a changé côté agent, pour qu'elle se rafraîchisse. */
|
|
87
|
+
function notifyDesignChanged(designId) {
|
|
88
|
+
broadcastToGUI({
|
|
89
|
+
type: "DESIGN_UPDATED",
|
|
90
|
+
data: { designId, designs: store.list() },
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function handleIPCMessage(msg) {
|
|
95
|
+
if (!msg || typeof msg.type !== "string")
|
|
96
|
+
return;
|
|
97
|
+
switch (msg.type) {
|
|
98
|
+
case "ALREADY_RUNNING":
|
|
99
|
+
guiStatus = "already_running";
|
|
100
|
+
guiUrl = msg.data?.url ?? guiUrl;
|
|
101
|
+
guiStartWaiter?.({ outcome: "already_running", url: msg.data?.url });
|
|
102
|
+
return;
|
|
103
|
+
case "READY":
|
|
104
|
+
guiStatus = "ready";
|
|
105
|
+
guiUrl = msg.data?.url ?? guiUrl;
|
|
106
|
+
logger.info(`GUI ready at ${guiUrl}`);
|
|
107
|
+
guiStartWaiter?.({ outcome: "ready", url: msg.data?.url });
|
|
108
|
+
return;
|
|
109
|
+
case "GET_INITIAL_STATE":
|
|
110
|
+
broadcastToGUI({
|
|
111
|
+
type: "INITIAL_STATE",
|
|
112
|
+
data: { designs: store.list(), version: APP_VERSION },
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
case "LIST_DESIGNS":
|
|
117
|
+
reply(msg, "LIST_DESIGNS_RESPONSE", { designs: store.list() });
|
|
118
|
+
return;
|
|
119
|
+
case "GET_DESIGN":
|
|
120
|
+
try {
|
|
121
|
+
reply(msg, "GET_DESIGN_RESPONSE", store.get(String(msg.data?.id)));
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
replyError(msg, "GET_DESIGN_RESPONSE", err.message);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
case "CREATE_DESIGN":
|
|
128
|
+
try {
|
|
129
|
+
const design = store.create({
|
|
130
|
+
name: String(msg.data?.name ?? "Nouveau design"),
|
|
131
|
+
width: msg.data?.width === undefined ? undefined : Number(msg.data.width),
|
|
132
|
+
height: msg.data?.height === undefined ? undefined : Number(msg.data.height),
|
|
133
|
+
background: msg.data?.background,
|
|
134
|
+
});
|
|
135
|
+
reply(msg, "CREATE_DESIGN_RESPONSE", { design, designs: store.list() });
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
replyError(msg, "CREATE_DESIGN_RESPONSE", err.message);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
case "SAVE_DESIGN":
|
|
142
|
+
try {
|
|
143
|
+
reply(msg, "SAVE_DESIGN_RESPONSE", handleSaveDesign(msg.data));
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
replyError(msg, "SAVE_DESIGN_RESPONSE", err.message);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
case "DELETE_DESIGN":
|
|
150
|
+
try {
|
|
151
|
+
store.delete(String(msg.data?.id));
|
|
152
|
+
reply(msg, "DELETE_DESIGN_RESPONSE", { designs: store.list() });
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
replyError(msg, "DELETE_DESIGN_RESPONSE", err.message);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
case "GET_CONFIG":
|
|
159
|
+
reply(msg, "GET_CONFIG_RESPONSE", { dataDir: store.getDataDir(), version: APP_VERSION });
|
|
160
|
+
return;
|
|
161
|
+
default:
|
|
162
|
+
logger.debug(`Unhandled IPC message type: ${msg.type}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Sauvegarde intégrale déclenchée par la GUI. La baseline n'est jamais touchée
|
|
167
|
+
* ici : c'est précisément l'écart entre elle et cet état que l'agent viendra
|
|
168
|
+
* lire via get_changes.
|
|
169
|
+
*/
|
|
170
|
+
function handleSaveDesign(data) {
|
|
171
|
+
const id = String(data?.id ?? "");
|
|
172
|
+
const nodes = data?.nodes;
|
|
173
|
+
if (!Array.isArray(nodes))
|
|
174
|
+
throw new Error("SAVE_DESIGN requires a nodes array.");
|
|
175
|
+
// La GUI remplace `nodes` en bloc : sans la révision d'origine, une sauvegarde
|
|
176
|
+
// partie d'un document périmé effacerait tout ce qu'un autre éditeur a ajouté.
|
|
177
|
+
const revision = Number(data?.revision);
|
|
178
|
+
const baseRevision = Number.isFinite(revision) ? revision : undefined;
|
|
179
|
+
const design = store.mutate(id, "user", (draft) => {
|
|
180
|
+
draft.nodes = nodes;
|
|
181
|
+
if (data?.name !== undefined)
|
|
182
|
+
draft.name = String(data.name);
|
|
183
|
+
if (data?.width !== undefined)
|
|
184
|
+
draft.width = Number(data.width);
|
|
185
|
+
if (data?.height !== undefined)
|
|
186
|
+
draft.height = Number(data.height);
|
|
187
|
+
if (data?.background !== undefined)
|
|
188
|
+
draft.background = String(data.background);
|
|
189
|
+
}, { baseRevision });
|
|
190
|
+
return { design, designs: store.list() };
|
|
191
|
+
}
|
|
192
|
+
function startGuiWorker() {
|
|
193
|
+
guiStatus = "starting";
|
|
194
|
+
const child = guiLauncher.start();
|
|
195
|
+
child.on("exit", () => {
|
|
196
|
+
// Un start() ultérieur remplace le process courant : seul l'exit du process
|
|
197
|
+
// actif change l'état. ALREADY_RUNNING = la GUI vit dans une autre instance.
|
|
198
|
+
if (guiLauncher.getProcess() === child && guiStatus !== "already_running")
|
|
199
|
+
guiStatus = "stopped";
|
|
200
|
+
});
|
|
201
|
+
return child;
|
|
202
|
+
}
|
|
203
|
+
guiLauncher = new GuiLauncher({
|
|
204
|
+
guiPath: path.join(__dirname, "gui-worker.js"),
|
|
205
|
+
env: { ...process.env },
|
|
206
|
+
maxRestarts: 0,
|
|
207
|
+
onMessage: handleIPCMessage,
|
|
208
|
+
});
|
|
209
|
+
function launchGUI() {
|
|
210
|
+
if (!process.env.PROXY_URL) {
|
|
211
|
+
logger.info("[MASTER] PROXY_URL non défini — GUI désactivée.");
|
|
212
|
+
guiStatus = "disabled";
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
logger.info("Launching GUI worker...");
|
|
216
|
+
startGuiWorker();
|
|
217
|
+
}
|
|
218
|
+
async function reconnectGui(args) {
|
|
219
|
+
getConfig(); // relit le .env au cas où PROXY_URL aurait été ajouté après le démarrage
|
|
220
|
+
if (!process.env.PROXY_URL) {
|
|
221
|
+
return {
|
|
222
|
+
content: [
|
|
223
|
+
{
|
|
224
|
+
type: "text",
|
|
225
|
+
text: "Error: PROXY_URL is not set — the GUI is disabled. Add PROXY_URL to the .env file, then call reconnect_gui again.",
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
isError: true,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const child = guiLauncher.getProcess();
|
|
232
|
+
const alive = !!child && child.exitCode === null && !child.killed;
|
|
233
|
+
const force = args?.force === true;
|
|
234
|
+
if (alive && !force) {
|
|
235
|
+
if (guiStatus === "starting") {
|
|
236
|
+
return {
|
|
237
|
+
content: [
|
|
238
|
+
{ type: "text", text: "GUI worker is already starting (proxy registration in progress). Wait a few seconds, then call reconnect_gui again if needed." },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
content: [
|
|
244
|
+
{ type: "text", text: `GUI is already registered with the proxy${guiUrl ? ` at ${guiUrl}` : ""}. Nothing to do (use force=true to restart it anyway).` },
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
logger.info(`[MASTER] reconnect_gui: ${alive ? "restarting (force)" : "relaunching"} GUI worker...`);
|
|
249
|
+
const result = await new Promise((resolve) => {
|
|
250
|
+
const newChild = startGuiWorker();
|
|
251
|
+
const onExit = (code) => {
|
|
252
|
+
cleanup();
|
|
253
|
+
resolve({ outcome: "exited", code });
|
|
254
|
+
};
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
cleanup();
|
|
257
|
+
resolve({ outcome: "timeout" });
|
|
258
|
+
}, 15000);
|
|
259
|
+
const cleanup = () => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
newChild.off("exit", onExit);
|
|
262
|
+
guiStartWaiter = null;
|
|
263
|
+
};
|
|
264
|
+
guiStartWaiter = (r) => {
|
|
265
|
+
cleanup();
|
|
266
|
+
resolve(r);
|
|
267
|
+
};
|
|
268
|
+
newChild.on("exit", onExit);
|
|
269
|
+
});
|
|
270
|
+
if (result.outcome === "ready") {
|
|
271
|
+
return { content: [{ type: "text", text: `GUI reconnected and registered with the proxy. URL: ${result.url}` }] };
|
|
272
|
+
}
|
|
273
|
+
if (result.outcome === "already_running") {
|
|
274
|
+
return { content: [{ type: "text", text: `GUI is already registered by another MCP instance. URL: ${result.url ?? "unknown"}` }] };
|
|
275
|
+
}
|
|
276
|
+
if (result.outcome === "exited") {
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{ type: "text", text: `GUI worker exited before completing registration (code=${result.code}). The proxy server is probably still unreachable at ${process.env.PROXY_URL}. Start the proxy server, then call reconnect_gui again.` },
|
|
280
|
+
],
|
|
281
|
+
isError: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
content: [
|
|
286
|
+
{ type: "text", text: `GUI worker started but did not confirm proxy registration within 15s. Check the logs (${resolveLogDir(rootDir)}), then retry.` },
|
|
287
|
+
],
|
|
288
|
+
isError: true,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** URL de la GUI ajoutée aux réponses des outils, pour que l'utilisateur sache où regarder. */
|
|
292
|
+
function guiHint(designId) {
|
|
293
|
+
if (!guiUrl)
|
|
294
|
+
return "";
|
|
295
|
+
return `\n\nOpen the canvas: ${guiUrl}#/${encodeURIComponent(designId)}`;
|
|
296
|
+
}
|
|
297
|
+
// --- Déclaration des outils -------------------------------------------------
|
|
298
|
+
const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeWidth, radius, fontSize, lineHeight), " +
|
|
299
|
+
"rotation is in degrees and opacity is a 0-1 ratio. " +
|
|
300
|
+
"Coordinates are expressed in the parent's space: a node with parentId null is positioned against the artboard, " +
|
|
301
|
+
"a node inside a group is positioned against that group's origin. " +
|
|
302
|
+
"Set parentId to the id of a group node to nest an element, or to null to move it back to the root. " +
|
|
303
|
+
"A \"group\" node is a pure container: it accepts name, parentId, x, y, rotation and opacity, but its width and " +
|
|
304
|
+
"height are derived from its content and cannot be set. " +
|
|
305
|
+
"Fields accepted per type — rect: fill, stroke, strokeWidth, radius; ellipse: fill, stroke, strokeWidth; " +
|
|
306
|
+
"line: stroke, strokeWidth (the line goes from (x, y) to (x + width, y + height)); " +
|
|
307
|
+
"text: text, color, fontSize, fontFamily, fontWeight, fontStyle, textAlign, lineHeight. " +
|
|
308
|
+
"Any field left out falls back to a sensible default, so a node can be created from its type alone.";
|
|
309
|
+
/**
|
|
310
|
+
* Propriétés d'un noeud exposées à l'agent. Les descriptions viennent de
|
|
311
|
+
* shared/node-fields.ts, partagées avec le schéma zod : l'unité d'un champ n'est
|
|
312
|
+
* écrite qu'à un seul endroit.
|
|
313
|
+
*/
|
|
314
|
+
const NODE_PROPERTIES = {
|
|
315
|
+
id: { type: "string", description: NODE_FIELD_DOCS.id },
|
|
316
|
+
name: { type: "string", description: NODE_FIELD_DOCS.name },
|
|
317
|
+
parentId: { type: ["string", "null"], description: NODE_FIELD_DOCS.parentId },
|
|
318
|
+
x: { type: "number", description: NODE_FIELD_DOCS.x },
|
|
319
|
+
y: { type: "number", description: NODE_FIELD_DOCS.y },
|
|
320
|
+
width: { type: "number", description: NODE_FIELD_DOCS.width },
|
|
321
|
+
height: { type: "number", description: NODE_FIELD_DOCS.height },
|
|
322
|
+
rotation: { type: "number", description: NODE_FIELD_DOCS.rotation },
|
|
323
|
+
opacity: { type: "number", minimum: 0, maximum: 1, description: NODE_FIELD_DOCS.opacity },
|
|
324
|
+
fill: { type: "string", description: NODE_FIELD_DOCS.fill },
|
|
325
|
+
stroke: { type: "string", description: NODE_FIELD_DOCS.stroke },
|
|
326
|
+
strokeWidth: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.strokeWidth },
|
|
327
|
+
radius: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.radius },
|
|
328
|
+
text: { type: "string", description: NODE_FIELD_DOCS.text },
|
|
329
|
+
color: { type: "string", description: NODE_FIELD_DOCS.color },
|
|
330
|
+
fontSize: { type: "number", exclusiveMinimum: 0, description: NODE_FIELD_DOCS.fontSize },
|
|
331
|
+
fontFamily: { type: "string", description: NODE_FIELD_DOCS.fontFamily },
|
|
332
|
+
fontWeight: { type: "number", minimum: 100, maximum: 900, description: NODE_FIELD_DOCS.fontWeight },
|
|
333
|
+
fontStyle: { type: "string", enum: ["normal", "italic"], description: NODE_FIELD_DOCS.fontStyle },
|
|
334
|
+
textAlign: { type: "string", enum: ["left", "center", "right"], description: NODE_FIELD_DOCS.textAlign },
|
|
335
|
+
lineHeight: { type: "number", minimum: MIN_LINE_HEIGHT_PX, description: NODE_FIELD_DOCS.lineHeight },
|
|
336
|
+
};
|
|
337
|
+
const DESIGN_ID_DOC = "Id of the design, as returned by list_designs or create_design.";
|
|
338
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
339
|
+
tools: [
|
|
340
|
+
{
|
|
341
|
+
name: "list_designs",
|
|
342
|
+
description: "List every design with its size, node count, revision and the number of edits not yet acknowledged with mark_synced. Start here when you do not know which design to work on.",
|
|
343
|
+
inputSchema: { type: "object", properties: {} },
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "create_design",
|
|
347
|
+
description: "Create an empty design (artboard). Returns its id, which every other tool needs. Default canvas is 1440x900 on a white background.",
|
|
348
|
+
inputSchema: {
|
|
349
|
+
type: "object",
|
|
350
|
+
properties: {
|
|
351
|
+
name: { type: "string", description: "Human-readable name of the design, e.g. \"Landing page\"." },
|
|
352
|
+
width: { type: "number", description: "Canvas width in pixels (default 1440). Use 375 for mobile, 1440 for desktop." },
|
|
353
|
+
height: { type: "number", description: "Canvas height in pixels (default 900)." },
|
|
354
|
+
background: { type: "string", description: "CSS color of the canvas background (default #ffffff)." },
|
|
355
|
+
},
|
|
356
|
+
required: ["name"],
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "get_design",
|
|
361
|
+
description: "Read a design. format=summary (default) gives a compact inventory of the nodes; format=json gives the exact values needed to build an update_nodes patch. Never acknowledges pending user edits — use get_changes for that.",
|
|
362
|
+
inputSchema: {
|
|
363
|
+
type: "object",
|
|
364
|
+
properties: {
|
|
365
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
366
|
+
format: { type: "string", enum: ["summary", "json"], description: "Level of detail (default summary)." },
|
|
367
|
+
},
|
|
368
|
+
required: ["design_id"],
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
name: "update_design",
|
|
373
|
+
description: "Change the artboard itself: its name, its dimensions or its background color. Does not touch the nodes.",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: "object",
|
|
376
|
+
properties: {
|
|
377
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
378
|
+
name: { type: "string", description: "New name." },
|
|
379
|
+
width: { type: "number", description: "New canvas width in pixels." },
|
|
380
|
+
height: { type: "number", description: "New canvas height in pixels." },
|
|
381
|
+
background: { type: "string", description: "New CSS background color." },
|
|
382
|
+
},
|
|
383
|
+
required: ["design_id"],
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "delete_design",
|
|
388
|
+
description: "Permanently delete a design and all of its nodes.",
|
|
389
|
+
inputSchema: {
|
|
390
|
+
type: "object",
|
|
391
|
+
properties: { design_id: { type: "string", description: DESIGN_ID_DOC } },
|
|
392
|
+
required: ["design_id"],
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "add_nodes",
|
|
397
|
+
description: `Add one or more nodes on top of the design. Node types: ${Object.keys(NODE_DEFAULTS).join(", ")}. ` +
|
|
398
|
+
NODE_FIELDS_DOC +
|
|
399
|
+
" Nodes are drawn in array order, so the last one added sits on top.",
|
|
400
|
+
inputSchema: {
|
|
401
|
+
type: "object",
|
|
402
|
+
properties: {
|
|
403
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
404
|
+
nodes: {
|
|
405
|
+
type: "array",
|
|
406
|
+
description: "Nodes to create, drawn bottom to top in this order.",
|
|
407
|
+
items: {
|
|
408
|
+
type: "object",
|
|
409
|
+
properties: {
|
|
410
|
+
type: { type: "string", enum: ["rect", "ellipse", "line", "text", "group"], description: NODE_FIELD_DOCS.type },
|
|
411
|
+
...NODE_PROPERTIES,
|
|
412
|
+
},
|
|
413
|
+
required: ["type"],
|
|
414
|
+
},
|
|
415
|
+
minItems: 1,
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
required: ["design_id", "nodes"],
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "update_nodes",
|
|
423
|
+
description: "Patch existing nodes. Each entry needs an id plus only the fields to change; everything else is preserved. " +
|
|
424
|
+
NODE_FIELDS_DOC +
|
|
425
|
+
" The whole batch is validated before anything is written, so an invalid entry changes nothing.",
|
|
426
|
+
inputSchema: {
|
|
427
|
+
type: "object",
|
|
428
|
+
properties: {
|
|
429
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
430
|
+
updates: {
|
|
431
|
+
type: "array",
|
|
432
|
+
description: "One object per node: { id, ...fields to change }.",
|
|
433
|
+
items: {
|
|
434
|
+
type: "object",
|
|
435
|
+
properties: {
|
|
436
|
+
...NODE_PROPERTIES,
|
|
437
|
+
id: { type: "string", description: "Id of the node to patch." },
|
|
438
|
+
},
|
|
439
|
+
required: ["id"],
|
|
440
|
+
},
|
|
441
|
+
minItems: 1,
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
required: ["design_id", "updates"],
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: "delete_nodes",
|
|
449
|
+
description: "Remove nodes from a design by id. Deleting a group also deletes everything inside it.",
|
|
450
|
+
inputSchema: {
|
|
451
|
+
type: "object",
|
|
452
|
+
properties: {
|
|
453
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
454
|
+
node_ids: { type: "array", items: { type: "string" }, description: "Ids of the nodes to delete.", minItems: 1 },
|
|
455
|
+
},
|
|
456
|
+
required: ["design_id", "node_ids"],
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: "group_nodes",
|
|
461
|
+
description: "Wrap existing nodes into a new group, so they can be moved, hidden or reordered together. The nodes must currently share the same parent, and nothing moves on screen: their coordinates are rebased onto the new group.",
|
|
462
|
+
inputSchema: {
|
|
463
|
+
type: "object",
|
|
464
|
+
properties: {
|
|
465
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
466
|
+
node_ids: { type: "array", items: { type: "string" }, description: "Ids of the nodes to group together.", minItems: 1 },
|
|
467
|
+
name: { type: "string", description: "Name of the new group (default \"Groupe\")." },
|
|
468
|
+
group_id: { type: "string", description: "Optional explicit id for the group. Generated when omitted." },
|
|
469
|
+
},
|
|
470
|
+
required: ["design_id", "node_ids"],
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: "ungroup_nodes",
|
|
475
|
+
description: "Dissolve groups: their content moves up one level, keeping its position on screen. The groups themselves are removed.",
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: "object",
|
|
478
|
+
properties: {
|
|
479
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
480
|
+
group_ids: { type: "array", items: { type: "string" }, description: "Ids of the groups to dissolve.", minItems: 1 },
|
|
481
|
+
},
|
|
482
|
+
required: ["design_id", "group_ids"],
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
name: "reorder_nodes",
|
|
487
|
+
description: "Change the stacking order of nodes — which one is drawn on top of which. This is the layer panel, agent side. " +
|
|
488
|
+
"Stacking is relative to SIBLINGS only: a node can only move past nodes sharing its parent, never past the content of " +
|
|
489
|
+
"another group. To move an element above a whole group, reorder that group instead; to pull it out of a group, " +
|
|
490
|
+
"change its parentId with update_nodes. " +
|
|
491
|
+
"Moving several nodes at once keeps their relative order, and a node already at the edge of its sibling order simply does not move.",
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
496
|
+
node_ids: {
|
|
497
|
+
type: "array",
|
|
498
|
+
items: { type: "string" },
|
|
499
|
+
description: "Ids of the nodes to move. Groups are allowed — a group moves with its whole content. " +
|
|
500
|
+
"A node whose ancestor is also listed is ignored: the ancestor already carries it.",
|
|
501
|
+
minItems: 1,
|
|
502
|
+
},
|
|
503
|
+
position: {
|
|
504
|
+
type: "string",
|
|
505
|
+
enum: [...REORDER_DIRECTIONS],
|
|
506
|
+
description: "front = on top of all its siblings, back = behind all of them, " +
|
|
507
|
+
"forward = one step up, backward = one step down.",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
required: ["design_id", "node_ids", "position"],
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "get_changes",
|
|
515
|
+
description: "Show every node added, modified, removed or reordered since the last mark_synced — this is how you pick up what the user changed by hand in the GUI. Reading changes never acknowledges them; call mark_synced once the changes are integrated into the code.",
|
|
516
|
+
inputSchema: {
|
|
517
|
+
type: "object",
|
|
518
|
+
properties: { design_id: { type: "string", description: DESIGN_ID_DOC } },
|
|
519
|
+
required: ["design_id"],
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: "mark_synced",
|
|
524
|
+
description: "Acknowledge the current state of a design as the new comparison baseline. Call it only after the user's edits have actually been reported into the codebase — get_changes will then be empty until the next edit.",
|
|
525
|
+
inputSchema: {
|
|
526
|
+
type: "object",
|
|
527
|
+
properties: { design_id: { type: "string", description: DESIGN_ID_DOC } },
|
|
528
|
+
required: ["design_id"],
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: "export_design",
|
|
533
|
+
description: "Render a design for integration. html = absolutely positioned divs + CSS (best starting point for real code), jsx = the same as a React component, svg = faithful preview, json = raw nodes, summary = textual inventory. Provide output_path to write to a file instead of returning the content.",
|
|
534
|
+
inputSchema: {
|
|
535
|
+
type: "object",
|
|
536
|
+
properties: {
|
|
537
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
538
|
+
format: { type: "string", enum: [...EXPORT_FORMATS], description: "Output format (default html)." },
|
|
539
|
+
output_path: { type: "string", description: "Absolute path to write the export to. When omitted, the content is returned directly." },
|
|
540
|
+
},
|
|
541
|
+
required: ["design_id"],
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: "screenshot_design",
|
|
546
|
+
description: "Render a design to a PNG image and look at it. Use it to check what the design actually looks like — proportions, overlaps, contrast, alignment — instead of inferring it from coordinates. " +
|
|
547
|
+
"Pass node_id to frame the capture on a single group (or any element) rather than the whole artboard. " +
|
|
548
|
+
"The image is returned inline; provide output_path to write a .png file instead.",
|
|
549
|
+
inputSchema: {
|
|
550
|
+
type: "object",
|
|
551
|
+
properties: {
|
|
552
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
553
|
+
node_id: {
|
|
554
|
+
type: "string",
|
|
555
|
+
description: "Id of a group (or any node) to frame the capture on. When omitted, the whole artboard is captured.",
|
|
556
|
+
},
|
|
557
|
+
scale: {
|
|
558
|
+
type: "number",
|
|
559
|
+
description: "Zoom factor applied to the capture (default 1). Useful to read a small group. The result is capped so its longest side stays under 1568 px.",
|
|
560
|
+
},
|
|
561
|
+
padding: {
|
|
562
|
+
type: "number",
|
|
563
|
+
description: "Margin in design pixels around the captured node (default 16, ignored for a full-artboard capture).",
|
|
564
|
+
},
|
|
565
|
+
output_path: {
|
|
566
|
+
type: "string",
|
|
567
|
+
description: "Absolute path of a .png file to write. When omitted, the image is returned inline.",
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
required: ["design_id"],
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: "reconnect_gui",
|
|
575
|
+
description: "Relaunch the GUI worker and re-register it with the proxy. Use it when the canvas is unreachable, or after starting the proxy server.",
|
|
576
|
+
inputSchema: {
|
|
577
|
+
type: "object",
|
|
578
|
+
properties: { force: { type: "boolean", description: "Restart the worker even if it looks alive." } },
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
}));
|
|
583
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
584
|
+
const { name, arguments: args } = request.params;
|
|
585
|
+
const params = (args ?? {});
|
|
586
|
+
try {
|
|
587
|
+
switch (name) {
|
|
588
|
+
case "list_designs":
|
|
589
|
+
return listDesigns(store);
|
|
590
|
+
case "create_design": {
|
|
591
|
+
const result = createDesign(store, params);
|
|
592
|
+
broadcastToGUI({ type: "DESIGNS_UPDATED", data: { designs: store.list() }, timestamp: new Date().toISOString() });
|
|
593
|
+
return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
|
|
594
|
+
}
|
|
595
|
+
case "get_design":
|
|
596
|
+
return getDesign(store, params);
|
|
597
|
+
case "update_design": {
|
|
598
|
+
const result = updateDesign(store, params);
|
|
599
|
+
notifyDesignChanged(result.design.id);
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
case "delete_design": {
|
|
603
|
+
const result = deleteDesign(store, params);
|
|
604
|
+
broadcastToGUI({ type: "DESIGNS_UPDATED", data: { designs: store.list() }, timestamp: new Date().toISOString() });
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
case "add_nodes": {
|
|
608
|
+
const result = addNodes(store, params);
|
|
609
|
+
notifyDesignChanged(result.design.id);
|
|
610
|
+
return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
|
|
611
|
+
}
|
|
612
|
+
case "update_nodes": {
|
|
613
|
+
const result = updateNodes(store, params);
|
|
614
|
+
notifyDesignChanged(result.design.id);
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
case "delete_nodes": {
|
|
618
|
+
const result = deleteNodes(store, params);
|
|
619
|
+
notifyDesignChanged(result.design.id);
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
case "group_nodes": {
|
|
623
|
+
const result = groupNodes(store, params);
|
|
624
|
+
notifyDesignChanged(result.design.id);
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
case "ungroup_nodes": {
|
|
628
|
+
const result = ungroupNodes(store, params);
|
|
629
|
+
notifyDesignChanged(result.design.id);
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
case "reorder_nodes": {
|
|
633
|
+
const result = reorderNodesTool(store, params);
|
|
634
|
+
notifyDesignChanged(result.design.id);
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
case "get_changes":
|
|
638
|
+
return getChanges(store, params);
|
|
639
|
+
case "mark_synced": {
|
|
640
|
+
const result = markSynced(store, params);
|
|
641
|
+
notifyDesignChanged(result.design.id);
|
|
642
|
+
return result;
|
|
643
|
+
}
|
|
644
|
+
case "export_design":
|
|
645
|
+
return exportDesignTool(store, params);
|
|
646
|
+
case "screenshot_design":
|
|
647
|
+
return screenshotDesignTool(store, params);
|
|
648
|
+
case "reconnect_gui":
|
|
649
|
+
return await reconnectGui(params);
|
|
650
|
+
default:
|
|
651
|
+
throw new Error(`Tool not found: ${name}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
async function main() {
|
|
659
|
+
const transport = new StdioServerTransport();
|
|
660
|
+
await server.connect(transport);
|
|
661
|
+
logger.info(`MCP Server connected and running on stdio (data dir: ${store.getDataDir()})`);
|
|
662
|
+
launchGUI();
|
|
663
|
+
}
|
|
664
|
+
main().catch((err) => {
|
|
665
|
+
logger.error(`Fatal error: ${err.message}`);
|
|
666
|
+
process.exit(1);
|
|
667
|
+
});
|
|
668
|
+
process.on("uncaughtException", (err) => {
|
|
669
|
+
logger.error(`Uncaught Exception in MASTER: ${err.message}`);
|
|
670
|
+
logger.error(err.stack || "No stack trace");
|
|
671
|
+
guiLauncher.getProcess()?.kill("SIGTERM");
|
|
672
|
+
process.exit(1);
|
|
673
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DesignStore } from "../core/design-store.js";
|
|
2
|
+
import type { Design } from "../../shared/schemas/design.schema.js";
|
|
3
|
+
import { type ToolResult } from "./types.js";
|
|
4
|
+
export interface MutationResult extends ToolResult {
|
|
5
|
+
design?: Design;
|
|
6
|
+
}
|
|
7
|
+
export declare function addNodes(store: DesignStore, args: Record<string, unknown> | undefined): MutationResult;
|