@imenam/mcp-design 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/index.js CHANGED
@@ -34,6 +34,7 @@ import { getChanges } from "./tools/get-changes.js";
34
34
  import { markSynced } from "./tools/mark-synced.js";
35
35
  import { exportDesignTool } from "./tools/export-design.js";
36
36
  import { screenshotDesignTool } from "./tools/screenshot-design.js";
37
+ import { RELAY_SHAPE_DESCRIPTIONS } from "./tools/relay.js";
37
38
  import { reorderNodesTool } from "./tools/reorder-nodes.js";
38
39
  import { searchIconsTool, DEFAULT_ICON_RESULTS, MAX_ICON_RESULTS } from "./tools/search-icons.js";
39
40
  const __filename = fileURLToPath(import.meta.url);
@@ -41,8 +42,14 @@ const __dirname = path.dirname(__filename);
41
42
  // Les flags CLI sont traités avant toute initialisation : ils s'exécutent dans le
42
43
  // répertoire de l'utilisateur, pas dans un serveur MCP.
43
44
  if (process.argv.slice(2).includes("--claude-setup-mcp")) {
44
- const { runSetupMcp } = await import("./cli/setup-mcp.js");
45
- process.exit(runSetupMcp({ cwd: process.cwd(), force: process.argv.slice(2).includes("--force") }));
45
+ const argv = process.argv.slice(2);
46
+ const { runSetupMcp, parseGuiExposure } = await import("./cli/setup-mcp.js");
47
+ const parsed = parseGuiExposure(argv);
48
+ if ("error" in parsed) {
49
+ console.error(`Erreur : ${parsed.error}`);
50
+ process.exit(1);
51
+ }
52
+ process.exit(runSetupMcp({ cwd: process.cwd(), force: argv.includes("--force"), exposure: parsed.exposure }));
46
53
  }
47
54
  function findProjectRoot(startDir) {
48
55
  let current = startDir;
@@ -62,7 +69,7 @@ const logger = createLogger("mcp-design");
62
69
  function getConfig() {
63
70
  if (fs.existsSync(packageEnvPath))
64
71
  dotenv.config({ path: packageEnvPath, override: true });
65
- return { proxyUrl: process.env.PROXY_URL, version: APP_VERSION };
72
+ return { proxyUrl: process.env.PROXY_URL, appPort: process.env.APP_PORT, version: APP_VERSION };
66
73
  }
67
74
  getConfig();
68
75
  const store = new DesignStore(rootDir);
@@ -224,9 +231,13 @@ guiLauncher = new GuiLauncher({
224
231
  maxRestarts: 0,
225
232
  onMessage: handleIPCMessage,
226
233
  });
234
+ /** La GUI a besoin soit d'un proxy, soit d'un port local (mode standalone). */
235
+ function guiIsConfigured() {
236
+ return !!(process.env.PROXY_URL?.trim() || process.env.APP_PORT?.trim());
237
+ }
227
238
  function launchGUI() {
228
- if (!process.env.PROXY_URL) {
229
- logger.info("[MASTER] PROXY_URL non défini — GUI désactivée.");
239
+ if (!guiIsConfigured()) {
240
+ logger.info("[MASTER] Ni PROXY_URL ni APP_PORT défini — GUI désactivée.");
230
241
  guiStatus = "disabled";
231
242
  return;
232
243
  }
@@ -234,13 +245,13 @@ function launchGUI() {
234
245
  startGuiWorker();
235
246
  }
236
247
  async function reconnectGui(args) {
237
- getConfig(); // relit le .env au cas où PROXY_URL aurait été ajouté après le démarrage
238
- if (!process.env.PROXY_URL) {
248
+ getConfig(); // relit le .env au cas où PROXY_URL/APP_PORT aurait été ajouté après le démarrage
249
+ if (!guiIsConfigured()) {
239
250
  return {
240
251
  content: [
241
252
  {
242
253
  type: "text",
243
- text: "Error: PROXY_URL is not set — the GUI is disabled. Add PROXY_URL to the .env file, then call reconnect_gui again.",
254
+ text: "Error: neither PROXY_URL nor APP_PORT is set — the GUI is disabled. Add PROXY_URL (to expose the GUI through the proxy) or APP_PORT (to serve it directly on localhost) to the .env file, then call reconnect_gui again.",
244
255
  },
245
256
  ],
246
257
  isError: true,
@@ -253,13 +264,13 @@ async function reconnectGui(args) {
253
264
  if (guiStatus === "starting") {
254
265
  return {
255
266
  content: [
256
- { type: "text", text: "GUI worker is already starting (proxy registration in progress). Wait a few seconds, then call reconnect_gui again if needed." },
267
+ { type: "text", text: "GUI worker is already starting. Wait a few seconds, then call reconnect_gui again if needed." },
257
268
  ],
258
269
  };
259
270
  }
260
271
  return {
261
272
  content: [
262
- { type: "text", text: `GUI is already registered with the proxy${guiUrl ? ` at ${guiUrl}` : ""}. Nothing to do (use force=true to restart it anyway).` },
273
+ { type: "text", text: `GUI is already running${guiUrl ? ` at ${guiUrl}` : ""}. Nothing to do (use force=true to restart it anyway).` },
263
274
  ],
264
275
  };
265
276
  }
@@ -285,23 +296,25 @@ async function reconnectGui(args) {
285
296
  };
286
297
  newChild.on("exit", onExit);
287
298
  });
299
+ const proxyUrl = process.env.PROXY_URL?.trim();
288
300
  if (result.outcome === "ready") {
289
- return { content: [{ type: "text", text: `GUI reconnected and registered with the proxy. URL: ${result.url}` }] };
301
+ return { content: [{ type: "text", text: `GUI reconnected. URL: ${result.url}` }] };
290
302
  }
291
303
  if (result.outcome === "already_running") {
292
- return { content: [{ type: "text", text: `GUI is already registered by another MCP instance. URL: ${result.url ?? "unknown"}` }] };
304
+ return { content: [{ type: "text", text: `GUI is already served by another MCP instance. URL: ${result.url ?? "unknown"}` }] };
293
305
  }
294
306
  if (result.outcome === "exited") {
307
+ const hint = proxyUrl
308
+ ? `The proxy server is probably still unreachable at ${proxyUrl}. Start the proxy server, then call reconnect_gui again.`
309
+ : `Port ${process.env.APP_PORT} is probably already taken by another process. Free it or set another APP_PORT, then call reconnect_gui again.`;
295
310
  return {
296
- content: [
297
- { 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.` },
298
- ],
311
+ content: [{ type: "text", text: `GUI worker exited before it was ready (code=${result.code}). ${hint}` }],
299
312
  isError: true,
300
313
  };
301
314
  }
302
315
  return {
303
316
  content: [
304
- { type: "text", text: `GUI worker started but did not confirm proxy registration within 15s. Check the logs (${resolveLogDir(rootDir)}), then retry.` },
317
+ { type: "text", text: `GUI worker started but did not confirm it was ready within 15s. Check the logs (${resolveLogDir(rootDir)}), then retry.` },
305
318
  ],
306
319
  isError: true,
307
320
  };
@@ -323,6 +336,8 @@ const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeW
323
336
  "Fields accepted per type — rect: fill, stroke, strokeWidth, radius; ellipse: fill, stroke, strokeWidth; " +
324
337
  "line: stroke, strokeWidth (the line goes from (x, y) to (x + width, y + height)); " +
325
338
  "text: text, color, fontSize, fontFamily, fontWeight, fontStyle, textAlign, lineHeight; " +
339
+ "a text node given no height takes the exact height of its text, so its first line starts at y — " +
340
+ "pass a height only to centre a label inside a container (same y and height as the container); " +
326
341
  "icon: icon (required — the name of the drawing), fill, stroke, strokeWidth; " +
327
342
  "instance: componentId (required — the id of the group it mirrors); nothing else, its look comes from that group. " +
328
343
  "Any field left out falls back to a sensible default, so a node can be created from its type alone.";
@@ -676,13 +691,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
676
691
  },
677
692
  {
678
693
  name: "export_design",
679
- 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.",
694
+ 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 destination_path and filename to write a file on the agent machine instead of returning the content.",
680
695
  inputSchema: {
681
696
  type: "object",
682
697
  properties: {
683
698
  design_id: { type: "string", description: DESIGN_ID_DOC },
684
699
  format: { type: "string", enum: [...EXPORT_FORMATS], description: "Output format (default html)." },
685
- output_path: { type: "string", description: "Absolute path to write the export to. When omitted, the content is returned directly." },
700
+ destination_path: {
701
+ type: "string",
702
+ description: `${RELAY_SHAPE_DESCRIPTIONS.destination_path}. When omitted, the content is returned directly instead of being written.`,
703
+ },
704
+ filename: { type: "string", description: `${RELAY_SHAPE_DESCRIPTIONS.filename}. Required when destination_path is provided.` },
705
+ destination_overwrite: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_overwrite },
706
+ destination_relay: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_relay },
686
707
  },
687
708
  required: ["design_id"],
688
709
  },
@@ -691,7 +712,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
691
712
  name: "screenshot_design",
692
713
  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. " +
693
714
  "Pass node_id to frame the capture on a single group (or any element) rather than the whole artboard. " +
694
- "The image is returned inline; provide output_path to write a .png file instead.",
715
+ "The image is returned inline; provide destination_path and filename to write a .png file on the agent machine instead.",
695
716
  inputSchema: {
696
717
  type: "object",
697
718
  properties: {
@@ -708,10 +729,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
708
729
  type: "number",
709
730
  description: "Margin in design pixels around the captured node (default 16, ignored for a full-artboard capture).",
710
731
  },
711
- output_path: {
732
+ destination_path: {
712
733
  type: "string",
713
- description: "Absolute path of a .png file to write. When omitted, the image is returned inline.",
734
+ description: `${RELAY_SHAPE_DESCRIPTIONS.destination_path}. When omitted, the image is returned inline instead of being written.`,
714
735
  },
736
+ filename: { type: "string", description: "Name of the .png file to create. Required when destination_path is provided. Must not contain path separators." },
737
+ destination_overwrite: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_overwrite },
738
+ destination_relay: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_relay },
715
739
  },
716
740
  required: ["design_id"],
717
741
  },
@@ -1,3 +1,3 @@
1
1
  import type { DesignStore } from "../core/design-store.js";
2
- import { type ToolResult } from "./types.js";
3
- export declare function exportDesignTool(store: DesignStore, args: Record<string, unknown> | undefined): ToolResult;
2
+ import { type FileToolResult } from "./types.js";
3
+ export declare function exportDesignTool(store: DesignStore, args: Record<string, unknown> | undefined): FileToolResult;
@@ -1,7 +1,13 @@
1
- import fs from "fs";
2
- import path from "path";
3
1
  import { EXPORT_FORMATS, exportDesign as render } from "../core/export.js";
2
+ import { deliverFile } from "./relay.js";
4
3
  import { requireString, textResult } from "./types.js";
4
+ const MIME_BY_FORMAT = {
5
+ html: "text/html",
6
+ jsx: "text/jsx",
7
+ svg: "image/svg+xml",
8
+ json: "application/json",
9
+ summary: "text/plain",
10
+ };
5
11
  export function exportDesignTool(store, args) {
6
12
  const id = requireString(args, "design_id");
7
13
  const format = (args?.format ?? "html");
@@ -10,12 +16,25 @@ export function exportDesignTool(store, args) {
10
16
  }
11
17
  const design = store.get(id);
12
18
  const content = render(design, format);
13
- const outputPath = typeof args?.output_path === "string" ? args.output_path.trim() : "";
14
- if (outputPath) {
15
- const target = path.resolve(outputPath);
16
- fs.mkdirSync(path.dirname(target), { recursive: true });
17
- fs.writeFileSync(target, content, "utf-8");
18
- return textResult(`Design "${design.name}" exported as ${format} to ${target} (${content.length} characters).`);
19
+ const destination = typeof args?.destination_path === "string" ? args.destination_path.trim() : "";
20
+ if (!destination) {
21
+ return textResult(content);
19
22
  }
20
- return textResult(content);
23
+ // Le fichier part vers la machine de l'agent : ici on ne fait que produire les octets,
24
+ // `deliverFile` décide s'ils s'écrivent sur ce disque ou voyagent jusqu'au relais.
25
+ const blocks = deliverFile(Buffer.from(content, "utf-8"), MIME_BY_FORMAT[format] ?? "text/plain", {
26
+ destination_path: destination,
27
+ filename: requireString(args, "filename"),
28
+ destination_overwrite: args?.destination_overwrite === true,
29
+ destination_relay: args?.destination_relay === true,
30
+ });
31
+ return {
32
+ content: [
33
+ {
34
+ type: "text",
35
+ text: `Design "${design.name}" exported as ${format} (${content.length} characters).`,
36
+ },
37
+ ...blocks,
38
+ ],
39
+ };
21
40
  }
@@ -0,0 +1,27 @@
1
+ export declare const RELAY_SHAPE_DESCRIPTIONS: {
2
+ readonly destination_path: "ABSOLUTE path of the destination DIRECTORY on the agent machine. It is created if missing, and the file keeps the name given in filename";
3
+ readonly filename: "Name of the file to create, extension included (e.g. 'design.html'). Must not contain path separators";
4
+ readonly destination_overwrite: "Replace the destination file if it already exists. Defaults to false";
5
+ readonly destination_relay: "Filled in by the mcp-http-gateway relay, never by the agent. Signals that the destination lives on another machine and that the file content must travel in the response";
6
+ };
7
+ export interface DeliveryTarget {
8
+ destination_path: string;
9
+ filename: string;
10
+ destination_overwrite?: boolean;
11
+ destination_relay?: boolean;
12
+ }
13
+ import type { ResourceBlock } from "./types.js";
14
+ type ContentBlock = {
15
+ type: "text";
16
+ text: string;
17
+ } | ResourceBlock;
18
+ /**
19
+ * Livre un fichier produit par le MCP, du bon côté de la chaîne.
20
+ *
21
+ * Renvoie les blocs de contenu à faire figurer dans le résultat de l'outil : un bloc
22
+ * `resource` que le relais interceptera et retirera, ou une simple confirmation quand
23
+ * l'écriture a eu lieu ici. Toute impossibilité est une erreur franche : mieux vaut un
24
+ * fichier perdu qu'un fichier écrit sur la mauvaise machine.
25
+ */
26
+ export declare function deliverFile(data: Buffer, mimeType: string, target: DeliveryTarget): ContentBlock[];
27
+ export {};
@@ -0,0 +1,67 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ // Un fichier produit par ce MCP n'a aucun moyen d'atteindre la machine de l'agent quand
4
+ // le MCP tourne derrière mcp-http-gateway : le protocole n'offre au serveur aucune
5
+ // primitive pour y écrire. La convention du relais règle ça — dès qu'un outil déclare la
6
+ // paire `destination_path` / `destination_relay`, le relais retient la destination, pose
7
+ // `destination_relay`, et écrit lui-même les octets qu'on lui renvoie.
8
+ //
9
+ // Ici, on ne sait pas si un relais est dans la chaîne : `destination_relay` le dit. Posé,
10
+ // on renvoie le contenu ; absent, les deux machines n'en font qu'une et on écrit nous-même.
11
+ /** Plafond de ce qui peut être encodé dans une réponse d'outil. Le relais applique sa
12
+ * propre limite, configurable ; celle-ci n'existe que pour qu'un fichier démesuré
13
+ * échoue proprement ici plutôt qu'en épuisant la mémoire pendant l'encodage base64. */
14
+ const RELAY_MAX_BYTES = 32 * 1024 * 1024;
15
+ export const RELAY_SHAPE_DESCRIPTIONS = {
16
+ destination_path: "ABSOLUTE path of the destination DIRECTORY on the agent machine. It is created if missing, and the file keeps the name given in filename",
17
+ filename: "Name of the file to create, extension included (e.g. 'design.html'). Must not contain path separators",
18
+ destination_overwrite: "Replace the destination file if it already exists. Defaults to false",
19
+ destination_relay: "Filled in by the mcp-http-gateway relay, never by the agent. Signals that the destination lives on another machine and that the file content must travel in the response",
20
+ };
21
+ /**
22
+ * Livre un fichier produit par le MCP, du bon côté de la chaîne.
23
+ *
24
+ * Renvoie les blocs de contenu à faire figurer dans le résultat de l'outil : un bloc
25
+ * `resource` que le relais interceptera et retirera, ou une simple confirmation quand
26
+ * l'écriture a eu lieu ici. Toute impossibilité est une erreur franche : mieux vaut un
27
+ * fichier perdu qu'un fichier écrit sur la mauvaise machine.
28
+ */
29
+ export function deliverFile(data, mimeType, target) {
30
+ const name = path.basename(target.filename);
31
+ if (name !== target.filename || name === "." || name === "..") {
32
+ throw new Error(`filename must be a bare file name, without path separators: ${target.filename}`);
33
+ }
34
+ if (data.length > RELAY_MAX_BYTES) {
35
+ throw new Error(`File too large to transfer: ${data.length} bytes, the limit is ${RELAY_MAX_BYTES}.`);
36
+ }
37
+ // Le relais tourne sur la machine de l'agent et a retenu la destination : il attend les
38
+ // octets, qu'il écrira lui-même. Écrire ici viserait le mauvais disque.
39
+ if (target.destination_relay) {
40
+ return [
41
+ {
42
+ type: "resource",
43
+ resource: {
44
+ uri: `file:///${name}`,
45
+ name,
46
+ mimeType,
47
+ blob: data.toString("base64"),
48
+ },
49
+ },
50
+ ];
51
+ }
52
+ // Pas de relais : le MCP tourne à côté de l'agent, son disque est le bon.
53
+ if (!path.isAbsolute(target.destination_path)) {
54
+ throw new Error(`destination_path must be an absolute directory path: ${target.destination_path}`);
55
+ }
56
+ if (fs.existsSync(target.destination_path) &&
57
+ !fs.statSync(target.destination_path).isDirectory()) {
58
+ throw new Error(`destination_path must be a directory, but a file already exists there: ${target.destination_path}`);
59
+ }
60
+ const written = path.join(target.destination_path, name);
61
+ if (!target.destination_overwrite && fs.existsSync(written)) {
62
+ throw new Error(`Destination already exists: ${written}. Pass destination_overwrite to replace it.`);
63
+ }
64
+ fs.mkdirSync(target.destination_path, { recursive: true });
65
+ fs.writeFileSync(written, data);
66
+ return [{ type: "text", text: `File written to ${written} (${data.length} bytes).` }];
67
+ }
@@ -1,6 +1,5 @@
1
- import fs from "fs";
2
- import path from "path";
3
1
  import { screenshotDesign } from "../core/screenshot.js";
2
+ import { deliverFile } from "./relay.js";
4
3
  import { requireString } from "./types.js";
5
4
  /** Marge par défaut autour d'un noeud capturé : de l'air, sans noyer l'élément. */
6
5
  const NODE_PADDING = 16;
@@ -20,12 +19,19 @@ export function screenshotDesignTool(store, args) {
20
19
  const subject = node ? `${node.type} "${node.name}" (${node.id})` : `design "${design.name}"`;
21
20
  const geometry = `${Math.round(shot.sourceWidth)}x${Math.round(shot.sourceHeight)} design px ` +
22
21
  `rendered at ${shot.width}x${shot.height} px (scale ${Math.round(shot.scale * 100) / 100})`;
23
- const outputPath = typeof args?.output_path === "string" ? args.output_path.trim() : "";
24
- if (outputPath) {
25
- const target = path.resolve(outputPath);
26
- fs.mkdirSync(path.dirname(target), { recursive: true });
27
- fs.writeFileSync(target, shot.png);
28
- return { content: [{ type: "text", text: `Screenshot of ${subject} written to ${target} — ${geometry}.` }] };
22
+ const destination = typeof args?.destination_path === "string" ? args.destination_path.trim() : "";
23
+ if (destination) {
24
+ // Livrer le PNG et le montrer à la fois n'aurait pas de sens : le relais retire le
25
+ // contenu du résultat, et une image de plus dans le contexte coûte cher pour rien.
26
+ const blocks = deliverFile(shot.png, "image/png", {
27
+ destination_path: destination,
28
+ filename: requireString(args, "filename"),
29
+ destination_overwrite: args?.destination_overwrite === true,
30
+ destination_relay: args?.destination_relay === true,
31
+ });
32
+ return {
33
+ content: [{ type: "text", text: `Screenshot of ${subject} — ${geometry}.` }, ...blocks],
34
+ };
29
35
  }
30
36
  return {
31
37
  content: [
@@ -20,6 +20,25 @@ export interface ImageToolResult {
20
20
  type: "image";
21
21
  data: string;
22
22
  mimeType: string;
23
- }>;
23
+ } | ResourceBlock>;
24
+ isError?: boolean;
25
+ }
26
+ /** Bloc qu'un outil renvoie pour un fichier que le relais du gateway écrira chez l'agent. */
27
+ export interface ResourceBlock {
28
+ type: "resource";
29
+ resource: {
30
+ uri: string;
31
+ name: string;
32
+ mimeType: string;
33
+ blob: string;
34
+ };
35
+ }
36
+ /** Variante de ToolResult pour les outils qui peuvent livrer un fichier. */
37
+ export interface FileToolResult {
38
+ [key: string]: unknown;
39
+ content: Array<{
40
+ type: "text";
41
+ text: string;
42
+ } | ResourceBlock>;
24
43
  isError?: boolean;
25
44
  }
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.6";
1
+ export declare const APP_VERSION = "1.0.8";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.6';
2
+ export const APP_VERSION = '1.0.8';