story-theme-mcp 1.0.0

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/src/server.js ADDED
@@ -0,0 +1,1350 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { z } from 'zod';
5
+
6
+ import { loadTheme } from './theme/loader.js';
7
+ import { Catalog, normalizeText, templateTypeOf, isInternalTemplate } from './theme/catalog.js';
8
+ import { validateFile, fileKind } from './validate/template.js';
9
+ import { lintTemplate, lintSettings, sampleTexts } from './validate/design.js';
10
+ import { plainText } from './validate/richtext.js';
11
+ import { collectTexts } from './build/texts.js';
12
+ import { contrast, parseColor, round2, auditSchemes } from './validate/color.js';
13
+ import { sectionFromPreset, blockFromPreset } from './build/scaffold.js';
14
+ import { applyOperations, outline } from './build/edit.js';
15
+ import { buildFromRecipe } from './build/recipe.js';
16
+ import { localizeData } from './build/localize.js';
17
+ import { generateColorSchemes } from './build/palette.js';
18
+ import { buildStore, STYLES, BRAND_PAGES } from './build/quick.js';
19
+ import { composeSections } from './build/compose.js';
20
+ import { DIRECTIONS } from './knowledge/directions.js';
21
+ import { ProjectStore, checkRelativePath } from './project/store.js';
22
+ import { localSource } from './theme/source.js';
23
+ import { reviewProject, formatReview, findSchemeReferences } from './project/review.js';
24
+ import { exportTheme, importTheme, migrateProject } from './project/export.js';
25
+ import { GUIDES, GUIDE_TOPICS } from './knowledge/guides.js';
26
+ import { RECIPES, findRecipe } from './knowledge/recipes.js';
27
+ import { FONT_PAIRINGS, searchFonts, checkFontHandle, fontInfo, FONT_LIBRARY } from './knowledge/fonts.js';
28
+ import { ok, fail, parseContent, formatSettings, formatValidation, formatFindings } from './format.js';
29
+
30
+ export { VERSION } from './version.js';
31
+ import { VERSION } from './version.js';
32
+
33
+ const INSTRUCTIONS = `Serveur MCP du Story Thème (thème Shopify premium de New Story). Il connaît chaque section, bloc, preset et réglage du thème, et fabrique des boutiques propres et professionnelles sous forme de thème à importer dans Shopify.
34
+
35
+ Règle n°1 : AGIR D'ABORD, DEMANDER ENSUITE.
36
+ - Dès la première demande, si vous savez de quelle boutique il s'agit (un nom, ou ce qu'elle vend), appelez build_store IMMÉDIATEMENT, sans poser de question avant. Déduisez le reste (style, ton) et rédigez les textes clés de la marque dans le paramètre copy. build_store livre une V1 complète en un seul appel : couleurs, polices, accueil, fiche produit, collection, pages, traduction, zip prêt à importer.
37
+ - Une boutique qui claque a des PHOTOS. Si un connecteur Shopify est disponible, juste avant build_store : lisez les collections (image) et les produits (images), choisissez 3 à 6 belles photos (une large et lumineuse pour le héro, des photos en situation plutôt que des packshots ; notez leur largeur : celles de 1600 px ou plus vont dans images.large), copiez-les dans Contenu > Fichiers avec la mutation fileCreate (read_guide images donne la requête), puis passez leurs noms de fichiers dans images, et les handles des collections dans collections. Sans connecteur, build_store fait un héro typographique propre et n'affiche jamais de carré vide.
38
+ - Une boutique BRANDÉE : choisissez une direction artistique (build_store direction : editorial, minimal, organique, artisan, douceur, pop, energie, tech) et remplissez copy au maximum : manifesto (s'illumine au défilement), rotating_words (mots qui tournent dans le titre), benefits (cartes empilées), process (étapes), story_steps et values (page Notre histoire), usage_steps, tips et usage_faq (page Comment utiliser, adaptée au produit). Titres avec **mot fort** en gras.
39
+ - Pour aller au-delà des recettes (page ingrédients, lookbook, guide cadeau, comparatif, landing), utilisez compose_page : toutes les sections du thème sont disponibles (read_guide signatures).
40
+ - Ne retirez jamais une section sans la remplacer par une section honnête (bénéfices, FAQ, liste de produits, bandeau défilant, collections) : l'accueil garde 8 à 10 sections.
41
+ - La seule question autorisée avant d'agir : « Pour quelle boutique ? », si la demande ne donne ni nom ni activité.
42
+ - Après la V1 : résumez en quelques lignes ce qui a été fait et les choix supposés, donnez le chemin du zip, puis posez 3 à 5 questions pour affiner (celles que renvoie build_store).
43
+ - Ensuite, chaque demande du marchand s'applique au projet en cours (inutile de préciser project) : edit_template, update_theme_settings, apply_recipe, puis export_theme pour un nouveau zip.
44
+
45
+ Honnêteté : n'inventez ni avis, ni note, ni chiffres, ni presse, ni labels. Pour les conditions (livraison, retours), restez général ou reprenez celles données par le marchand ; la revue signale ce qui reste à confirmer.
46
+
47
+ Le thème vient de l'espace membre Story Thème : toujours la dernière version publiée. Si le marchand parle d'une nouvelle version, ou si une section semble manquer, appelez theme_source (update: true).
48
+
49
+ Garde-fous : aucun type de section/bloc ni identifiant de réglage inventé (get_section, get_block, search_theme donnent les vrais) ; tout fichier est validé contre les schémas du thème et un fichier invalide n'est jamais enregistré ; undo annule la dernière modification. Le MCP ne touche ni au code du thème ni à une boutique en ligne. Si un connecteur Shopify est disponible, servez-vous-en pour lire le nom de la boutique, ses collections et ses produits réels, et passez leurs handles à build_store.
50
+
51
+ Répondez au marchand dans sa langue, en vouvoyant en français, brièvement.`;
52
+
53
+ function createContext(config) {
54
+ // La source dit OÙ est le thème : un dossier local en développement, la dernière version
55
+ // publiée sur le dashboard chez un client (src/theme/source.js). Elle peut changer de dossier
56
+ // en cours de route (mise à jour du thème) : on relit `source.dir` à chaque appel.
57
+ const source = config.source || localSource(config.themeDir);
58
+ let catalog = null;
59
+ let store = null;
60
+ let loadedFrom = null;
61
+ let loadedAt = 0;
62
+ let checkedAt = 0;
63
+
64
+ const newestSchemaChange = (dir) => {
65
+ let newest = 0;
66
+ for (const folder of ['sections', 'blocks', 'config', 'locales', 'templates']) {
67
+ const full = path.join(dir, folder);
68
+ if (!fs.existsSync(full)) continue;
69
+ for (const name of fs.readdirSync(full)) {
70
+ const stat = fs.statSync(path.join(full, name));
71
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs;
72
+ }
73
+ }
74
+ return newest;
75
+ };
76
+
77
+ return {
78
+ config,
79
+ source,
80
+ get() {
81
+ const dir = source.dir || config.themeDir;
82
+ const now = Date.now();
83
+ if (catalog && dir !== loadedFrom) catalog = null;
84
+ if (catalog && now - checkedAt > 10000) {
85
+ checkedAt = now;
86
+ if (newestSchemaChange(dir) > loadedAt) catalog = null;
87
+ }
88
+ if (!catalog) {
89
+ loadedAt = Date.now();
90
+ checkedAt = loadedAt;
91
+ loadedFrom = dir;
92
+ catalog = new Catalog(loadTheme(dir));
93
+ store = new ProjectStore({ homeDir: config.homeDir, catalog });
94
+ }
95
+ return { catalog, store };
96
+ },
97
+ };
98
+ }
99
+
100
+ const run = (handler) => async (args) => {
101
+ try {
102
+ return await handler(args || {});
103
+ } catch (error) {
104
+ return fail(error.message);
105
+ }
106
+ };
107
+
108
+ const languageOf = (ctx, args, project = null) => args.language || project?.language || ctx.config.defaultLanguage;
109
+
110
+ function presetOutlines(catalog, kind, type, language) {
111
+ const entry = kind === 'section' ? catalog.section(type) : catalog.block(type);
112
+ return (entry?.schema?.presets || []).map((preset, index) => {
113
+ const name = catalog.t(preset.name, language);
114
+ try {
115
+ if (kind === 'section') {
116
+ const { id, section } = sectionFromPreset(catalog, type, { preset: index, language });
117
+ return `### Preset « ${name} » (${catalog.t(preset.name, 'en')})\n${outline(catalog, { sections: { [id]: section }, order: [id] }, { language }).split('\n').slice(1).join('\n')}`;
118
+ }
119
+ const { id, block } = blockFromPreset(catalog, type, { preset: index, language });
120
+ return `### Preset « ${name} »\n${outline(catalog, { sections: { s: { type: 'advanced-custom-section', blocks: { [id]: block }, block_order: [id] } }, order: ['s'] }, { language }).split('\n').slice(2).join('\n')}`;
121
+ } catch (error) {
122
+ return `### Preset « ${name} » : ${error.message}`;
123
+ }
124
+ });
125
+ }
126
+
127
+ function schemeIdsFor(store, slug) {
128
+ const settings = store.read(slug, 'config/settings_data.json').data;
129
+ const current = typeof settings?.current === 'string' ? settings.presets?.[settings.current] : settings?.current;
130
+ return Object.keys(current?.color_schemes || {});
131
+ }
132
+
133
+ function fileReport(catalog, store, slug, file, data, language) {
134
+ const validation = validateFile(catalog, file, data, { schemeIds: schemeIdsFor(store, slug) });
135
+ const design = file === 'config/settings_data.json' ? lintSettings(catalog, data) : lintTemplate(catalog, file, data, { language });
136
+ return { validation, design };
137
+ }
138
+
139
+ const BRIEF = z
140
+ .object({
141
+ brand: z.string().optional().describe('Nom de la marque'),
142
+ activity: z.string().optional().describe('Ce que la boutique vend'),
143
+ audience: z.string().optional().describe('Clients visés'),
144
+ positioning: z.string().optional().describe('Gamme de prix, positionnement (premium, accessible…)'),
145
+ tone: z.string().optional().describe('Ton : vouvoiement/tutoiement, 3 adjectifs'),
146
+ brand_colors: z.array(z.string()).optional().describe('Couleurs de marque en #RRGGBB'),
147
+ fonts: z.string().optional().describe('Polices imposées ou style souhaité'),
148
+ inspirations: z.string().optional().describe('Boutiques ou marques d’inspiration'),
149
+ collections: z.array(z.string()).optional().describe('Handles des collections existantes ou prévues'),
150
+ key_products: z.array(z.string()).optional().describe('Handles des produits phares'),
151
+ proofs: z.string().optional().describe('Preuves réelles disponibles : note moyenne, nombre d’avis, presse, labels, chiffres'),
152
+ shipping_returns: z.string().optional().describe('Conditions réelles : délais, seuil de livraison offerte, retours'),
153
+ offers: z.string().optional().describe('Offres réelles (paliers, lots, code de bienvenue)'),
154
+ pages: z.array(z.string()).optional().describe('Pages voulues (à propos, contact, FAQ, suivi…)'),
155
+ notes: z.string().optional(),
156
+ })
157
+ .passthrough();
158
+
159
+ const PROJECT = z.string().optional().describe('Projet (par défaut : le projet en cours)');
160
+
161
+ const COPY = z
162
+ .object({
163
+ tagline: z.string().optional().describe('Promesse du héro, 3 à 8 mots (devient le <h1> de l’accueil)'),
164
+ hero_text: z.string().optional().describe('Une phrase sous la promesse'),
165
+ hero_button: z.string().optional().describe('Bouton du héro, 2 à 4 mots'),
166
+ announcements: z.array(z.string()).optional().describe('1 à 3 messages courts de la barre d’annonce'),
167
+ reassurance: z.array(z.string()).optional().describe('3 ou 4 arguments courts (livraison, retours, fabrication…), repris dans le bandeau défilant et les listes à puces'),
168
+ brand_story_title: z.string().optional(),
169
+ brand_story_text: z.string().optional().describe('2 ou 3 phrases vraies sur la marque'),
170
+ benefits: z.array(z.object({ title: z.string(), text: z.string().optional() })).optional().describe('3 ou 4 bénéfices produits'),
171
+ faq: z.array(z.object({ question: z.string(), answer: z.string() })).optional().describe('4 à 6 questions/réponses ; conditions données par le marchand ou formulations générales'),
172
+ newsletter_title: z.string().optional(),
173
+ newsletter_text: z.string().optional(),
174
+ manifesto: z.string().optional().describe('Manifeste de marque en 1 ou 2 phrases fortes : s’illumine mot à mot au défilement (accueil et Notre histoire)'),
175
+ rotating_words: z.array(z.string()).optional().describe('3 à 5 mots qui tournent dans le titre du héro, placés à la fin du titre (ex. tenue, humeur, occasion)'),
176
+ benefits_title: z.string().optional().describe('Titre au-dessus des bénéfices (cartes empilées)'),
177
+ process: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: z.string().optional() })).optional().describe('3 à 5 étapes de fabrication ou de préparation (direction organique / artisanale)'),
178
+ process_title: z.string().optional(),
179
+ story_title: z.string().optional().describe('Titre de la page Notre histoire'),
180
+ story_intro: z.string().optional(),
181
+ story_steps: z.array(z.object({ label: z.string().optional(), title: z.string(), text: z.string().optional() })).optional().describe('Étapes réelles de l’histoire (dates ou moments) : frise de la page Notre histoire'),
182
+ values: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: z.string().optional() })).optional().describe('3 ou 4 valeurs ou engagements réels'),
183
+ usage_title: z.string().optional().describe('Titre de la page Comment utiliser, ex. « Comment porter votre chouchou »'),
184
+ usage_intro: z.string().optional(),
185
+ usage_steps: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: z.string().optional() })).optional().describe('3 à 5 étapes d’utilisation du produit, concrètes'),
186
+ tips: z.array(z.object({ title: z.string(), text: z.string().optional() })).optional().describe('3 ou 4 astuces ou idées d’usage (cartes colorées)'),
187
+ usage_faq: z.array(z.object({ question: z.string(), answer: z.string() })).optional().describe('Questions sur l’utilisation, l’entretien, la taille'),
188
+ showcase_title: z.string().optional().describe('Phrase forte du grand visuel (zoom au défilement)'),
189
+ showcase_text: z.string().optional(),
190
+ details: z.array(z.object({ title: z.string(), text: z.string() })).optional().describe('Détails produit réels : matières, utilisation, entretien (accordéons)'),
191
+ faq_intro: z.string().optional().describe('Une phrase sous le titre de la FAQ'),
192
+ })
193
+ .describe('Textes clés rédigés pour la marque, placés directement dans les pages');
194
+
195
+ const POSITION = z
196
+ .union([z.number().int(), z.enum(['start', 'end']), z.object({ before: z.string() }), z.object({ after: z.string() })])
197
+ .optional()
198
+ .describe('Position : index, "start", "end", { before: id } ou { after: id }');
199
+
200
+ const OPERATION = z
201
+ .object({
202
+ op: z.enum(['add_section', 'remove_section', 'move_section', 'duplicate_section', 'replace_section', 'update_settings', 'add_block', 'remove_block', 'move_block', 'set_disabled']),
203
+ type: z.string().optional().describe('add_section / add_block : type de section ou de bloc'),
204
+ preset: z.union([z.string(), z.number()]).optional().describe('Nom (français ou anglais) ou index du preset'),
205
+ id: z.string().optional().describe('Identifiant souhaité pour la nouvelle section / le nouveau bloc'),
206
+ section: z.string().optional().describe('remove/move/duplicate/replace_section : id de la section'),
207
+ target: z.string().optional().describe('update_settings, remove_block, move_block, set_disabled : "idSection" ou "idSection/idBloc/idSousBloc"'),
208
+ parent: z.string().optional().describe('add_block : "idSection" ou "idSection/idBloc" du conteneur'),
209
+ settings: z.record(z.string(), z.any()).optional().describe('Réglages { id: valeur } ; null supprime un réglage'),
210
+ mode: z.enum(['merge', 'replace']).optional().describe('update_settings : merge (défaut) ou replace'),
211
+ position: POSITION,
212
+ disabled: z.boolean().optional(),
213
+ data: z.record(z.string(), z.any()).optional().describe('replace_section : section complète'),
214
+ })
215
+ .passthrough();
216
+
217
+ export function createServer(config) {
218
+ const ctx = createContext(config);
219
+ const server = new McpServer({ name: 'story-theme', version: VERSION }, { instructions: INSTRUCTIONS });
220
+ const readOnly = { readOnlyHint: true, openWorldHint: false };
221
+ const writes = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
222
+
223
+ // ---------------------------------------------------------------- knowledge
224
+
225
+ server.registerTool(
226
+ 'get_started',
227
+ {
228
+ title: 'Démarrer',
229
+ description: 'START HERE. Theme version, method to build a professional store with Story Theme, the tools in order, and existing projects.',
230
+ annotations: readOnly,
231
+ },
232
+ run(() => {
233
+ const { catalog, store } = ctx.get();
234
+ const sections = catalog.sectionTypes.filter((type) => catalog.section(type).schema);
235
+ const publicBlocks = catalog.blockTypes.filter((type) => !catalog.isPrivate(type));
236
+ const projects = store.list();
237
+ const current = store.current();
238
+ return ok(
239
+ [
240
+ `# ${catalog.name} ${catalog.version}`,
241
+ current ? `Projet en cours : ${current} (les outils l'utilisent par défaut).` : 'Aucun projet en cours : build_store crée la boutique dès la première demande.',
242
+ ctx.source.info?.origin === 'dashboard'
243
+ ? `Thème : dernière version publiée sur l'espace membre${ctx.source.info.canal === 'test' ? ' (canal test)' : ''}. theme_source pour vérifier les mises à jour.`
244
+ : ctx.source.info?.origin === 'cache'
245
+ ? `Thème : cache local (${ctx.source.info.message || 'espace membre injoignable'}).`
246
+ : `Thème lu dans ${catalog.theme.dir}`,
247
+ `${sections.length} sections, ${publicBlocks.length} blocs publics et ${catalog.blockTypes.length - publicBlocks.length} blocs privés, ${RECIPES.length} recettes de pages, langues de l'éditeur : ${catalog.languages.join(', ')}.`,
248
+ '',
249
+ 'Pour une nouvelle boutique : build_store tout de suite (V1 complète en un appel), questions ensuite.',
250
+ '',
251
+ GUIDES.demarrage.body,
252
+ '',
253
+ '## Outils',
254
+ '- Connaître le thème : search_theme, list_sections, get_section, list_blocks, get_block, get_theme_settings, get_theme_template, read_guide',
255
+ '- Concevoir : recipes, generate_color_schemes, check_contrast, find_fonts',
256
+ '- Construire vite : build_store (V1 complète en un appel), compose_page (pages originales avec toutes les sections), read_guide signatures',
257
+ '- Construire : create_project, update_brief, apply_recipe, localize_template, list_texts, edit_template, write_file, update_theme_settings, read_file, exclude_template',
258
+ '- Contrôler : validate_json, review_project, undo, revert_file',
259
+ '- Livrer : export_theme (zip + check-list) ; import_theme pour auditer un thème exporté d’une boutique',
260
+ `Guides : ${GUIDE_TOPICS.join(', ')}`,
261
+ '',
262
+ `## Projets (${projects.length})`,
263
+ projects.length ? projects.map((project) => `- ${project.slug} — ${project.name} (${project.language}, modifié ${project.updated_at?.slice(0, 10)})`).join('\n') : 'Aucun projet pour le moment.',
264
+ ].join('\n'),
265
+ );
266
+ }),
267
+ );
268
+
269
+ server.registerTool(
270
+ 'theme_source',
271
+ {
272
+ title: 'Source du thème',
273
+ description:
274
+ 'Which Story Theme version the MCP is working on and where it comes from (member area dashboard, local cache, or local folder). `update: true` asks the member area for the latest published version and downloads it if it changed. Use it when the merchant mentions a new theme version, or when a section seems missing.',
275
+ inputSchema: {
276
+ update: z.boolean().optional().describe('Interroger l’espace membre maintenant et télécharger la dernière version publiée si elle a changé'),
277
+ force: z.boolean().optional().describe('Retélécharger la version même si elle est déjà en cache (cache abîmé)'),
278
+ },
279
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
280
+ },
281
+ run(async ({ update = false, force = false }) => {
282
+ const source = ctx.source;
283
+ let changed = false;
284
+ let erreur = null;
285
+ if (update || force) {
286
+ try {
287
+ const resultat = await source.ensure({ force, timeoutMs: 15000 });
288
+ changed = Boolean(resultat.changed);
289
+ } catch (error) {
290
+ erreur = error.message;
291
+ }
292
+ }
293
+ const { catalog } = ctx.get();
294
+ const info = source.info || {};
295
+ const origine = {
296
+ dashboard: 'dernière version publiée sur l’espace membre Story Thème',
297
+ cache: 'cache local (espace membre injoignable)',
298
+ local: 'dossier local (STORY_THEME_DIR)',
299
+ }[info.origin] || 'dossier local';
300
+
301
+ return ok(
302
+ [
303
+ `# ${catalog.name} ${catalog.version}`,
304
+ `Origine : ${origine}`,
305
+ info.version && info.version !== catalog.version ? `Version annoncée par l’espace membre : ${info.version}` : null,
306
+ info.canal === 'test' ? 'Canal : version EN TEST (compte testeur).' : null,
307
+ `Dossier : ${info.dir || config.themeDir}`,
308
+ info.message,
309
+ changed ? `✅ Thème mis à jour en ${info.version} : les sections et réglages connus du MCP viennent de cette version.` : null,
310
+ // Une vérification qui retombe sur le cache n'est pas une vérification : dire « déjà
311
+ // à jour » alors que l'espace membre n'a pas répondu ferait chercher ailleurs.
312
+ update && !changed && !erreur && info.origin === 'cache'
313
+ ? '⚠️ Impossible de vérifier : le MCP travaille sur le thème en cache. Réessayez connecté.'
314
+ : null,
315
+ update && !changed && !erreur && info.origin !== 'cache' ? 'Déjà à jour : aucune version plus récente publiée.' : null,
316
+ erreur ? `⚠️ Mise à jour impossible : ${erreur}` : null,
317
+ !config.token && !config.themeDirExplicit
318
+ ? '\nAucun jeton configuré : ajoutez STORY_MCP_TOKEN (espace membre Story Thème > MCP & Appli) pour suivre automatiquement les versions publiées.'
319
+ : null,
320
+ ]
321
+ .filter(Boolean)
322
+ .join('\n'),
323
+ );
324
+ }),
325
+ );
326
+
327
+ server.registerTool(
328
+ 'read_guide',
329
+ {
330
+ title: 'Lire un guide',
331
+ description: `Read a Story Theme guide (French). Topics: ${GUIDE_TOPICS.join(', ')}.`,
332
+ inputSchema: { topic: z.enum(GUIDE_TOPICS) },
333
+ annotations: readOnly,
334
+ },
335
+ run(({ topic }) => ok(`# ${GUIDES[topic].title}\n\n${GUIDES[topic].body}`)),
336
+ );
337
+
338
+ server.registerTool(
339
+ 'search_theme',
340
+ {
341
+ title: 'Chercher dans le thème',
342
+ description: 'Full-text search (French or English, accents optional) across sections, blocks, presets, settings, global settings, guides and recipes. Use it to find how the theme does something ("compte à rebours", "livraison offerte", "avant après").',
343
+ inputSchema: {
344
+ query: z.string().min(2),
345
+ kind: z.enum(['any', 'section', 'block', 'setting', 'global_setting', 'guide', 'recipe']).optional(),
346
+ limit: z.number().int().min(1).max(60).optional(),
347
+ },
348
+ annotations: readOnly,
349
+ },
350
+ run(({ query, kind = 'any', limit = 20 }) => {
351
+ const { catalog } = ctx.get();
352
+ const extra = [
353
+ ...GUIDE_TOPICS.map((topic) => ({ kind: 'guide', key: topic, title: GUIDES[topic].title, titleText: normalizeText(GUIDES[topic].title), text: normalizeText(GUIDES[topic].body) })),
354
+ ...RECIPES.map((recipe) => ({ kind: 'recipe', key: recipe.id, title: recipe.title, titleText: normalizeText(recipe.title), text: normalizeText(`${recipe.for} ${recipe.why} ${recipe.steps.map((step) => `${step.type} ${step.preset} ${step.role}`).join(' ')}`) })),
355
+ ];
356
+ const results = catalog.search(query, { kinds: kind === 'any' ? null : [kind], limit, extra });
357
+ if (!results.length) return ok(`Aucun résultat pour « ${query} ». Essayez un synonyme, ou list_sections / list_blocks.`);
358
+ return ok(results.map((result) => `- [${result.kind}] ${result.key} — ${result.title}`).join('\n'));
359
+ }),
360
+ );
361
+
362
+ server.registerTool(
363
+ 'list_sections',
364
+ {
365
+ title: 'Lister les sections',
366
+ description: 'List the sections you can place, with their presets and what they are for. Filter by template type (index, product, collection, page…) or section group (header, footer).',
367
+ inputSchema: {
368
+ template: z.string().optional().describe('Type de modèle : index, product, collection, page, cart, search, blog, article, 404, password, list-collections, customers/account…'),
369
+ group: z.string().optional().describe('Groupe de sections : header, footer'),
370
+ language: z.string().optional(),
371
+ },
372
+ annotations: readOnly,
373
+ },
374
+ run(({ template, group, language }) => {
375
+ const { catalog } = ctx.get();
376
+ const lang = languageOf(ctx, { language });
377
+ const placement = template ? { template } : group ? { group } : null;
378
+ const byCategory = new Map();
379
+ for (const type of catalog.sectionTypes) {
380
+ const entry = catalog.section(type);
381
+ if (!entry.schema) continue;
382
+ if (placement && !catalog.sectionAllowedIn(entry.schema, placement)) continue;
383
+ const summary = catalog.sectionSummary(type, lang);
384
+ const category = summary.presets[0]?.category || (type.startsWith('main-') ? 'Pages principales' : 'Autres');
385
+ if (!byCategory.has(category)) byCategory.set(category, []);
386
+ const presets = summary.presets.map((preset) => preset.name).join(' ; ');
387
+ byCategory.get(category).push(`- ${type} — ${summary.name}${presets ? ` · presets : ${presets}` : ' · sans preset'}${summary.use_for ? ` · ${summary.use_for}` : ''}`);
388
+ }
389
+ const lines = [`Sections${placement ? ` autorisées (${template ? `modèle ${template}` : `groupe ${group}`})` : ''} :`];
390
+ for (const [category, items] of byCategory) lines.push(`\n## ${category}\n${items.join('\n')}`);
391
+ lines.push('\nDétail, réglages et contenu des presets : get_section.');
392
+ return ok(lines.join('\n'));
393
+ }),
394
+ );
395
+
396
+ server.registerTool(
397
+ 'get_section',
398
+ {
399
+ title: 'Détail d’une section',
400
+ description: 'Everything about one section: purpose, where it is allowed, accepted blocks, static blocks, every preset as a block tree (in the chosen language), and its settings (ids, types, options, ranges, defaults). Read this before adding or editing a section.',
401
+ inputSchema: {
402
+ type: z.string(),
403
+ detail: z.enum(['summary', 'presets', 'settings', 'full']).optional().describe('summary, presets, settings, ou full (défaut)'),
404
+ language: z.string().optional(),
405
+ },
406
+ annotations: readOnly,
407
+ },
408
+ run(({ type, detail = 'full', language }) => {
409
+ const { catalog } = ctx.get();
410
+ const lang = languageOf(ctx, { language });
411
+ const summary = catalog.sectionSummary(type, lang);
412
+ if (!summary) {
413
+ const hint = catalog.search(type, { kinds: ['section'], limit: 5 }).map((result) => result.key);
414
+ throw new Error(`section « ${type} » inconnue${hint.length ? ` (proches : ${hint.join(', ')})` : ''}. list_sections donne la liste.`);
415
+ }
416
+ const lines = [`# ${summary.name} (${type})`];
417
+ if (summary.description) lines.push(summary.description);
418
+ if (summary.use_for) lines.push(`Quand l’utiliser : ${summary.use_for}`);
419
+ lines.push(`Placement : ${summary.placement}${summary.limit ? ` · ${summary.limit} par page maximum` : ''}`);
420
+ lines.push(`Blocs acceptés : ${summary.children}`);
421
+ if (summary.static_blocks.length) lines.push(`Blocs statiques (id imposé) : ${summary.static_blocks.map((entry) => `${entry.type}#${entry.id}`).join(', ')}`);
422
+ if (detail === 'summary') {
423
+ lines.push(`Presets : ${summary.presets.map((preset) => `${preset.name} (${preset.name_en})`).join(' ; ') || 'aucun'}`);
424
+ return ok(lines.join('\n'));
425
+ }
426
+ if (detail === 'full' || detail === 'presets') {
427
+ const outlines = presetOutlines(catalog, 'section', type, lang);
428
+ lines.push('', outlines.length ? outlines.join('\n\n') : 'Aucun preset : partez d’un modèle du thème (get_theme_template) ou composez les blocs.');
429
+ }
430
+ if (detail === 'full' || detail === 'settings') {
431
+ lines.push('', `## Réglages de la section (${summary.settings_count})`, formatSettings(catalog.normalizeSettings(catalog.section(type).schema.settings, lang)));
432
+ }
433
+ return ok(lines.join('\n'));
434
+ }),
435
+ );
436
+
437
+ server.registerTool(
438
+ 'list_blocks',
439
+ {
440
+ title: 'Lister les blocs',
441
+ description: 'List theme blocks. With `parent` (a section or block type), lists exactly what that parent accepts (public @theme blocks and its private blocks).',
442
+ inputSchema: {
443
+ parent: z.string().optional().describe('Type de section ou de bloc parent'),
444
+ include_private: z.boolean().optional().describe('Inclure les blocs privés (préfixe _) sans parent donné'),
445
+ language: z.string().optional(),
446
+ },
447
+ annotations: readOnly,
448
+ },
449
+ run(({ parent, include_private = false, language }) => {
450
+ const { catalog } = ctx.get();
451
+ const lang = languageOf(ctx, { language });
452
+ let types;
453
+ let heading;
454
+ if (parent) {
455
+ const entry = catalog.section(parent) || catalog.block(parent);
456
+ if (!entry?.schema) throw new Error(`« ${parent} » n'est ni une section ni un bloc du thème`);
457
+ const policy = catalog.childPolicy(entry.schema);
458
+ types = catalog.acceptedChildTypes(entry.schema);
459
+ heading = `Blocs acceptés par « ${parent} »${policy.app ? ' (+ blocs d’applications)' : ''}${entry.statics.length ? ` · statiques : ${entry.statics.map((item) => `${item.type}#${item.id}`).join(', ')}` : ''}`;
460
+ } else {
461
+ types = catalog.blockTypes.filter((type) => include_private || !catalog.isPrivate(type));
462
+ heading = include_private ? 'Tous les blocs' : 'Blocs publics (utilisables dans toute section qui accepte @theme)';
463
+ }
464
+ const byCategory = new Map();
465
+ for (const type of types) {
466
+ const summary = catalog.blockSummary(type, lang);
467
+ if (!summary) continue;
468
+ const category = summary.private ? 'Blocs privés' : summary.category || 'Autres';
469
+ if (!byCategory.has(category)) byCategory.set(category, []);
470
+ byCategory.get(category).push(`- ${type} — ${summary.name}${summary.presets.length > 1 ? ` · presets : ${summary.presets.join(' ; ')}` : ''}${summary.children ? ' · contient des blocs' : ''}${summary.description ? ` · ${summary.description}` : ''}`);
471
+ }
472
+ const lines = [heading];
473
+ for (const [category, items] of byCategory) lines.push(`\n## ${category}\n${items.join('\n')}`);
474
+ return ok(lines.join('\n'));
475
+ }),
476
+ );
477
+
478
+ server.registerTool(
479
+ 'get_block',
480
+ {
481
+ title: 'Détail d’un bloc',
482
+ description: 'Everything about one block: purpose, which parents accept it, its children, presets as trees, and every setting (ids, types, options, ranges, defaults).',
483
+ inputSchema: { type: z.string(), detail: z.enum(['summary', 'settings', 'full']).optional(), language: z.string().optional() },
484
+ annotations: readOnly,
485
+ },
486
+ run(({ type, detail = 'full', language }) => {
487
+ const { catalog } = ctx.get();
488
+ const lang = languageOf(ctx, { language });
489
+ const summary = catalog.blockSummary(type, lang);
490
+ if (!summary) {
491
+ const hint = catalog.search(type, { kinds: ['block'], limit: 5 }).map((result) => result.key);
492
+ throw new Error(`bloc « ${type} » inconnu${hint.length ? ` (proches : ${hint.join(', ')})` : ''}`);
493
+ }
494
+ const parents = catalog.parentsOf(type);
495
+ const explicit = parents.filter((parent) => parent.via === 'explicit').map((parent) => `${parent.kind === 'section' ? 'section' : 'bloc'} ${parent.type}`);
496
+ const lines = [`# ${summary.name} (${type})${summary.private ? ' — bloc privé' : ''}`];
497
+ if (summary.description) lines.push(summary.description);
498
+ lines.push(summary.private ? `Parents : ${explicit.join(', ') || 'aucun (bloc statique rendu par le code)'}` : `Parents : toute section ou tout bloc qui accepte @theme (${parents.length} au total)${explicit.length ? ` ; déclaré explicitement par ${explicit.join(', ')}` : ''}`);
499
+ if (summary.children) lines.push(`Blocs enfants : ${summary.children}`);
500
+ if (summary.static_blocks.length) lines.push(`Blocs statiques : ${summary.static_blocks.map((entry) => `${entry.type}#${entry.id}`).join(', ')}`);
501
+ if (detail !== 'settings') {
502
+ const outlines = presetOutlines(catalog, 'block', type, lang);
503
+ if (outlines.length) lines.push('', outlines.join('\n\n'));
504
+ }
505
+ if (detail !== 'summary') lines.push('', `## Réglages (${summary.settings_count})`, formatSettings(catalog.normalizeSettings(catalog.block(type).schema.settings, lang)));
506
+ return ok(lines.join('\n'));
507
+ }),
508
+ );
509
+
510
+ server.registerTool(
511
+ 'get_theme_settings',
512
+ {
513
+ title: 'Réglages du thème',
514
+ description: 'Global theme settings (config/settings_schema.json): colours, typography, buttons, radius, cards, cart, social links… With `project`, also shows that project’s current values.',
515
+ inputSchema: {
516
+ group: z.string().optional().describe('Filtrer un groupe (ex. Typographie, Boutons, Panier)'),
517
+ project: z.string().optional(),
518
+ language: z.string().optional(),
519
+ },
520
+ annotations: readOnly,
521
+ },
522
+ run(({ group, project, language }) => {
523
+ const { catalog, store } = ctx.get();
524
+ const lang = languageOf(ctx, { language });
525
+ if (project) store.meta(project);
526
+ const current = project ? store.read(project, 'config/settings_data.json').data?.current : catalog.theme.settingsData?.current;
527
+ const lines = [];
528
+ for (const entry of catalog.theme.settingsSchema) {
529
+ if (entry.name === 'theme_info') continue;
530
+ const name = catalog.t(entry.name, lang);
531
+ if (group && !normalizeText(name).includes(normalizeText(group)) && !normalizeText(catalog.t(entry.name, 'en')).includes(normalizeText(group))) continue;
532
+ lines.push(`\n## ${name}`);
533
+ const settings = catalog.normalizeSettings(entry.settings, lang).map((setting) => {
534
+ if (setting.type === 'color_scheme_group') return { ...setting, definition: undefined, info: `palettes : ${Object.keys(current?.color_schemes || {}).join(', ')} — rôles : ${setting.definition.map((item) => item.id).join(', ')}` };
535
+ return setting;
536
+ });
537
+ lines.push(formatSettings(settings));
538
+ if (current) {
539
+ const values = settings.filter((setting) => current[setting.id] !== undefined && typeof current[setting.id] !== 'object').map((setting) => `${setting.id}=${JSON.stringify(current[setting.id])}`);
540
+ if (values.length) lines.push(` valeurs ${project ? 'du projet' : 'du thème'} : ${values.join(', ')}`);
541
+ }
542
+ }
543
+ if (!lines.length) throw new Error(`aucun groupe ne correspond à « ${group} »`);
544
+ return ok(lines.join('\n').trim());
545
+ }),
546
+ );
547
+
548
+ server.registerTool(
549
+ 'get_theme_template',
550
+ {
551
+ title: 'Modèle du thème',
552
+ description: 'A template or section group as shipped with the theme (reference compositions). Without `file`, lists them.',
553
+ inputSchema: { file: z.string().optional().describe('ex. templates/product.json, sections/footer-group.json'), format: z.enum(['outline', 'json']).optional(), language: z.string().optional() },
554
+ annotations: readOnly,
555
+ },
556
+ run(({ file, format = 'outline', language }) => {
557
+ const { catalog } = ctx.get();
558
+ const all = [...catalog.theme.templates.keys(), ...catalog.theme.groups.keys()].filter((rel) => !isInternalTemplate(rel));
559
+ if (!file) return ok(`Modèles du thème :\n${all.map((rel) => `- ${rel}`).join('\n')}`);
560
+ const data = catalog.theme.templates.get(file) || catalog.theme.groups.get(file);
561
+ if (!data) throw new Error(`« ${file} » n'existe pas dans le thème. Modèles : ${all.join(', ')}`);
562
+ if (format === 'json') return ok(JSON.stringify(data, null, 1));
563
+ return ok(`${outline(catalog, data, { language: languageOf(ctx, { language }), file })}\n\n(textes en anglais : ce sont les exemples livrés avec le thème)`);
564
+ }),
565
+ );
566
+
567
+ // ---------------------------------------------------------------- design
568
+
569
+ server.registerTool(
570
+ 'recipes',
571
+ {
572
+ title: 'Recettes de pages',
573
+ description: 'Ready-made page compositions (home pages by business type, product pages, collection, about, contact, FAQ, tracking, landing) built only from theme presets. Without `id`: the list. With `id`: the steps and what each section must say.',
574
+ inputSchema: { id: z.string().optional(), template: z.string().optional().describe('Filtrer par fichier ou type (index, product, page…)') },
575
+ annotations: readOnly,
576
+ },
577
+ run(({ id, template }) => {
578
+ if (id) {
579
+ const recipe = findRecipe(id);
580
+ if (!recipe) throw new Error(`recette « ${id} » inconnue (recettes : ${RECIPES.map((item) => item.id).join(', ')})`);
581
+ return ok([
582
+ `# ${recipe.title} (${recipe.id}) → ${recipe.template}`,
583
+ `Pour : ${recipe.for}`,
584
+ `Pourquoi : ${recipe.why}`,
585
+ recipe.base ? `Base : ${recipe.base} du thème, sections ajoutées ensuite.` : '',
586
+ '',
587
+ ...recipe.steps.map((step, index) => `${index + 1}. ${step.type} · preset « ${step.preset} »${step.settings ? ` · ${JSON.stringify(step.settings)}` : ''}${step.blocks ? ` · + ${step.blocks.map((block) => block.type).join(', ')}` : ''}\n → ${step.role}`),
588
+ '',
589
+ 'apply_recipe construit ce modèle dans un projet ; réécrivez ensuite chaque texte pour la marque.',
590
+ ].filter((line) => line !== '').join('\n'));
591
+ }
592
+ const list = RECIPES.filter((recipe) => !template || recipe.template.includes(template));
593
+ return ok(list.map((recipe) => `- ${recipe.id} → ${recipe.template} · ${recipe.title} : ${recipe.for}`).join('\n'));
594
+ }),
595
+ );
596
+
597
+ server.registerTool(
598
+ 'generate_color_schemes',
599
+ {
600
+ title: 'Générer les palettes',
601
+ description: 'Builds the four colour schemes the theme uses (background-1, background-2, inverse, scheme), the global palette and button palettes from 1–2 brand colours. Every text/button pair is pushed to WCAG AA; brand colour adjustments are reported. Pass the result to update_theme_settings.',
602
+ inputSchema: {
603
+ primary: z.string().describe('Couleur principale #RRGGBB'),
604
+ secondary: z.string().optional().describe('Couleur secondaire #RRGGBB'),
605
+ background: z.string().optional().describe('Fond clair imposé #RRGGBB (ex. crème)'),
606
+ neutral: z.enum(['tinted', 'warm', 'cool', 'pure']).optional().describe('Teinte des neutres : tinted (teintée par la marque, défaut), warm (crème), cool, pure (gris neutres)'),
607
+ mood: z.enum(['light', 'dark']).optional().describe('light (défaut) ou dark (boutique sombre)'),
608
+ },
609
+ annotations: readOnly,
610
+ },
611
+ run((args) => {
612
+ const result = generateColorSchemes(args);
613
+ return ok([
614
+ `Palettes générées${result.adjustments.length ? ' avec ajustements :\n' + result.adjustments.map((item) => `- ${item}`).join('\n') : ' (aucun ajustement nécessaire)'}`,
615
+ result.audit.length ? `Points de vigilance :\n${result.audit.map((item) => `- ${item.scheme} : ${item.message}`).join('\n')}` : 'Tous les contrastes passent AA.',
616
+ `Usage :\n${Object.entries(result.usage).map(([id, usage]) => `- ${id} : ${usage}`).join('\n')}`,
617
+ '',
618
+ 'À appliquer avec update_theme_settings :',
619
+ JSON.stringify({ color_schemes: result.color_schemes, palette: result.palette, settings: result.button_palettes }, null, 1),
620
+ ].join('\n'));
621
+ }),
622
+ );
623
+
624
+ server.registerTool(
625
+ 'check_contrast',
626
+ {
627
+ title: 'Vérifier un contraste',
628
+ description: 'WCAG contrast ratio between two colours, with AA/AAA verdicts for body text and large text.',
629
+ inputSchema: { foreground: z.string(), background: z.string() },
630
+ annotations: readOnly,
631
+ },
632
+ run(({ foreground, background }) => {
633
+ if (!parseColor(foreground) || !parseColor(background)) throw new Error('couleurs attendues au format #RRGGBB');
634
+ const ratio = contrast(foreground, background);
635
+ const verdict = (value, target) => (value >= target ? '✅' : '❌');
636
+ return ok(`${foreground} sur ${background} : ${round2(ratio)}:1\n- Texte courant AA (4,5) ${verdict(ratio, 4.5)} · AAA (7) ${verdict(ratio, 7)}\n- Grands titres AA (3) ${verdict(ratio, 3)} · AAA (4,5) ${verdict(ratio, 4.5)}`);
637
+ }),
638
+ );
639
+
640
+ server.registerTool(
641
+ 'find_fonts',
642
+ {
643
+ title: 'Polices',
644
+ description: 'Fonts from the Shopify font library (the only ones a font_picker accepts). No args: tested pairings by style. `query`: search families. `handle`: check one handle (unknown or deprecated).',
645
+ inputSchema: { query: z.string().optional(), handle: z.string().optional() },
646
+ annotations: readOnly,
647
+ },
648
+ run(({ query, handle }) => {
649
+ if (handle) {
650
+ const problems = checkFontHandle(handle);
651
+ const info = fontInfo(handle);
652
+ return ok(problems.length ? problems.map((problem) => `${problem.level === 'error' ? '❌' : '⚠️'} ${problem.message}`).join('\n') : `✅ ${handle} : ${info.family}${info.alternative ? ` (police sous licence Monotype ; alternative libre : ${info.alternative})` : ''}`);
653
+ }
654
+ if (query) {
655
+ const found = searchFonts(query);
656
+ if (!found.length) return ok(`Aucune famille « ${query} » dans la bibliothèque Shopify (${FONT_LIBRARY.available.length} familles). Si c'est une police dépréciée, find_fonts avec handle donne sa remplaçante.`);
657
+ return ok(found.map((family) => `- ${family.family} : ${family.handles.join(' ')}${family.licensed ? ` (sous licence ; alternative libre ${family.free_alternative})` : ''}`).join('\n') + '\nSuffixe : n = normal, i = italique, chiffre = graisse (4 = regular, 6 = semi-bold, 7 = bold).');
658
+ }
659
+ return ok(['Paires vérifiées dans la bibliothèque Shopify (headings_font / body_font) :', ...FONT_PAIRINGS.map((pair) => `- ${pair.id} — ${pair.style} : ${pair.heading} / ${pair.body}. ${pair.note}`), '', 'Autre famille : find_fonts avec query.'].join('\n'));
660
+ }),
661
+ );
662
+
663
+ server.registerTool(
664
+ 'validate_json',
665
+ {
666
+ title: 'Valider un JSON',
667
+ description: 'Validates a template, section group or settings_data against the real theme schemas (types, blocks allowed, setting ids and values, limits, rich text), plus a design review. No project needed.',
668
+ inputSchema: {
669
+ file: z.string().describe('Chemin du fichier dans le thème, ex. templates/index.json, templates/page.contact.json, sections/header-group.json, config/settings_data.json'),
670
+ content: z.union([z.string(), z.record(z.string(), z.any())]).describe('Le JSON (objet ou texte)'),
671
+ language: z.string().optional(),
672
+ },
673
+ annotations: readOnly,
674
+ },
675
+ run(({ file, content, language }) => {
676
+ const { catalog } = ctx.get();
677
+ const rel = checkRelativePath(file);
678
+ const data = parseContent(content);
679
+ const validation = validateFile(catalog, rel, data);
680
+ const design = rel === 'config/settings_data.json' ? lintSettings(catalog, data) : lintTemplate(catalog, rel, data, { language: languageOf(ctx, { language }) });
681
+ return ok(`${formatValidation(validation)}\n\n${formatFindings(design)}`);
682
+ }),
683
+ );
684
+
685
+ // ---------------------------------------------------------------- projects
686
+
687
+ server.registerTool(
688
+ 'build_store',
689
+ {
690
+ title: 'Construire la V1 d’une boutique',
691
+ description: `FIRST CALL for a new store, without asking questions first. Builds a complete first version in one call and exports the zip: colour schemes from the brand colour (or the style), font pairing, home page (recipe picked from the style), product page, collection page, about/contact/FAQ/tracking pages, the rest of the theme translated, demo templates removed, and the brand copy you pass in \`copy\` placed in the pages. Reuses the project if it already exists (undo restores). Returns the choices made and the questions to ask the merchant next. Styles: ${Object.entries(STYLES).map(([id, style]) => `${id} (${style.label})`).join(', ')}.`,
692
+ inputSchema: {
693
+ name: z.string().min(2).describe('Nom de la boutique ou de la marque'),
694
+ activity: z.string().optional().describe('Ce qu’elle vend, en une phrase'),
695
+ style: z.enum(Object.keys(STYLES)).optional().describe('Déduit de l’activité si absent'),
696
+ primary_color: z.string().optional().describe('Couleur de marque #RRGGBB'),
697
+ secondary_color: z.string().optional(),
698
+ background_color: z.string().optional().describe('Fond clair imposé (ex. crème)'),
699
+ dark: z.boolean().optional().describe('Boutique sur fond sombre'),
700
+ fonts: z.enum(FONT_PAIRINGS.map((pair) => pair.id)).optional().describe('Paire de polices (sinon celle du style)'),
701
+ home: z.enum(RECIPES.filter((recipe) => recipe.template === 'templates/index.json').map((recipe) => recipe.id)).optional().describe('Recette d’accueil (sinon celle du style)'),
702
+ pages: z.array(z.enum(['a-propos', 'utilisation', 'contact', 'faq', 'suivi'])).optional().describe('Pages à créer (toutes par défaut) : a-propos = Notre histoire, utilisation = Comment utiliser'),
703
+ direction: z.enum(Object.keys(DIRECTIONS)).optional().describe(`Direction artistique : ${Object.entries(DIRECTIONS).map(([id, direction]) => `${id} (${direction.label} : ${direction.for})`).join(' ; ')}. Déduite du style si absente`),
704
+ offers: z.boolean().optional().describe('Fiche produit avec offres par paliers'),
705
+ collection: z.string().optional().describe('Handle de la collection principale (bouton du héro, meilleures ventes)'),
706
+ collections: z.array(z.string()).optional().describe('Handles des collections à montrer (la première = principale). Sans eux, les listes montrent toutes les collections'),
707
+ images: z
708
+ .object({
709
+ hero: z.string().optional().describe('Photo du héro, large et nette (1600 px de large ou plus ; nom du fichier dans Contenu > Fichiers, ex. darly-hero.jpg)'),
710
+ hero_mobile: z.string().optional().describe('Photo du héro en format portrait pour le mobile'),
711
+ story: z.array(z.string()).optional().describe('1 à 3 photos pour l’histoire de la marque'),
712
+ lifestyle: z.array(z.string()).optional().describe('Photos d’ambiance (visuels des sections)'),
713
+ large: z.array(z.string()).optional().describe('Parmi ces photos, celles qui font 1600 px de large ou plus : seules elles vont en plein écran (zoom au défilement, héro des pages de marque). Sans elles, pas de zoom et héro typographique sur les pages de marque, plutôt qu’une photo floue'),
714
+ })
715
+ .optional()
716
+ .describe('Photos déjà présentes dans Contenu > Fichiers. Sans photo : héro typographique, aucun carré vide'),
717
+ proofs: z
718
+ .object({
719
+ rating: z.number().optional().describe('Note moyenne réelle sur 5'),
720
+ testimonials: z.array(z.object({ name: z.string(), text: z.string(), role: z.string().optional() })).optional().describe('Vrais avis clients'),
721
+ figures: z.array(z.object({ value: z.string(), label: z.string() })).optional().describe('Chiffres réels et vérifiables, ex. {120}K+'),
722
+ })
723
+ .optional()
724
+ .describe('Uniquement des preuves réelles. Sans elles, les sections d’avis et de chiffres sont remplacées par des sections factuelles'),
725
+ shop_domain: z.string().optional().describe('Boutique Shopify (xxx.myshopify.com), pour mémoire'),
726
+ language: z.string().optional(),
727
+ copy: COPY.optional(),
728
+ },
729
+ annotations: writes,
730
+ },
731
+ run((input) => {
732
+ const { catalog, store } = ctx.get();
733
+ const result = buildStore({ catalog, store }, input);
734
+ const exported = exportTheme({ catalog, store, slug: result.slug });
735
+ const review = reviewProject(catalog, store, result.slug);
736
+ const own = review.perFile.filter((entry) => entry.origin === 'project');
737
+ const toWrite = own.reduce((total, entry) => total + entry.design.filter((finding) => finding.level === 'important').length, 0);
738
+ const placed = Object.values(result.written).reduce((total, count) => total + count, 0);
739
+ const questions = [];
740
+ if (!result.hasImages) questions.push('Je récupère les photos de votre boutique (collections, produits) pour le héro et les grandes sections ? C’est ce qui fera la différence visuelle.');
741
+ if (!input.collection && !input.collections?.length) questions.push('Quelle est votre collection principale (ou vos meilleures ventes) ? Je relie le bouton d’accueil et la liste de produits.');
742
+ if (!input.proofs?.testimonials?.length) questions.push('Avez-vous des avis clients réels (note, quelques citations) ? J’ajouterai une section d’avis.');
743
+ questions.push('Vos conditions exactes : délais de livraison, livraison offerte à partir de combien, retours sous combien de jours ?');
744
+ if (!input.primary_color) questions.push('Avez-vous une couleur de marque et un logo ?');
745
+ if (!input.offers) questions.push('Proposez-vous des lots ou des remises par quantité ?');
746
+ return ok([
747
+ `✅ V1 de « ${input.name} » ${result.existed ? 'reconstruite' : 'construite'} — projet ${result.slug} (projet en cours).`,
748
+ `Choix faits (à confirmer) : style ${result.style} ; ${result.choices.join(' ; ')}.`,
749
+ result.signatures.length ? `Moments signature de l’accueil : ${result.signatures.join(', ')}.` : 'Aucun moment signature : donnez manifesto, rotating_words, benefits (3+) ou process dans copy.',
750
+ result.brandPages.length ? `Pages de marque : ${result.brandPages.map((file) => file.replace('templates/', '')).join(', ')} (à attribuer à une page Shopify via « Modèle de thème »).` : '',
751
+ ...result.notes.map((note) => `- ${note}`),
752
+ `Accueil : ${result.sectionsPerFile['templates/index.json']} sections${result.hasImages ? '' : ' (sans photo : héro typographique, aucun carré vide)'}.`,
753
+ `Textes de la marque placés : ${placed}${result.translated ? ` · textes du thème traduits : ${result.translated}` : ''} · textes d’exemple encore à personnaliser : ${toWrite} (list_texts puis edit_template).`,
754
+ exported.ok ? `Zip : ${exported.zip}\nÀ importer dans Shopify : Boutique en ligne > Thèmes > Ajouter un thème > Importer un fichier zip (sans publier), puis Personnaliser pour voir le rendu.` : `Export impossible : ${exported.message}`,
755
+ '',
756
+ 'Questions à poser maintenant pour affiner (pas plus de 5) :',
757
+ ...questions.slice(0, 5).map((question) => `- ${question}`),
758
+ ].join('\n'));
759
+ }),
760
+ );
761
+
762
+ const SECTION_SPEC = z
763
+ .object({
764
+ type: z.string().describe('Type de section (list_sections)'),
765
+ preset: z.union([z.string(), z.number()]).optional().describe('Preset de départ (get_section)'),
766
+ id: z.string().optional(),
767
+ palette: z.string().optional().describe('Palette du fond : background-1, background-2, inverse, scheme'),
768
+ separator: z.object({ top: z.string().optional(), bottom: z.string().optional(), animation: z.string().optional(), flip: z.boolean().optional() }).optional().describe('Formes : curve, slope, wave, waves, organic, tilt, peak, zigzag, scallop, clouds, mountains, torn, drip, fade, line ; animation : flow, swell, grow, parallax'),
769
+ image: z.string().optional().describe('Photo de la section (héro, zoom) : nom du fichier dans Contenu > Fichiers'),
770
+ image_mobile: z.string().optional(),
771
+ images: z.array(z.string()).optional().describe('Photos des blocs image de la section, dans l’ordre (les blocs image sans photo sont retirés)'),
772
+ settings: z.record(z.string(), z.any()).optional().describe('Réglages bruts de la section'),
773
+ content: z
774
+ .object({
775
+ eyebrow: z.string().optional().describe('Sur-titre'),
776
+ title: z.string().optional().describe('Titre ; **mot** = mot en gras (coloré par le style de titre)'),
777
+ title_tag: z.enum(['h1', 'h2', 'p']).optional(),
778
+ rotating_words: z.array(z.string()).optional(),
779
+ text: z.string().optional().describe('Texte ; ligne vide = nouveau paragraphe ; "" retire le texte d’exemple'),
780
+ buttons: z.array(z.object({ label: z.string(), link: z.string().optional(), style: z.string().optional() })).optional().describe('[] retire les boutons'),
781
+ manifesto: z.string().optional().describe('Remplace le contenu par un texte révélé au défilement'),
782
+ lines: z.array(z.string()).optional().describe('Lignes d’un bandeau défilant ou d’une liste à puces'),
783
+ items: z.array(z.union([z.string(), z.record(z.string(), z.any())])).optional().describe('Éléments répétés (étapes, cartes, questions, chiffres, caractéristiques, lignes de comparaison…) : { title, text, label, value, icon, image, button: {label, link}, palette, question, answer, name, role, values: [..] } ; le nombre d’éléments s’adapte'),
784
+ })
785
+ .optional(),
786
+ })
787
+ .passthrough();
788
+
789
+ server.registerTool(
790
+ 'compose_page',
791
+ {
792
+ title: 'Composer une page',
793
+ description: 'Designs a page freely with ANY section of the theme: for each section, a preset to start from plus its content (title, text, buttons, repeated items such as steps, cards, questions, figures, specs, comparison rows), palette, shape separators and photos. The MCP places everything in the right blocks, adds or removes items, and saves only if the file is valid. Use it for original pages: our story, how to use, ingredients, lookbook, gift guide, landing page. mode append adds the sections to an existing page.',
794
+ inputSchema: {
795
+ project: PROJECT,
796
+ file: z.string().describe('ex. templates/page.utilisation.json, templates/page.ingredients.json, templates/index.json'),
797
+ sections: z.array(SECTION_SPEC).min(1),
798
+ mode: z.enum(['replace', 'append']).optional(),
799
+ summary: z.string().optional(),
800
+ },
801
+ annotations: writes,
802
+ },
803
+ run(({ project, file, sections, mode = 'replace', summary }) => {
804
+ const { store, catalog } = ctx.get();
805
+ project = store.resolve(project);
806
+ const meta = store.meta(project);
807
+ const rel = checkRelativePath(file);
808
+ if (fileKind(rel) !== 'template') throw new Error('compose_page écrit un modèle (templates/…)');
809
+ const base = mode === 'append' ? store.read(project, rel).data || { sections: {}, order: [] } : { sections: {}, order: [] };
810
+ const { data, notes } = composeSections(catalog, sections, { language: meta.language, data: base });
811
+ const report = fileReport(catalog, store, project, rel, data, meta.language);
812
+ if (!report.validation.ok) return fail(`Rien n'a été enregistré.\n${formatValidation(report.validation)}${notes.length ? `\nNotes : ${notes.join(' ; ')}` : ''}`);
813
+ store.write(project, rel, data, summary || `page composée (${sections.length} sections)`);
814
+ const pageHint = /^templates\/page\.[^.]+\.json$/.test(rel) ? `\nDans Shopify : créez la page puis choisissez le modèle « ${rel.replace('templates/', '').replace('.json', '')} ».` : '';
815
+ return ok(`Enregistré : ${rel}${pageHint}\n${notes.length ? `Notes : ${notes.join(' ; ')}\n` : ''}\n${outline(catalog, data, { language: meta.language, file: rel })}\n\n${formatFindings(report.design, { max: 12 })}`);
816
+ }),
817
+ );
818
+
819
+ server.registerTool(
820
+ 'create_project',
821
+ {
822
+ title: 'Créer un projet',
823
+ description: 'Creates a store project: a workspace holding the store’s templates, section groups and theme settings, starting from the theme defaults. Fill the brief with facts from the merchant (never invent proofs).',
824
+ inputSchema: {
825
+ name: z.string().min(2).describe('Nom de la boutique ou du projet'),
826
+ language: z.string().optional().describe('Langue de la boutique (fr par défaut)'),
827
+ brief: BRIEF.optional(),
828
+ },
829
+ annotations: writes,
830
+ },
831
+ run(({ name, language, brief = {} }) => {
832
+ const { store, catalog } = ctx.get();
833
+ const lang = languageOf(ctx, { language });
834
+ if (!catalog.languages.includes(lang)) throw new Error(`langue « ${lang} » : le thème est traduit en ${catalog.languages.join(', ')}`);
835
+ const meta = store.create({ name, language: lang, brief });
836
+ const missing = ['activity', 'audience', 'positioning', 'tone', 'brand_colors', 'proofs', 'shipping_returns'].filter((key) => !brief[key] || (Array.isArray(brief[key]) && !brief[key].length));
837
+ return ok([
838
+ `Projet « ${meta.name} » créé : ${meta.slug} (Story Thème ${meta.theme.version}, langue ${meta.language}).`,
839
+ `Dossier : ${store.dir(meta.slug)}`,
840
+ missing.length ? `À préciser plus tard avec le marchand : ${missing.join(', ')} (update_brief). Ne bloquez pas dessus : construisez d'abord.` : 'Brief complet.',
841
+ 'Plus rapide : build_store construit toute la V1 en un appel.',
842
+ ].join('\n'));
843
+ }),
844
+ );
845
+
846
+ server.registerTool(
847
+ 'list_projects',
848
+ { title: 'Projets', description: 'Lists store projects.', annotations: readOnly },
849
+ run(() => {
850
+ const { store } = ctx.get();
851
+ const projects = store.list();
852
+ return ok(projects.length ? projects.map((project) => `- ${project.slug} — ${project.name} (${project.language}, thème ${project.theme?.version}, modifié ${project.updated_at?.slice(0, 16)})`).join('\n') : 'Aucun projet.');
853
+ }),
854
+ );
855
+
856
+ server.registerTool(
857
+ 'get_project',
858
+ {
859
+ title: 'Projet',
860
+ description: 'A project’s brief, which files it customises (vs theme defaults), the outline of its home page, and recent changes.',
861
+ inputSchema: { project: PROJECT },
862
+ annotations: readOnly,
863
+ },
864
+ run(({ project }) => {
865
+ const { store, catalog } = ctx.get();
866
+ project = store.resolve(project);
867
+ const meta = store.meta(project);
868
+ const files = store.projectFiles(project);
869
+ const index = store.read(project, 'templates/index.json');
870
+ return ok([
871
+ `# ${meta.name} (${meta.slug}) — Story Thème ${meta.theme?.version}, langue ${meta.language}`,
872
+ meta.theme?.version !== catalog.version ? `⚠️ Projet créé avec la version ${meta.theme?.version}, thème actuel ${catalog.version}.` : '',
873
+ `Brief : ${JSON.stringify(meta.brief || {}, null, 1)}`,
874
+ `Fichiers personnalisés : ${files.join(', ') || 'aucun'}`,
875
+ '',
876
+ outline(catalog, index.data, { language: meta.language, file: `templates/index.json (${index.origin === 'project' ? 'projet' : 'version du thème'})` }),
877
+ '',
878
+ `Dernières modifications :\n${(meta.log || []).slice(-10).map((entry) => `- ${entry.at.slice(0, 16)} ${entry.file} : ${entry.summary}`).join('\n') || '- aucune'}`,
879
+ ].filter(Boolean).join('\n'));
880
+ }),
881
+ );
882
+
883
+ server.registerTool(
884
+ 'update_brief',
885
+ {
886
+ title: 'Compléter le brief',
887
+ description: 'Adds or changes facts in a project brief (merged).',
888
+ inputSchema: { project: PROJECT, brief: BRIEF },
889
+ annotations: writes,
890
+ },
891
+ run(({ project, brief }) => {
892
+ const { store } = ctx.get();
893
+ project = store.resolve(project);
894
+ const meta = store.updateBrief(project, brief);
895
+ return ok(`Brief mis à jour :\n${JSON.stringify(meta.brief, null, 1)}`);
896
+ }),
897
+ );
898
+
899
+ server.registerTool(
900
+ 'read_file',
901
+ {
902
+ title: 'Lire un fichier du projet',
903
+ description: 'Reads a project file (or the theme default when the project has not customised it) as an outline with block ids (for edit_template targets) and/or full JSON.',
904
+ inputSchema: {
905
+ project: PROJECT,
906
+ file: z.string().describe('ex. templates/index.json, sections/header-group.json, config/settings_data.json'),
907
+ format: z.enum(['outline', 'json', 'both']).optional(),
908
+ },
909
+ annotations: readOnly,
910
+ },
911
+ run(({ project, file, format = 'outline' }) => {
912
+ const { store, catalog } = ctx.get();
913
+ project = store.resolve(project);
914
+ const meta = store.meta(project);
915
+ const rel = checkRelativePath(file);
916
+ const { data, origin } = store.read(project, rel);
917
+ if (!data) return ok(`${rel} n'existe ni dans le projet ni dans le thème : créez-le avec apply_recipe, edit_template (add_section) ou write_file.`);
918
+ const header = `${rel} — ${origin === 'project' ? 'version du projet' : 'version du thème (pas encore personnalisée)'}`;
919
+ if (rel === 'config/settings_data.json' && format === 'outline') {
920
+ const current = typeof data.current === 'string' ? data.presets?.[data.current] : data.current;
921
+ const simple = Object.entries(current || {}).filter(([, value]) => typeof value !== 'object').map(([key, value]) => `${key}=${JSON.stringify(value)}`);
922
+ return ok(`${header}\nPalettes : ${Object.keys(current?.color_schemes || {}).join(', ')}\n${simple.join('\n')}\n(format json pour les palettes et le tiroir panier)`);
923
+ }
924
+ const parts = [header];
925
+ if (format !== 'json') parts.push(outline(catalog, data, { language: meta.language, file: rel }));
926
+ if (format !== 'outline') parts.push(JSON.stringify(data, null, 1));
927
+ return ok(parts.join('\n\n'));
928
+ }),
929
+ );
930
+
931
+ server.registerTool(
932
+ 'list_texts',
933
+ {
934
+ title: 'Textes et liens d’une page',
935
+ description: 'Lists every text, rich text and link of a project file with its exact target ("section/block/…") and setting id, flagged [à réécrire] when the review would flag it (sample copy, English, unverified claims) or [exemple générique] for short theme labels that may stay. Use it to rewrite a whole page in one edit_template call of update_settings operations.',
936
+ inputSchema: { project: PROJECT, file: z.string(), only_to_fix: z.boolean().optional().describe('Seulement les textes signalés') },
937
+ annotations: readOnly,
938
+ },
939
+ run(({ project, file, only_to_fix = false }) => {
940
+ const { store, catalog } = ctx.get();
941
+ project = store.resolve(project);
942
+ const meta = store.meta(project);
943
+ const rel = checkRelativePath(file);
944
+ const { data, origin } = store.read(project, rel);
945
+ if (!data?.sections) throw new Error(`${rel} n'a pas de sections`);
946
+ const lines = [`${rel} (${origin === 'project' ? 'projet' : 'version du thème'}) — cibles pour edit_template / update_settings :`];
947
+ for (const entry of collectTexts(catalog, rel, data, { language: meta.language })) {
948
+ if (only_to_fix && entry.flag !== 'fix') continue;
949
+ const flags = [];
950
+ if (entry.flag === 'fix') flags.push(/anglais/.test(entry.issue) ? '[à réécrire, EN]' : /témoignage|chiffre|réponse|promesses|annonce|messages/.test(entry.issue) ? '[à réécrire, fait à vérifier]' : '[à réécrire]');
951
+ else if (entry.flag === 'sample') flags.push('[exemple générique]');
952
+ const shown = String(entry.value).length > 400 ? `${String(entry.value).slice(0, 397)}…` : entry.value;
953
+ lines.push(`- ${entry.target} › ${entry.setting} (${entry.type})${flags.length ? ' ' + flags.join(' ') : ''} = ${JSON.stringify(shown)}`);
954
+ }
955
+ if (lines.length === 1) lines.push(only_to_fix ? 'Aucun texte à corriger.' : 'Aucun texte renseigné (les valeurs par défaut du thème s’appliquent).');
956
+ lines.push('Rappel : un texte riche s’écrit <p>…</p> ; un texte riche en ligne sans <p>.');
957
+ return ok(lines.join('\n'));
958
+ }),
959
+ );
960
+
961
+ server.registerTool(
962
+ 'write_file',
963
+ {
964
+ title: 'Écrire un fichier entier',
965
+ description: 'Saves a whole template, section group or settings_data in a project, after validation against the theme schemas. Refused (nothing saved) if any error. Prefer edit_template for changes.',
966
+ inputSchema: {
967
+ project: PROJECT,
968
+ file: z.string(),
969
+ content: z.union([z.string(), z.record(z.string(), z.any())]),
970
+ summary: z.string().optional().describe('Ce qui change, pour l’historique'),
971
+ },
972
+ annotations: writes,
973
+ },
974
+ run(({ project, file, content, summary }) => {
975
+ const { store, catalog } = ctx.get();
976
+ project = store.resolve(project);
977
+ const meta = store.meta(project);
978
+ const rel = checkRelativePath(file);
979
+ const data = parseContent(content);
980
+ const report = fileReport(catalog, store, project, rel, data, meta.language);
981
+ if (!report.validation.ok) return fail(`Rien n'a été enregistré.\n${formatValidation(report.validation)}`);
982
+ store.write(project, rel, data, summary || 'fichier écrit');
983
+ return ok(`Enregistré : ${rel}\n${formatValidation(report.validation)}\n\n${rel.startsWith('config/') ? '' : outline(catalog, data, { language: meta.language, file: rel }) + '\n\n'}${formatFindings(report.design)}`);
984
+ }),
985
+ );
986
+
987
+ server.registerTool(
988
+ 'edit_template',
989
+ {
990
+ title: 'Modifier un modèle',
991
+ description: `Applies a list of operations to a template or section group of a project, validates the result, and saves it only if valid (all or nothing). Operations:
992
+ - add_section {type, preset?, id?, position?, settings?} (sections come from their preset, texts in the project language)
993
+ - remove_section {section} · move_section {section, position} · duplicate_section {section, position?} · replace_section {section, data}
994
+ - update_settings {target, settings, mode?} (target "section" or "section/block/…"; null removes a setting)
995
+ - add_block {parent, type, preset?, id?, position?, settings?} · remove_block {target} · move_block {target, position} · set_disabled {target, disabled}
996
+ Read the file first (read_file) to get ids.`,
997
+ inputSchema: {
998
+ project: PROJECT,
999
+ file: z.string().describe('templates/<nom>.json ou sections/<groupe>.json'),
1000
+ operations: z.array(OPERATION).min(1),
1001
+ summary: z.string().optional(),
1002
+ },
1003
+ annotations: writes,
1004
+ },
1005
+ run(({ project, file, operations, summary }) => {
1006
+ const { store, catalog } = ctx.get();
1007
+ project = store.resolve(project);
1008
+ const meta = store.meta(project);
1009
+ const rel = checkRelativePath(file);
1010
+ if (rel === 'config/settings_data.json') throw new Error('pour les réglages du thème, utilisez update_theme_settings');
1011
+ const current = store.read(project, rel).data || (rel.startsWith('sections/') ? null : { sections: {}, order: [] });
1012
+ if (!current) throw new Error(`${rel} n'existe pas : un groupe de sections se crée avec write_file ({ type, name, sections, order })`);
1013
+ const { data, log } = applyOperations(catalog, current, operations, { language: meta.language });
1014
+ const report = fileReport(catalog, store, project, rel, data, meta.language);
1015
+ if (!report.validation.ok) return fail(`Rien n'a été enregistré : le résultat serait refusé par Shopify.\n${formatValidation(report.validation)}\n\nOpérations appliquées sur la copie :\n${log.join('\n')}`);
1016
+ store.write(project, rel, data, summary || log.join(' ; '));
1017
+ return ok(`Enregistré : ${rel}\n${log.map((line) => `- ${line}`).join('\n')}\n\n${outline(catalog, data, { language: meta.language, file: rel })}\n\n${formatValidation(report.validation)}\n${formatFindings(report.design, { max: 12 })}`);
1018
+ }),
1019
+ );
1020
+
1021
+ server.registerTool(
1022
+ 'apply_recipe',
1023
+ {
1024
+ title: 'Appliquer une recette',
1025
+ description: 'Builds a page from a recipe (see `recipes`) into a project file, from theme presets with texts in the project language. Replaces the file by default (undo restores it). Then rewrite every sample text for the brand.',
1026
+ inputSchema: {
1027
+ project: PROJECT,
1028
+ recipe: z.string(),
1029
+ file: z.string().optional().describe('Fichier cible (par défaut celui de la recette)'),
1030
+ mode: z.enum(['replace', 'append']).optional().describe('replace (défaut) ou append (ajoute les sections à la fin)'),
1031
+ },
1032
+ annotations: writes,
1033
+ },
1034
+ run(({ project, recipe: recipeId, file, mode = 'replace' }) => {
1035
+ const { store, catalog } = ctx.get();
1036
+ project = store.resolve(project);
1037
+ const meta = store.meta(project);
1038
+ const recipe = findRecipe(recipeId);
1039
+ if (!recipe) throw new Error(`recette « ${recipeId} » inconnue (recettes : ${RECIPES.map((item) => item.id).join(', ')})`);
1040
+ const rel = checkRelativePath(file || recipe.template);
1041
+ if (fileKind(rel) !== 'template') throw new Error('une recette produit un modèle (templates/…)');
1042
+ if (templateTypeOf(rel) !== templateTypeOf(recipe.template)) throw new Error(`la recette ${recipe.id} est faite pour un modèle ${templateTypeOf(recipe.template)}, pas ${templateTypeOf(rel)}`);
1043
+ let base = recipe.base ? catalog.theme.templates.get(recipe.base) : null;
1044
+ if (mode === 'append') base = store.read(project, rel).data || base;
1045
+ const { data, roles } = buildFromRecipe(catalog, recipe, { language: meta.language, base });
1046
+ const report = fileReport(catalog, store, project, rel, data, meta.language);
1047
+ if (!report.validation.ok) return fail(`La recette produit un fichier invalide avec cette version du thème (rien n'a été enregistré) :\n${formatValidation(report.validation)}`);
1048
+ store.write(project, rel, data, `recette ${recipe.id}`);
1049
+ return ok([
1050
+ `Recette « ${recipe.title} » appliquée à ${rel}.`,
1051
+ '',
1052
+ outline(catalog, data, { language: meta.language, file: rel }),
1053
+ '',
1054
+ 'À faire maintenant, section par section (edit_template → update_settings) :',
1055
+ ...roles.map((role) => `- ${role.section} : ${role.role}`),
1056
+ '',
1057
+ 'Tous les textes sont ceux des presets du thème : réécrivez-les pour la marque, supprimez ce qui ne s’applique pas, renseignez les vrais liens (shopify://collections/<handle>).',
1058
+ formatFindings(report.design.filter((finding) => finding.level !== 'important' || !/exemple du thème/.test(finding.message)), { max: 10 }),
1059
+ ].join('\n'));
1060
+ }),
1061
+ );
1062
+
1063
+ server.registerTool(
1064
+ 'localize_template',
1065
+ {
1066
+ title: 'Traduire les modèles du thème',
1067
+ description: 'Translates the English sample texts of the theme’s own templates and section groups into the project language, using the theme’s official translations (about 95 % of the default home page, half of the other pages). Saves the result in the project and lists the texts left to write (then list_texts + edit_template). Use it for pages kept close to the theme’s design; use apply_recipe for pages rebuilt from a recipe.',
1068
+ inputSchema: {
1069
+ project: PROJECT,
1070
+ files: z.array(z.string()).optional().describe('Fichiers à traduire ; par défaut tous les modèles et groupes pas encore personnalisés par le projet'),
1071
+ },
1072
+ annotations: writes,
1073
+ },
1074
+ run(({ project, files }) => {
1075
+ const { store, catalog } = ctx.get();
1076
+ project = store.resolve(project);
1077
+ const meta = store.meta(project);
1078
+ if (meta.language === catalog.defaultLanguage) return ok(`Le projet est en ${meta.language}, la langue des textes du thème : rien à traduire.`);
1079
+ const effective = store.effectiveFiles(project);
1080
+ const targets = files?.length ? files.map((file) => checkRelativePath(file)) : [...effective.entries()].filter(([rel, entry]) => entry.origin === 'theme' && rel !== 'config/settings_data.json').map(([rel]) => rel);
1081
+ const lines = [];
1082
+ let left = 0;
1083
+ for (const rel of targets) {
1084
+ const source = store.read(project, rel).data;
1085
+ if (!source?.sections) {
1086
+ lines.push(`- ${rel} : introuvable`);
1087
+ continue;
1088
+ }
1089
+ const { data, translated, remaining } = localizeData(catalog, source, meta.language);
1090
+ if (!translated) {
1091
+ lines.push(`- ${rel} : rien de traduisible${remaining.length ? `, ${remaining.length} texte(s) à écrire` : ''}`);
1092
+ left += remaining.length;
1093
+ continue;
1094
+ }
1095
+ const validation = validateFile(catalog, rel, data, { schemeIds: schemeIdsFor(store, project) });
1096
+ if (!validation.ok) {
1097
+ lines.push(`- ${rel} : non enregistré (${validation.errors[0].message})`);
1098
+ continue;
1099
+ }
1100
+ store.write(project, rel, data, `traduction ${meta.language} (${translated} textes)`);
1101
+ left += remaining.length;
1102
+ lines.push(`- ${rel} : ${translated} texte(s) traduit(s)${remaining.length ? `, ${remaining.length} à écrire` : ' ✅'}`);
1103
+ }
1104
+ return ok(`${lines.join('\n')}\n\n${left ? `${left} texte(s) restent en anglais : list_texts (only_to_fix) sur chaque fichier, puis edit_template. Les modèles inutiles (démos d'offres, page d'aide…) peuvent être retirés avec exclude_template.` : 'Tous les textes sont traduits. Relisez-les : ce sont encore des exemples à adapter à la marque.'}`);
1105
+ }),
1106
+ );
1107
+
1108
+ server.registerTool(
1109
+ 'exclude_template',
1110
+ {
1111
+ title: 'Retirer un modèle inutile',
1112
+ description: 'Leaves an unused alternate template (product.offers-*, page.help, page.wishlist…) out of the export and the review, so it never appears in the admin with sample content. Base templates cannot be removed. include=true puts it back.',
1113
+ inputSchema: { project: PROJECT, file: z.string(), include: z.boolean().optional() },
1114
+ annotations: writes,
1115
+ },
1116
+ run(({ project, file, include = false }) => {
1117
+ const { store } = ctx.get();
1118
+ project = store.resolve(project);
1119
+ const excluded = store.setExcluded(project, file, !include);
1120
+ return ok(`${file} ${include ? "remis dans l'export" : "retiré de l'export"}. Modèles retirés : ${excluded.join(', ') || 'aucun'}.`);
1121
+ }),
1122
+ );
1123
+
1124
+ server.registerTool(
1125
+ 'update_theme_settings',
1126
+ {
1127
+ title: 'Réglages du thème du projet',
1128
+ description: 'Merges global theme settings into the project’s config/settings_data.json: `settings` (fonts, buttons, radius, cart type, social links…), `color_schemes` (replaces the listed schemes), `palette`. Validated and contrast-audited; refused on error.',
1129
+ inputSchema: {
1130
+ project: PROJECT,
1131
+ settings: z.record(z.string(), z.any()).optional().describe('{ id_réglage: valeur } ; null supprime'),
1132
+ color_schemes: z.record(z.string(), z.any()).optional().describe('{ "background-1": { settings: {…} }, … } (sortie de generate_color_schemes)'),
1133
+ palette: z.record(z.string(), z.string()).optional(),
1134
+ remove_schemes: z.array(z.string()).optional().describe('Palettes à supprimer (vérifiez qu’aucune section ne les utilise)'),
1135
+ },
1136
+ annotations: writes,
1137
+ },
1138
+ run(({ project, settings = {}, color_schemes, palette, remove_schemes = [] }) => {
1139
+ const { store, catalog } = ctx.get();
1140
+ project = store.resolve(project);
1141
+ store.meta(project);
1142
+ const rel = 'config/settings_data.json';
1143
+ const data = JSON.parse(JSON.stringify(store.read(project, rel).data || { current: {} }));
1144
+ if (typeof data.current === 'string') data.current = JSON.parse(JSON.stringify(data.presets?.[data.current] || {}));
1145
+ const current = data.current;
1146
+ for (const [key, value] of Object.entries(settings)) {
1147
+ if (value === null) delete current[key];
1148
+ else current[key] = value;
1149
+ }
1150
+ if (color_schemes) {
1151
+ current.color_schemes = { ...(current.color_schemes || {}) };
1152
+ for (const [id, scheme] of Object.entries(color_schemes)) current.color_schemes[id] = scheme?.settings ? scheme : { settings: scheme };
1153
+ }
1154
+ if (remove_schemes.length) {
1155
+ const used = findSchemeReferences(catalog, store.effectiveFiles(project), remove_schemes);
1156
+ if (used.length) return fail(`Rien n'a été enregistré : ${remove_schemes.join(', ')} ${used.length > 1 ? 'sont encore utilisées' : 'est encore utilisée'} (${used.length} réglage(s)) :\n${used.slice(0, 15).map((line) => `- ${line}`).join('\n')}${used.length > 15 ? '\n- …' : ''}\nChangez d'abord ces palettes (edit_template), puis supprimez.`);
1157
+ }
1158
+ for (const id of remove_schemes) delete current.color_schemes?.[id];
1159
+ if (palette) current.palette = { ...(current.palette || {}), ...palette };
1160
+ const validation = validateFile(catalog, rel, data);
1161
+ if (!validation.ok) return fail(`Rien n'a été enregistré.\n${formatValidation(validation)}`);
1162
+ store.write(project, rel, data, `réglages : ${[...Object.keys(settings), color_schemes ? 'palettes' : null, palette ? 'palette globale' : null].filter(Boolean).join(', ')}`);
1163
+ const buttons = { 1: current.button_1_palette, 2: current.button_2_palette, 3: current.button_3_palette };
1164
+ const contrastIssues = auditSchemes(current.color_schemes || {}, buttons).filter((finding) => finding.level !== 'ok');
1165
+ return ok(`Réglages enregistrés.\n${formatValidation(validation)}\n${contrastIssues.length ? `Contrastes à surveiller :\n${contrastIssues.map((item) => `- ${item.level === 'error' ? '🔴' : '🟡'} ${item.scheme} : ${item.message}`).join('\n')}` : 'Contrastes : tout passe AA.'}\n${formatFindings(lintSettings(catalog, data).filter((finding) => !/contraste/.test(finding.message)))}`);
1166
+ }),
1167
+ );
1168
+
1169
+ server.registerTool(
1170
+ 'review_project',
1171
+ {
1172
+ title: 'Revue complète',
1173
+ description: 'Full review of a project before export: blocking errors, design issues (sample or English texts left, missing links, several h1, contrast, fonts, fake urgency…), theme templates still untouched, and the go-live checklist.',
1174
+ inputSchema: { project: PROJECT },
1175
+ annotations: readOnly,
1176
+ },
1177
+ run(({ project }) => {
1178
+ const { store, catalog } = ctx.get();
1179
+ project = store.resolve(project);
1180
+ return ok(formatReview(reviewProject(catalog, store, project)));
1181
+ }),
1182
+ );
1183
+
1184
+ server.registerTool(
1185
+ 'undo',
1186
+ {
1187
+ title: 'Annuler',
1188
+ description: 'Restores a project file to its state before the last change (of that file, or of the whole project). Can be repeated.',
1189
+ inputSchema: { project: PROJECT, file: z.string().optional() },
1190
+ annotations: writes,
1191
+ },
1192
+ run(({ project, file }) => {
1193
+ const { store } = ctx.get();
1194
+ project = store.resolve(project);
1195
+ const restored = store.undo(project, file || null);
1196
+ if (restored.kind === 'exclusion') return ok(`Annulé : retour à la liste des modèles retirés du ${restored.at.slice(0, 19).replace('T', ' ')} (${restored.excluded.join(', ') || 'aucun'}).`);
1197
+ return ok(`Annulé : ${restored.rel} revient à son état du ${restored.at.slice(0, 19).replace('T', ' ')}${restored.existed ? '' : ' (version du thème)'}.`);
1198
+ }),
1199
+ );
1200
+
1201
+ server.registerTool(
1202
+ 'revert_file',
1203
+ {
1204
+ title: 'Revenir à la version du thème',
1205
+ description: 'Drops the project’s version of a file so the theme default applies again (undo can bring it back).',
1206
+ inputSchema: { project: PROJECT, file: z.string() },
1207
+ annotations: writes,
1208
+ },
1209
+ run(({ project, file }) => {
1210
+ const { store } = ctx.get();
1211
+ project = store.resolve(project);
1212
+ return ok(store.revert(project, file) ? `${file} : retour à la version du thème.` : `${file} n'était pas personnalisé.`);
1213
+ }),
1214
+ );
1215
+
1216
+ server.registerTool(
1217
+ 'export_theme',
1218
+ {
1219
+ title: 'Exporter le thème',
1220
+ description: 'Builds the theme zip to upload in Shopify (Online Store > Themes > Add theme > Upload zip), with the project files on top of the theme, plus the go-live checklist. Refused while any blocking error remains. Nothing is sent to a store.',
1221
+ inputSchema: { project: PROJECT },
1222
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
1223
+ },
1224
+ run(({ project }) => {
1225
+ const { store, catalog } = ctx.get();
1226
+ project = store.resolve(project);
1227
+ const result = exportTheme({ catalog, store, slug: project });
1228
+ if (!result.ok) return fail(`${result.message}\n${(result.errors || []).map((entry) => `${entry.file} :\n${entry.errors.map((error) => ` - ${error.path} : ${error.message}`).join('\n')}`).join('\n')}`);
1229
+ const notReady = /pas prêt à publier/.test(result.report);
1230
+ const migratedFiles = Object.keys(result.migrated || {});
1231
+ return ok(`${migratedFiles.length ? `Projet mis à jour vers le thème ${catalog.version} : ${migratedFiles.map((file) => `${file} (${result.migrated[file].length})`).join(', ')} — réglages que le thème n'a plus, sans effet sur le rendu (undo possible).\n` : ''}${notReady ? '⚠️ Exporté, mais PAS PRÊT À PUBLIER : des points 🔴 restent (voir la revue ci-dessous). Importez-le sans le publier.\n' : ''}✅ Thème exporté : ${result.zip} (${result.size_mb} Mo, ${result.files} fichiers, personnalisés : ${result.overlays.join(', ') || 'aucun'})\nCheck-list : ${result.checklist}\n\n${result.report}`);
1232
+ }),
1233
+ );
1234
+
1235
+ server.registerTool(
1236
+ 'migrate_project',
1237
+ {
1238
+ title: 'Mettre le projet à jour avec le thème',
1239
+ description: 'After a theme update: removes settings the theme no longer has and resets values it no longer accepts (default value, nearest valid step), in every project file, without changing what renders. Unknown section or block types are reported, not removed. export_theme runs it automatically.',
1240
+ inputSchema: { project: PROJECT },
1241
+ annotations: writes,
1242
+ },
1243
+ run(({ project }) => {
1244
+ const { store, catalog } = ctx.get();
1245
+ project = store.resolve(project);
1246
+ const report = migrateProject({ catalog, store, slug: project });
1247
+ const files = Object.entries(report);
1248
+ const remaining = reviewProject(catalog, store, project).errorCount;
1249
+ return ok([
1250
+ files.length ? `Mis à jour vers le thème ${catalog.version} :` : `Rien à mettre à jour : le projet correspond déjà au thème ${catalog.version}.`,
1251
+ ...files.map(([file, changes]) => `- ${file} : ${changes.length} changement(s) — ${changes.slice(0, 4).join(' ; ')}${changes.length > 4 ? ' ; …' : ''}`),
1252
+ remaining ? `⚠️ ${remaining} erreur(s) restent (sections ou blocs disparus du thème) : review_project les détaille.` : '✅ Aucune erreur bloquante.',
1253
+ ].join('\n'));
1254
+ }),
1255
+ );
1256
+
1257
+ server.registerTool(
1258
+ 'import_theme',
1259
+ {
1260
+ title: 'Importer un thème',
1261
+ description: 'Creates a project from a theme exported from a store (folder or .zip from Online Store > Themes > Download), to audit or rework it. Reports files that do not match this theme version.',
1262
+ inputSchema: { source: z.string().describe('Chemin du dossier ou du .zip'), name: z.string(), language: z.string().optional() },
1263
+ annotations: writes,
1264
+ },
1265
+ run(({ source, name, language }) => {
1266
+ const { store, catalog } = ctx.get();
1267
+ const result = importTheme({ catalog, store, source, name, language: languageOf(ctx, { language }) });
1268
+ const withErrors = result.results.filter((entry) => entry.errors);
1269
+ return ok([
1270
+ `Projet ${result.project} créé depuis ${source} (version importée ${result.version}, thème du MCP ${result.themeVersion}).`,
1271
+ result.version !== result.themeVersion ? `⚠️ Versions différentes : les sections qui n'existent plus en ${result.themeVersion} apparaissent en erreur.` : '',
1272
+ ...result.results.map((entry) => `- ${entry.file} : ${entry.imported ? (entry.errors ? `❌ ${entry.errors} erreur(s)` : '✅') : `non importé (${entry.errors.join(', ')})`}${entry.warnings ? `, ${entry.warnings} avertissement(s)` : ''}`),
1273
+ withErrors.length ? 'review_project détaille chaque problème.' : '',
1274
+ ].filter(Boolean).join('\n'));
1275
+ }),
1276
+ );
1277
+
1278
+ // ---------------------------------------------------------------- prompts & resources
1279
+
1280
+ server.registerPrompt(
1281
+ 'creer-boutique',
1282
+ {
1283
+ title: 'Créer une boutique avec le Story Thème',
1284
+ description: 'Guide pas à pas : brief, identité, pages, revue, export.',
1285
+ argsSchema: { marque: z.string(), activite: z.string().optional(), langue: z.string().optional() },
1286
+ },
1287
+ ({ marque, activite, langue }) => ({
1288
+ messages: [
1289
+ {
1290
+ role: 'user',
1291
+ content: {
1292
+ type: 'text',
1293
+ text: `Crée la boutique « ${marque} »${activite ? ` (${activite})` : ''} avec le Story Thème, en ${langue || 'français'}. Construis tout de suite une V1 complète avec build_store (sans me poser de question avant), en rédigeant les textes clés de la marque. Ensuite, résume ce que tu as fait, donne-moi le zip et pose-moi quelques questions pour affiner.`,
1294
+ },
1295
+ },
1296
+ ],
1297
+ }),
1298
+ );
1299
+
1300
+ server.registerPrompt(
1301
+ 'refaire-une-page',
1302
+ {
1303
+ title: 'Refaire une page',
1304
+ description: 'Refondre une page d’un projet existant.',
1305
+ argsSchema: { projet: z.string(), page: z.string().describe('ex. accueil, fiche produit, à propos') },
1306
+ },
1307
+ ({ projet, page }) => ({
1308
+ messages: [
1309
+ {
1310
+ role: 'user',
1311
+ content: {
1312
+ type: 'text',
1313
+ text: `Dans le projet ${projet}, refais la page « ${page} ». Lis d'abord le brief (get_project) et la page actuelle (read_file), propose-moi une structure (recipes, list_sections) avant de modifier, puis applique-la et réécris les textes pour la marque. Termine par review_project.`,
1314
+ },
1315
+ },
1316
+ ],
1317
+ }),
1318
+ );
1319
+
1320
+ server.registerPrompt(
1321
+ 'auditer-un-theme',
1322
+ {
1323
+ title: 'Auditer un thème exporté',
1324
+ description: 'Importer un thème téléchargé depuis une boutique et lister ce qu’il faut corriger.',
1325
+ argsSchema: { chemin: z.string(), nom: z.string() },
1326
+ },
1327
+ ({ chemin, nom }) => ({
1328
+ messages: [
1329
+ {
1330
+ role: 'user',
1331
+ content: {
1332
+ type: 'text',
1333
+ text: `Importe le thème « ${chemin} » dans un projet nommé « ${nom} » (import_theme), lance review_project, puis donne-moi les corrections par ordre d'impact sur les ventes et l'image de marque, et propose de les appliquer.`,
1334
+ },
1335
+ },
1336
+ ],
1337
+ }),
1338
+ );
1339
+
1340
+ for (const topic of GUIDE_TOPICS) {
1341
+ server.registerResource(
1342
+ `guide-${topic}`,
1343
+ `storytheme://guides/${topic}`,
1344
+ { title: GUIDES[topic].title, description: `Guide Story Thème : ${GUIDES[topic].title}`, mimeType: 'text/markdown' },
1345
+ async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: `# ${GUIDES[topic].title}\n\n${GUIDES[topic].body}` }] }),
1346
+ );
1347
+ }
1348
+
1349
+ return server;
1350
+ }