@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 +20 -3
- package/dist/shared/node-defaults.d.ts +1 -1
- package/dist/shared/node-defaults.js +9 -3
- package/dist/shared/node-fields.js +6 -2
- package/dist/shared/render.d.ts +18 -0
- package/dist/shared/render.js +22 -0
- package/dist/src/core/node-factory.js +15 -0
- package/dist/src/index.js +17 -5
- package/dist/src/tools/export-design.d.ts +2 -2
- package/dist/src/tools/export-design.js +28 -9
- package/dist/src/tools/relay.d.ts +27 -0
- package/dist/src/tools/relay.js +67 -0
- package/dist/src/tools/screenshot-design.js +14 -8
- package/dist/src/tools/types.d.ts +20 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/index-DBZNXgF_.js +10 -0
- package/gui/dist/assets/{search-C-KcHHKM.js → search-CSwiX0lv.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/gui/dist/assets/index-B-q92Pfu.js +0 -10
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`. `
|
|
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, `
|
|
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,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
|
-
|
|
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
|
-
//
|
|
20
|
-
|
|
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".',
|
package/dist/shared/render.d.ts
CHANGED
|
@@ -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).
|
package/dist/shared/render.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
732
|
+
destination_path: {
|
|
724
733
|
type: "string",
|
|
725
|
-
description:
|
|
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
|
|
3
|
-
export declare function exportDesignTool(store: DesignStore, args: Record<string, unknown> | undefined):
|
|
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
|
|
14
|
-
if (
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
}
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.8";
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated version file - do not edit manually
|
|
2
|
-
export const APP_VERSION = '1.0.
|
|
2
|
+
export const APP_VERSION = '1.0.8';
|