@imenam/mcp-design 1.0.7 → 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/README.md CHANGED
@@ -167,6 +167,19 @@ Le texte n'est **jamais re-wrappé automatiquement** : seuls les `\n` explicites
167
167
 
168
168
  `lineHeight` est une **distance en pixels entre deux lignes de base**, pas un multiplicateur de `fontSize` : avec `fontSize: 64`, on écrit `76`, pas `1.2`. C'est l'unité que les agents emploient spontanément, et la seule qui se lise dans la même échelle que le reste. Une valeur sous 4 est donc refusée à l'écriture avec un message explicite, et les fichiers écrits avant ce changement sont convertis à la lecture (`ratio x fontSize`) par [`src/core/migrate.ts`](src/core/migrate.ts). Champ omis = `fontSize x 1.35`.
169
169
 
170
+ #### La boîte d'un texte épouse son texte
171
+
172
+ Le bloc de lignes est **centré verticalement dans la boîte** du nœud : c'est ce qui permet de centrer un libellé dans un bouton sans calculer de décalage. Encore faut-il que la boîte soit celle qu'on croit — une hauteur par défaut plus grande que le texte le fait descendre sous le point posé.
173
+
174
+ Donc : **un nœud `text` créé sans `height` prend la hauteur exacte de son texte** (`lignes x lineHeight`). Le centrage n'a alors plus rien à décaler, et la première ligne commence bien à `y`, à gauche de `x`. Ajouter une ligne agrandit la boîte au lieu de la faire déborder : une boîte qui épousait son texte continue de l'épouser, à chaque changement de `text`, `fontSize` ou `lineHeight`, à l'écriture par l'agent comme à l'édition dans la GUI.
175
+
176
+ Une hauteur **explicite**, elle, n'est jamais retouchée — c'est le geste qui dit « centre ce libellé » :
177
+
178
+ ```jsonc
179
+ { "type": "text", "x": 40, "y": 120, "text": "Titre", "fontSize": 32 } // boîte = 43.2px, texte posé à y
180
+ { "type": "text", "x": 40, "y": 100, "height": 26, "text": "OK", "fontSize": 12 } // libellé centré dans son bouton
181
+ ```
182
+
170
183
  ### Groupes réutilisables
171
184
 
172
185
  Quand un même bloc revient plusieurs fois — une carte, une ligne de liste, un bouton —, il ne se recopie pas : il se **construit une fois** en groupe, puis s'**instancie**. Une `instance` ne stocke aucun contenu, seulement l'identifiant du groupe qu'elle reproduit ; le sous-arbre est reconstitué au rendu. Éditer le groupe met donc à jour **toutes** ses instances d'un coup, et un bloc répété reste une seule chose à lire et à modifier. C'est le fonctionnement d'un composant principal dans Figma.
@@ -208,8 +221,12 @@ Un lot est intégralement validé avant la moindre écriture : une entrée inval
208
221
  |---|---|
209
222
  | `get_changes` | Liste tout ce qui a été ajouté, modifié, supprimé ou réordonné depuis la dernière baseline, champ par champ (`x: 40 -> 240`). |
210
223
  | `mark_synced` | Fige l'état courant comme nouvelle baseline. |
211
- | `export_design` | Rend la planche en `html`, `jsx`, `svg`, `json` ou `summary`. `output_path` écrit dans un fichier au lieu de renvoyer le contenu. |
212
- | `screenshot_design` | Rasterise la planche en PNG et le renvoie **en image** à l'agent. `node_id` cadre la capture sur un groupe (ou n'importe quel élément), `scale` zoome, `padding` ajoute de la marge, `output_path` écrit un `.png` au lieu de renvoyer l'image. |
224
+ | `export_design` | Rend la planche en `html`, `jsx`, `svg`, `json` ou `summary`. `destination_path` + `filename` écrivent un fichier **sur la machine de l'agent** au lieu de renvoyer le contenu. |
225
+ | `screenshot_design` | Rasterise la planche en PNG et le renvoie **en image** à l'agent. `node_id` cadre la capture sur un groupe (ou n'importe quel élément), `scale` zoome, `padding` ajoute de la marge, `destination_path` + `filename` écrivent un `.png` **sur la machine de l'agent** au lieu de renvoyer l'image. |
226
+
227
+ > **Où le fichier est écrit.** `destination_path` est un **dossier absolu** sur la machine de l'agent, `filename` le nom que le fichier y prend. Omis, les deux outils se comportent comme avant : le contenu (ou l'image) revient dans la réponse, sans fichier.
228
+ >
229
+ > En stdio, ce MCP tourne à côté de l'agent et écrit lui-même. Derrière [`@imenam/mcp-http-gateway`](https://www.npmjs.com/package/@imenam/mcp-http-gateway), il tourne sur la machine du gateway et ne peut pas atteindre le disque de l'agent : il déclare alors la paire `destination_path` / `destination_relay`, renvoie les octets, et le relais — qui tourne, lui, sur la machine de l'agent — les y écrit. `destination_relay` est rempli par le relais, jamais par l'agent, et cette voie exige `GATEWAY_INLINE_WRITE_ROOTS` dans le bloc `env` du relais.
213
230
 
214
231
  **`get_changes` n'acquitte jamais ce qu'il montre.** Seul `mark_synced` déplace la baseline, et il ne doit être appelé qu'une fois les modifications réellement reportées dans le code. Un simple appel de lecture ne peut donc pas faire disparaître un diff que l'agent n'a pas encore traité.
215
232
 
@@ -246,7 +263,7 @@ Trois colonnes : la liste des planches, le canvas, l'inspecteur et les calques.
246
263
  - **Outils** — sélection, rectangle, ellipse, ligne, texte, icône. Glisser pour dimensionner, cliquer pour poser à la taille par défaut. L'outil icône ouvre d'abord son sélecteur : poser une icône suppose d'avoir choisi laquelle.
247
264
  - **Édition** — déplacement au glisser, 8 poignées de redimensionnement (2 extrémités pour une ligne), sélection multiple au rectangle d'encadrement ou au Maj+clic.
248
265
  - **Groupes** — `Ctrl+G` groupe la sélection, `Ctrl+Maj+G` la dissout. Un clic dans le canvas sélectionne le **groupe le plus haut** ; `Alt+clic` vise l'élément exact à l'intérieur. Déplacer un groupe déplace tout son contenu.
249
- - **Texte** — double-clic pour éditer en place, avec la même métrique qu'au rendu final.
266
+ - **Texte** — double-clic pour éditer en place, avec la même métrique qu'au rendu final. Une boîte à la taille de son texte le reste : ajouter une ligne l'agrandit d'autant.
250
267
  - **Inspecteur** — position, taille, rotation, opacité, couleurs, épaisseur, arrondi, contenu, taille et graisse de police, interligne (en px), alignement, choix de l'icône. Pour un groupe, la taille est affichée en lecture seule puisqu'elle suit son contenu ; pour une icône, un seul champ de taille, puisqu'elle est carrée.
251
268
  - **Calques** — arbre repliable, ordre d'empilement, sélection, montée/descente d'un cran.
252
269
 
@@ -1,4 +1,4 @@
1
- import type { RenderNode } from "./render.js";
1
+ import { type RenderNode } from "./render.js";
2
2
  /**
3
3
  * Valeurs par défaut et champs autorisés par type de noeud.
4
4
  *
@@ -1,3 +1,4 @@
1
+ import { defaultLineHeight } from "./render.js";
1
2
  import { DEFAULT_ICON_STROKE_WIDTH, ICON_GRID } from "./icons/index.js";
2
3
  export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
3
4
  /** Types que l'utilisateur peut dessiner directement ; un groupe se cree a partir d'une selection. */
@@ -8,7 +9,11 @@ export const NODE_DEFAULTS = {
8
9
  line: { width: 200, height: 0, stroke: "#1a1a18", strokeWidth: 2 },
9
10
  text: {
10
11
  width: 260,
11
- height: 40,
12
+ // Une ligne : la boîte par défaut épouse le texte par défaut. Toute autre
13
+ // hauteur ferait descendre la première ligne sous le point posé, puisque le
14
+ // bloc de lignes est centré dans sa boîte. Un texte créé sans hauteur la
15
+ // recalcule de toute façon sur son propre contenu.
16
+ height: defaultLineHeight(24),
12
17
  text: "Texte",
13
18
  color: "#1a1a18",
14
19
  fontSize: 24,
@@ -16,8 +21,9 @@ export const NODE_DEFAULTS = {
16
21
  fontWeight: 400,
17
22
  fontStyle: "normal",
18
23
  textAlign: "left",
19
- // En pixels, comme x/y/fontSize — jamais un multiplicateur.
20
- lineHeight: 32,
24
+ // `lineHeight` n'a volontairement pas de valeur par défaut : figée, elle
25
+ // rendrait un texte de 64px sur un interligne de 32. Absente, elle suit la
26
+ // taille de police (fontSize x 1.35), comme l'annonce sa documentation.
21
27
  },
22
28
  // Taille de la grille de Lucide : une icone posee sans consigne rend au
23
29
  // format pour lequel son trace a ete dessine.
@@ -22,7 +22,10 @@ export const NODE_FIELD_DOCS = {
22
22
  width: "Width of the bounding box, in pixels. On a line, the horizontal delta from start to end — it may be negative. " +
23
23
  "On a group, derived from the content and read-only.",
24
24
  height: "Height of the bounding box, in pixels. On a line, the vertical delta from start to end — it may be negative. " +
25
- "On a group, derived from the content and read-only.",
25
+ "On a group, derived from the content and read-only. " +
26
+ "On a TEXT node, leave it out: the box then takes the exact height of the text (lines x lineHeight), " +
27
+ "so the first line starts at y. Set it only to centre a label inside a container — give the text node " +
28
+ "the container's y and height, and the block of lines is centred in it.",
26
29
  rotation: "Rotation in degrees, clockwise, around the centre of the bounding box. 0 = upright.",
27
30
  opacity: "Opacity, from 0 (fully transparent) to 1 (fully opaque).",
28
31
  fill: 'CSS colour filling the interior, e.g. "#ffffff" or "rgba(0,0,0,0.4)". rect, ellipse and icon. On an icon it is "none" by default — the shape is drawn by its stroke.',
@@ -42,7 +45,8 @@ export const NODE_FIELD_DOCS = {
42
45
  "node to read and to change. Whenever the same block appears more than once (cards, list rows, menu items, " +
43
46
  "buttons), build it once as a group and add instances of it instead of duplicating it. " +
44
47
  "Change this field to swap an instance over to another group; use detach_instances when one copy must diverge.",
45
- text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line.",
48
+ text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line. " +
49
+ "A box that hugs its text keeps hugging it: adding a line grows the box instead of overflowing it.",
46
50
  color: "CSS colour of the text.",
47
51
  fontSize: "Font size, in pixels (same meaning as the CSS font-size property).",
48
52
  fontFamily: 'CSS font stack, e.g. "Inter, system-ui, sans-serif".',
@@ -81,6 +81,24 @@ export declare function textMetrics(node: RenderNode): {
81
81
  };
82
82
  /** Hauteur minimale nécessaire pour afficher tout le texte du noeud. */
83
83
  export declare function measuredTextHeight(node: RenderNode): number;
84
+ /**
85
+ * Vrai quand la boîte épouse déjà exactement son texte.
86
+ *
87
+ * C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
88
+ * suit son contenu, et doit continuer à le suivre quand le texte change. Une
89
+ * hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
90
+ * cette égalité et n'est donc jamais réajustée.
91
+ */
92
+ export declare function hugsText(node: RenderNode): boolean;
93
+ /**
94
+ * Le noeud, avec la hauteur qui épouse son texte.
95
+ *
96
+ * Une boîte à la taille de son texte le place de fait en haut à gauche : le
97
+ * centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
98
+ * texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
99
+ * lui, donne une hauteur explicite.
100
+ */
101
+ export declare function withFittedTextHeight<T extends RenderNode>(node: T): T;
84
102
  /**
85
103
  * Transform SVG d'un groupe : translation vers son origine, puis rotation
86
104
  * autour du centre de son contenu (exprime dans l'espace local du groupe).
@@ -98,6 +98,28 @@ export function measuredTextHeight(node) {
98
98
  const { lineStep } = textMetrics(node);
99
99
  return Math.max(lineStep, textLines(node).length * lineStep);
100
100
  }
101
+ /**
102
+ * Vrai quand la boîte épouse déjà exactement son texte.
103
+ *
104
+ * C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
105
+ * suit son contenu, et doit continuer à le suivre quand le texte change. Une
106
+ * hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
107
+ * cette égalité et n'est donc jamais réajustée.
108
+ */
109
+ export function hugsText(node) {
110
+ return Math.abs(Math.abs(node.height) - measuredTextHeight(node)) < 0.5;
111
+ }
112
+ /**
113
+ * Le noeud, avec la hauteur qui épouse son texte.
114
+ *
115
+ * Une boîte à la taille de son texte le place de fait en haut à gauche : le
116
+ * centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
117
+ * texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
118
+ * lui, donne une hauteur explicite.
119
+ */
120
+ export function withFittedTextHeight(node) {
121
+ return { ...node, height: measuredTextHeight(node) };
122
+ }
101
123
  function shapeAttributes(node) {
102
124
  const parts = [];
103
125
  if (node.type !== "line" && node.type !== "text") {
@@ -1,5 +1,6 @@
1
1
  import { DesignNodeSchema } from "../../shared/schemas/design.schema.js";
2
2
  import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, SIZE_DERIVED_KINDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
3
+ import { hugsText, measuredTextHeight } from "../../shared/render.js";
3
4
  import { CORE_ICONS, hasIcon, iconCount } from "../../shared/icons/index.js";
4
5
  import { suggestIcons } from "../../shared/icons/search.js";
5
6
  import { shortId } from "./ids.js";
@@ -106,6 +107,14 @@ export function buildNode(input, existingIds, index) {
106
107
  const size = clampNodeSize(type, Number(draft.width), Number(draft.height));
107
108
  draft.width = size.width;
108
109
  draft.height = size.height;
110
+ // Sans hauteur demandée, la boîte d'un texte est celle de son texte : le bloc
111
+ // de lignes, centré dans une boîte à sa taille, se pose alors exactement au
112
+ // point donné — ce que l'appelant attend quand il n'a parlé que de x et y.
113
+ // Après le clamp, donc : ce plancher garde une boîte dessinée sélectionnable,
114
+ // il n'a pas à gonfler une boîte qui n'a jamais été dimensionnée à la souris.
115
+ if (type === "text" && input.height === undefined) {
116
+ draft.height = measuredTextHeight(draft);
117
+ }
109
118
  const parsed = DesignNodeSchema.safeParse(draft);
110
119
  if (!parsed.success) {
111
120
  throw new DesignError(`Invalid node: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
@@ -131,6 +140,12 @@ export function applyPatch(node, patch) {
131
140
  if (patch[field] !== undefined)
132
141
  next[field] = patch[field];
133
142
  }
143
+ // Une boîte qui épousait son texte continue de l'épouser : rallonger le texte
144
+ // d'une ligne agrandit la boîte au lieu de la faire déborder. Une hauteur
145
+ // choisie — ou redonnée dans ce patch — reste intacte.
146
+ if (node.type === "text" && patch.height === undefined && hugsText(node)) {
147
+ next.height = measuredTextHeight(next);
148
+ }
134
149
  if (node.type === "icon" && patch.icon !== undefined)
135
150
  next.icon = requireIconName(patch.icon);
136
151
  // Changer le lien echange l'instance de composant, comme un "swap instance".
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);
@@ -335,6 +336,8 @@ const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeW
335
336
  "Fields accepted per type — rect: fill, stroke, strokeWidth, radius; ellipse: fill, stroke, strokeWidth; " +
336
337
  "line: stroke, strokeWidth (the line goes from (x, y) to (x + width, y + height)); " +
337
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); " +
338
341
  "icon: icon (required — the name of the drawing), fill, stroke, strokeWidth; " +
339
342
  "instance: componentId (required — the id of the group it mirrors); nothing else, its look comes from that group. " +
340
343
  "Any field left out falls back to a sensible default, so a node can be created from its type alone.";
@@ -688,13 +691,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
688
691
  },
689
692
  {
690
693
  name: "export_design",
691
- 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.",
692
695
  inputSchema: {
693
696
  type: "object",
694
697
  properties: {
695
698
  design_id: { type: "string", description: DESIGN_ID_DOC },
696
699
  format: { type: "string", enum: [...EXPORT_FORMATS], description: "Output format (default html)." },
697
- 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 },
698
707
  },
699
708
  required: ["design_id"],
700
709
  },
@@ -703,7 +712,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
703
712
  name: "screenshot_design",
704
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. " +
705
714
  "Pass node_id to frame the capture on a single group (or any element) rather than the whole artboard. " +
706
- "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.",
707
716
  inputSchema: {
708
717
  type: "object",
709
718
  properties: {
@@ -720,10 +729,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
720
729
  type: "number",
721
730
  description: "Margin in design pixels around the captured node (default 16, ignored for a full-artboard capture).",
722
731
  },
723
- output_path: {
732
+ destination_path: {
724
733
  type: "string",
725
- 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.`,
726
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 },
727
739
  },
728
740
  required: ["design_id"],
729
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.7";
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.7';
2
+ export const APP_VERSION = '1.0.8';