story-theme-mcp 1.0.0 → 1.1.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/README.md CHANGED
@@ -13,7 +13,9 @@ IA : couleurs, polices, accueil de 9 sections, fiche produit, collection, page
13
13
 
14
14
  ## Installation
15
15
 
16
- Node 20 ou plus ([nodejs.org](https://nodejs.org)).
16
+ **Le plus simple — l'extension Claude.** Téléchargez `story-theme-mcp.mcpb` depuis votre espace membre (page MCP & Appli) et ouvrez le fichier : Claude installe le serveur et vous demande votre jeton dans un formulaire. Rien d'autre à installer, Node compris.
17
+
18
+ Les deux méthodes ci-dessous restent valables si vous préférez configurer à la main. Elles demandent Node 20 ou plus ([nodejs.org](https://nodejs.org)).
17
19
 
18
20
  **Claude (application)** — Réglages > Développeur > Modifier la configuration, puis dans `claude_desktop_config.json` :
19
21
 
@@ -102,8 +104,11 @@ Pas de connexion à une boutique : le MCP fabrique un thème à importer, il ne
102
104
  npm test # validateur, presets, recettes, couleurs, polices, projets, export, source, serveur MCP
103
105
  npm run check # après une mise à jour du thème : presets, recettes et descriptions toujours valides ?
104
106
  npm run fonts # recharge la bibliothèque de polices Shopify (shopify.dev)
107
+ npm run bundle # fabrique build/story-theme-mcp-<version>.mcpb (extension Claude, un clic)
105
108
  ```
106
109
 
110
+ Après `npm run bundle`, déposer le `.mcpb` dans le dossier `mcp/` de l'espace membre : la page MCP & Appli sert automatiquement la version la plus récente qu'elle y trouve.
111
+
107
112
  Avec `STORY_THEME_DIR` sur le dépôt du thème, le MCP lit le thème en cours d'écriture et n'appelle pas l'espace membre. Les connaissances rédigées à la main vivent dans `src/knowledge/` ; tout le reste vient du thème.
108
113
 
109
114
  Publication : `npm version <x.y.z>` (mettre à jour `src/version.js` à l'identique), `npm publish` — `prepublishOnly` vérifie l'ensemble.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "story-theme-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Serveur MCP du Story Thème : votre assistant IA connaît le thème Shopify Story par cœur et construit des boutiques propres et professionnelles.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -15,7 +15,8 @@
15
15
  "test": "node --test test/*.test.js",
16
16
  "check": "node scripts/check-theme.js",
17
17
  "fonts": "node scripts/fetch-fonts.js",
18
- "prepublishOnly": "npm test && node scripts/check-release.js"
18
+ "prepublishOnly": "npm test && node scripts/check-release.js",
19
+ "bundle": "node scripts/build-bundle.js"
19
20
  },
20
21
  "author": "New Story (SAS NS GROUP) <contact@story-theme.com>",
21
22
  "license": "SEE LICENSE IN LICENSE",
@@ -42,5 +43,8 @@
42
43
  "src",
43
44
  "README.md",
44
45
  "LICENSE"
45
- ]
46
+ ],
47
+ "devDependencies": {
48
+ "express": "^5.2.1"
49
+ }
46
50
  }
package/src/http.js ADDED
@@ -0,0 +1,147 @@
1
+ // =====================================================================
2
+ // Le même MCP, servi en HTTP — pour les connecteurs distants
3
+ // =====================================================================
4
+ //
5
+ // L'extension .mcpb installe le serveur CHEZ le client : Claude prévient alors, à raison, que
6
+ // le programme aura accès à tout son ordinateur. Un connecteur distant ne s'installe pas — on
7
+ // colle une adresse, on clique « Se connecter », et rien ne descend sur la machine. C'est aussi
8
+ // la seule forme que ChatGPT accepte.
9
+ //
10
+ // Ce module ne réécrit rien : il prend le serveur MCP existant (src/server.js, ses 35 outils,
11
+ // ses garde-fous) et le branche sur le transport HTTP du SDK. Ce qui change, c'est QUI parle :
12
+ //
13
+ // stdio un seul utilisateur, sa configuration vient des variables d'environnement
14
+ // HTTP autant d'utilisateurs que de requêtes, chacun avec son compte, sa licence,
15
+ // ses projets — d'où `configPour(authInfo)`, appelé à chaque nouvelle session.
16
+ //
17
+ // Le thème, lui, est COMMUN : c'est la même version publiée pour tout le monde. Une seule
18
+ // copie sur le serveur, partagée par toutes les sessions (voir `sourcePartagee`), au lieu d'un
19
+ // téléchargement par client.
20
+
21
+ import crypto from 'node:crypto';
22
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
23
+ import { createServer } from './server.js';
24
+
25
+ /** En-tête de session du protocole : le client le renvoie à chaque requête suivante. */
26
+ const ENTETE_SESSION = 'mcp-session-id';
27
+
28
+ /**
29
+ * Une session MCP = un serveur + un transport, gardés en mémoire entre deux requêtes.
30
+ * Fermée au bout de `inactiviteMs` sans rien : un client qui ferme Claude ne laisse pas sa
31
+ * session ouverte indéfiniment.
32
+ */
33
+ class Sessions {
34
+ constructor({ inactiviteMs = 30 * 60 * 1000, max = 500 } = {}) {
35
+ this.parId = new Map();
36
+ this.inactiviteMs = inactiviteMs;
37
+ this.max = max;
38
+ }
39
+
40
+ get(id) {
41
+ const session = this.parId.get(id);
42
+ if (session) session.vueA = Date.now();
43
+ return session;
44
+ }
45
+
46
+ set(id, session) {
47
+ this.parId.set(id, { ...session, vueA: Date.now() });
48
+ this.nettoyer();
49
+ }
50
+
51
+ supprimer(id) {
52
+ const session = this.parId.get(id);
53
+ if (!session) return;
54
+ this.parId.delete(id);
55
+ session.transport.close?.().catch(() => {});
56
+ }
57
+
58
+ /** Purge à l'écriture plutôt qu'au minuteur : pas de timer qui tourne dans le vide. */
59
+ nettoyer() {
60
+ const limite = Date.now() - this.inactiviteMs;
61
+ for (const [id, session] of this.parId) {
62
+ if (session.vueA < limite) this.supprimer(id);
63
+ }
64
+ // Garde-fou de dernier recours : au-delà du plafond, les plus anciennes partent.
65
+ if (this.parId.size > this.max) {
66
+ const triees = [...this.parId.entries()].sort((a, b) => a[1].vueA - b[1].vueA);
67
+ for (const [id] of triees.slice(0, this.parId.size - this.max)) this.supprimer(id);
68
+ }
69
+ }
70
+
71
+ get taille() {
72
+ return this.parId.size;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Le gestionnaire HTTP du MCP, à monter sur une route Express (POST, GET et DELETE).
78
+ *
79
+ * @param {object} options
80
+ * @param {(authInfo: object) => Promise<object>|object} options.configPour
81
+ * Rend la configuration du serveur MCP pour l'utilisateur authentifié : sa langue, son
82
+ * dossier de projets, la source du thème. C'est ici que l'espace membre vérifie la
83
+ * licence — lever une erreur refuse la session.
84
+ * @param {object} [options.sessions] — remplaçable dans les tests
85
+ * @returns {{ handler: Function, sessions: Sessions }}
86
+ */
87
+ export function createHttpHandler({ configPour, sessions = new Sessions() }) {
88
+ const handler = async (req, res) => {
89
+ const idSession = req.headers[ENTETE_SESSION];
90
+
91
+ // Requête suivante d'une session connue : on repasse au transport qui la porte.
92
+ if (idSession && sessions.get(idSession)) {
93
+ const { transport } = sessions.get(idSession);
94
+ if (req.method === 'DELETE') sessions.supprimer(idSession);
95
+ return transport.handleRequest(req, res, req.body);
96
+ }
97
+
98
+ // Un identifiant de session inconnu n'est pas une erreur du client : sa session a pu
99
+ // expirer pendant qu'il déjeunait. On le dit dans les termes du protocole, il rouvrira.
100
+ if (idSession) {
101
+ return res.status(404).json({
102
+ jsonrpc: '2.0',
103
+ error: { code: -32001, message: 'Session expirée. Reconnectez-vous.' },
104
+ id: null,
105
+ });
106
+ }
107
+
108
+ if (req.method !== 'POST') {
109
+ return res.status(405).json({
110
+ jsonrpc: '2.0',
111
+ error: { code: -32000, message: 'Method Not Allowed' },
112
+ id: null,
113
+ });
114
+ }
115
+
116
+ // Nouvelle session : c'est le seul moment où l'on regarde qui parle.
117
+ let config;
118
+ try {
119
+ config = await configPour(req.auth);
120
+ } catch (error) {
121
+ return res.status(403).json({
122
+ jsonrpc: '2.0',
123
+ error: { code: -32000, message: error.message },
124
+ id: null,
125
+ });
126
+ }
127
+
128
+ const transport = new StreamableHTTPServerTransport({
129
+ sessionIdGenerator: () => crypto.randomUUID(),
130
+ onsessioninitialized: (id) => sessions.set(id, { transport, server }),
131
+ // JSON plutôt que SSE : nos outils répondent d'un coup, aucun ne diffuse en continu.
132
+ enableJsonResponse: true,
133
+ });
134
+
135
+ const server = createServer(config);
136
+ transport.onclose = () => {
137
+ if (transport.sessionId) sessions.supprimer(transport.sessionId);
138
+ };
139
+
140
+ await server.connect(transport);
141
+ return transport.handleRequest(req, res, req.body);
142
+ };
143
+
144
+ return { handler, sessions };
145
+ }
146
+
147
+ export { Sessions };
package/src/server.js CHANGED
@@ -26,6 +26,7 @@ import { GUIDES, GUIDE_TOPICS } from './knowledge/guides.js';
26
26
  import { RECIPES, findRecipe } from './knowledge/recipes.js';
27
27
  import { FONT_PAIRINGS, searchFonts, checkFontHandle, fontInfo, FONT_LIBRARY } from './knowledge/fonts.js';
28
28
  import { ok, fail, parseContent, formatSettings, formatValidation, formatFindings } from './format.js';
29
+ import { registerShopTools } from './shops/tools.js';
29
30
 
30
31
  export { VERSION } from './version.js';
31
32
  import { VERSION } from './version.js';
@@ -257,6 +258,7 @@ export function createServer(config) {
257
258
  '- Construire : create_project, update_brief, apply_recipe, localize_template, list_texts, edit_template, write_file, update_theme_settings, read_file, exclude_template',
258
259
  '- Contrôler : validate_json, review_project, undo, revert_file',
259
260
  '- Livrer : export_theme (zip + check-list) ; import_theme pour auditer un thème exporté d’une boutique',
261
+ '- Vos boutiques en direct (appli Story Apps) : list_shops, shops_overview, shop_graphql',
260
262
  `Guides : ${GUIDE_TOPICS.join(', ')}`,
261
263
  '',
262
264
  `## Projets (${projects.length})`,
@@ -1221,14 +1223,29 @@ Read the file first (read_file) to get ids.`,
1221
1223
  inputSchema: { project: PROJECT },
1222
1224
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
1223
1225
  },
1224
- run(({ project }) => {
1226
+ run(async ({ project }) => {
1225
1227
  const { store, catalog } = ctx.get();
1226
1228
  project = store.resolve(project);
1227
1229
  const result = exportTheme({ catalog, store, slug: project });
1228
1230
  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
1231
  const notReady = /pas prêt à publier/.test(result.report);
1230
1232
  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}`);
1233
+
1234
+ // Servi à distance (connecteur), le chemin du zip ne veut rien dire pour le marchand :
1235
+ // le fichier est sur le serveur, pas chez lui. `publishExport` le dépose alors dans son
1236
+ // espace et rend un lien de téléchargement, qui remplace le chemin dans la réponse.
1237
+ let livraison = `${result.zip} (${result.size_mb} Mo, ${result.files} fichiers, personnalisés : ${result.overlays.join(', ') || 'aucun'})\nCheck-list : ${result.checklist}`;
1238
+ if (typeof config.publishExport === 'function') {
1239
+ try {
1240
+ const lien = await config.publishExport({ zip: result.zip, checklist: result.checklist, project, catalog: catalog.version });
1241
+ if (lien) livraison = `${lien} (${result.size_mb} Mo, ${result.files} fichiers, personnalisés : ${result.overlays.join(', ') || 'aucun'})`;
1242
+ } catch (error) {
1243
+ // L'export a réussi : un dépôt raté ne doit pas le transformer en échec.
1244
+ livraison = `${result.zip} — ⚠️ mise en ligne du fichier impossible (${error.message})`;
1245
+ }
1246
+ }
1247
+
1248
+ 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é : ${livraison}\n\n${result.report}`);
1232
1249
  }),
1233
1250
  );
1234
1251
 
@@ -1346,5 +1363,8 @@ Read the file first (read_file) to get ids.`,
1346
1363
  );
1347
1364
  }
1348
1365
 
1366
+ // Boutiques du compte, via l'appli Shopify Story Apps (src/shops/).
1367
+ registerShopTools(server, config);
1368
+
1349
1369
  return server;
1350
1370
  }
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Version du serveur MCP. Ici et dans package.json — les deux doivent être identiques
2
2
  // (npm test le vérifie), et rien d'autre ne la redéclare.
3
- export const VERSION = '1.0.0';
3
+ export const VERSION = '1.1.0';