@nodefony/devkit 10.0.0-beta.3 → 10.0.0-beta.5

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.
Files changed (55) hide show
  1. package/README.md +146 -4
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +25 -226
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  18. package/dist/nodefony/src/profileSession.js +14 -0
  19. package/dist/types/index.d.ts +15 -1
  20. package/dist/types/nodefony/config/config.d.ts +1 -1
  21. package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
  22. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  23. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  24. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  25. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  26. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  27. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  28. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  29. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  30. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  31. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  32. package/docs/index.md +160 -11
  33. package/package.json +16 -11
  34. package/skills/nodefony-add-crud/SKILL.md +10 -1
  35. package/skills/nodefony-add-service/SKILL.md +3 -3
  36. package/skills/nodefony-authentication/SKILL.md +5 -5
  37. package/skills/nodefony-browser/SKILL.md +20 -0
  38. package/skills/nodefony-browser/references/socket.md +13 -4
  39. package/skills/nodefony-browser/references/sondes.md +13 -1
  40. package/skills/nodefony-browser/scripts/inspect.mjs +55 -8
  41. package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
  42. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  43. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  44. package/skills/nodefony-dev/SKILL.md +31 -1
  45. package/skills/nodefony-dev/scripts/docs.mjs +3 -3
  46. package/skills/nodefony-devops/SKILL.md +85 -64
  47. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  48. package/skills/nodefony-devops/references/frontal.md +277 -47
  49. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  50. package/skills/nodefony-devops/references/secrets.md +13 -2
  51. package/skills/nodefony-devops/references/variables.md +14 -12
  52. package/skills/nodefony-protect-route/SKILL.md +6 -4
  53. package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
  54. package/skills/nodefony-troubleshoot/SKILL.md +86 -12
  55. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -0,0 +1,696 @@
1
+ import { observeTool } from "./observe.js";
2
+ import { profileRouteTool } from "./profileRoute.js";
3
+ import { ADMIN_SCOPE_READ, INSPECT_SUBJECTS, adminReadCatalog, callAdminEndpoint, collectDoctorReport, countCheckFindings, findAdminReadEntry, lookupSymbol, outlineMarkdown, readAdminSubject, readSymbolsGraph, skippedChecks } from "nodefony";
4
+ import { MCP_RESERVED_PREFIX, MCP_TEXT_MAX_CHARS, mcpText } from "@nodefony/ai";
5
+ // nodefony/mcp/tools.ts
6
+ /**
7
+ * Outils MCP du framework : le catalogue `nodefony_*`, que `@nodefony/devkit`
8
+ * déclare par `getMcpTools()` comme n'importe quel module d'application.
9
+ *
10
+ * ⚠️ **Rien n'est calculé ici.** Chaque outil intégré traduit un appel JSON-RPC
11
+ * vers une brique qui répond DÉJÀ à une autre porte : `inspect` lit le plan
12
+ * d'administration par {@link readAdminSubject} (la même fonction que la
13
+ * commande `nodefony inspect`), `check` appelle {@link collectDoctorReport} (la
14
+ * même que `nodefony doctor`). Une source, plusieurs portes — un outil qui
15
+ * recalculerait sa réponse finirait par contredire la commande, et c'est lui
16
+ * qu'on croirait sur parole.
17
+ *
18
+ * ⭐ **La description d'un outil est le premier critère de son déclenchement.**
19
+ * Un modèle n'appelle pas ce qu'il ne comprend pas : le POC de 2026-05 l'a payé
20
+ * cash — un outil à description neutre n'a jamais été appelé, un skill
21
+ * auto-déclenché prenait la main à chaque fois. Ces descriptions disent donc ce
22
+ * que l'outil rend ET quand s'en servir, pas seulement son nom.
23
+ */
24
+ /**
25
+ * Marge laissée au corps d'un refus. Un producteur qui explique pourquoi il
26
+ * refuse tient en quelques centaines d'octets (un plan de page, une liste de
27
+ * valeurs) ; au-delà, c'est une donnée déguisée en message d'erreur, et une
28
+ * réponse démesurée sature la fenêtre de celui qui la lit.
29
+ */
30
+ const MCP_ERROR_BODY_MAX_CHARS = 4e3;
31
+ /**
32
+ * Rend un refus du plan d'administration — la phrase ET ce que le producteur a
33
+ * joint pour la corriger.
34
+ *
35
+ * ⚠️ Rendre la seule phrase faisait conclure l'inverse de la vérité : demander
36
+ * une section absente d'une page PRÉSENTE produit « … introuvable (404) », et
37
+ * l'agent en déduisait que la page n'existait pas — alors que le producteur
38
+ * joignait le plan de cette page, c'est-à-dire la réponse. Une porte qui résume
39
+ * un refus lui fait dire autre chose.
40
+ *
41
+ * @param read - l'échec rendu par la lecture, corps du producteur inclus.
42
+ * @returns le résultat d'outil, en erreur, borné en volume.
43
+ */
44
+ function mcpAdminFailure(read) {
45
+ if (read.body === void 0 || read.body === null) return mcpText(read.message, true);
46
+ const rendered = JSON.stringify(read.body, null, 2);
47
+ const body = rendered.length <= MCP_ERROR_BODY_MAX_CHARS ? rendered : `${rendered.slice(0, MCP_ERROR_BODY_MAX_CHARS)}\n… body du refus tronqué (${rendered.length} caractères)`;
48
+ return mcpText(`${read.message}\n\n${body}`, true);
49
+ }
50
+ /**
51
+ * Marge laissée à l'enveloppe JSON d'une page : `frontmatter`, `slug`, guillemets
52
+ * échappés. Sous-estimer ferait retomber la page dans le résumé générique — celui
53
+ * qui rend des CLÉS, c'est-à-dire rien d'utile sur un markdown.
54
+ */
55
+ const DOC_ENVELOPE_CHARS = 4e3;
56
+ /**
57
+ * Une page de documentation, ou son PLAN quand elle est trop lourde.
58
+ *
59
+ * Le résumé générique ({@link summarize}) sait ramener une liste à son compte et un
60
+ * objet à ses clés ; sur une page de 78 ko il rendrait
61
+ * `keys: [slug, frontmatter, markdown]` et conseillerait « demande une branche
62
+ * précise » — une phrase qui ne désigne aucun geste possible. Le plan, lui, se lit
63
+ * en quelques centaines d'octets et nomme exactement ce qu'il faut redemander.
64
+ *
65
+ * @param data - ce que le producteur a rendu pour la page
66
+ * @param module - module interrogé, pour rédiger le geste suivant
67
+ * @param slug - page interrogée, idem
68
+ */
69
+ function pageOrOutline(data, module, slug) {
70
+ const page = data;
71
+ const markdown = typeof page?.markdown === "string" ? page.markdown : null;
72
+ if (markdown === null || markdown.length <= MCP_TEXT_MAX_CHARS - DOC_ENVELOPE_CHARS) return data;
73
+ return {
74
+ ...data,
75
+ markdown: void 0,
76
+ chars: markdown.length,
77
+ note: `cette page pèse ${markdown.length} caractères — voici son PLAN. Rappelle l'outil avec section: "<titre>" (module: "${module}", slug: "${slug}") pour n'obtenir que le passage utile.`,
78
+ outline: outlineMarkdown(markdown)
79
+ };
80
+ }
81
+ /**
82
+ * Clé de module telle que le kernel la connaît, depuis un nom de package npm.
83
+ *
84
+ * Le graphe symbolique indexe par nom npm (`@nodefony/http`) ; le plan
85
+ * d'administration, lui, désigne les modules par leur clé courte (`http`), et
86
+ * le socle sous le nom logique `core` — son package réel s'appelant `nodefony`
87
+ * pour des raisons d'histoire. Deux vocabulaires pour une même chose : la
88
+ * traduction vit ICI, une fois, plutôt que dans chaque appelant.
89
+ */
90
+ function moduleKey(packageName) {
91
+ if (packageName === "nodefony" || packageName === "@nodefony/core") return "core";
92
+ return packageName.replace(/^@nodefony\//u, "");
93
+ }
94
+ /**
95
+ * Largeur maximale d'un résumé dans le catalogue d'administration.
96
+ *
97
+ * Le catalogue vaut par sa densité : soixante lignes qu'on parcourt d'un coup
98
+ * d'œil. Un résumé de trois phrases y ferait perdre la colonne de gauche — le
99
+ * chemin, c'est-à-dire la seule chose qu'on recopie ensuite.
100
+ */
101
+ const CATALOG_SUMMARY_MAX = 96;
102
+ /** Largeur de la colonne des chemins, au-delà de laquelle on cesse d'aligner. */
103
+ const CATALOG_PATH_COLUMN = 34;
104
+ /**
105
+ * Budget du catalogue rendu, sous la borne générique de {@link mcpText}.
106
+ *
107
+ * 🔴 Il ne s'agit pas de politesse : au-delà de sa borne, `mcpText` remplace une
108
+ * CHAÎNE par une note générique — le résumé sait ramener une liste à son compte
109
+ * et un objet à ses clés, mais d'un texte il ne garde rien. Le catalogue
110
+ * disparaîtrait donc en entier, en conseillant « demande une branche précise »,
111
+ * un geste qui ne désigne rien ici. On borne nous-mêmes, en gardant des entrées
112
+ * ET en nommant les deux restrictions qui EXISTENT.
113
+ */
114
+ const CATALOG_MAX_CHARS = 24e3;
115
+ /**
116
+ * Met le catalogue en forme pour un lecteur qui va s'en servir tout de suite.
117
+ *
118
+ * Texte plutôt que JSON, et ce n'est pas une question de goût : la même
119
+ * information sérialisée en objets indentés pèse environ trois fois plus, pour
120
+ * une porte dont l'unique raison d'être est de ne pas coûter de contexte. Le
121
+ * groupement par producteur porte la seule structure nécessaire — le producteur
122
+ * est le premier argument de l'appel qui suit.
123
+ *
124
+ * @param view - ce que le cœur a retenu, et ce qu'il a écarté.
125
+ * @returns le catalogue rendu, écarts annoncés.
126
+ */
127
+ function renderAdminCatalog(view) {
128
+ const gaps = [];
129
+ if (view.mutations > 0) gaps.push(`${view.mutations} mutations (POST/PATCH/DELETE — cette porte ne fait que LIRE)`);
130
+ if (view.selfService > 0) gaps.push(`${view.selfService} self-service liés à une session (« me », « sessions/mine ») — cette porte n'a pas d'utilisateur connecté`);
131
+ if (view.denied > 0) gaps.push(`${view.denied} hors des droits de ce jeton`);
132
+ if (view.filtered > 0) gaps.push(`${view.filtered} hors du filtre demandé`);
133
+ const lines = [`${view.entries.length} lectures appelables — sur ${view.total} endpoints déclarés par cette application.`, `Appel : nodefony_admin_call { namespace, path, params?, query? } — recopier « path » TEL QUEL, variables comprises.`];
134
+ if (gaps.length > 0) lines.push(`Non listés : ${gaps.join(" · ")}.`);
135
+ let namespace = null;
136
+ for (const entry of view.entries) {
137
+ if (entry.namespace !== namespace) {
138
+ namespace = entry.namespace;
139
+ const count = view.entries.filter((other) => other.namespace === namespace).length;
140
+ lines.push("", `## ${namespace} (${count})`);
141
+ }
142
+ const summary = entry.summary.length > CATALOG_SUMMARY_MAX ? `${entry.summary.slice(0, 95)}…` : entry.summary;
143
+ const suffix = summary === "" ? "" : ` ${summary}`;
144
+ lines.push(`${entry.path.padEnd(CATALOG_PATH_COLUMN)}${suffix}`.trimEnd());
145
+ const caps = [];
146
+ if (entry.page?.sortable.length) caps.push(`tri: ${entry.page.sortable.join(",")}`);
147
+ if (entry.page?.filters.length) caps.push(`filtres: ${entry.page.filters.join(",")}`);
148
+ if (entry.page?.search) caps.push("recherche q");
149
+ if (caps.length > 0) lines.push(`${" ".repeat(4)}${caps.join(" | ")}`);
150
+ }
151
+ if (view.entries.length === 0) lines.push("", "(aucune entrée ne répond à cette demande)");
152
+ const rendered = lines.join("\n");
153
+ if (rendered.length <= CATALOG_MAX_CHARS) return rendered;
154
+ const cut = rendered.slice(0, CATALOG_MAX_CHARS);
155
+ return `${cut.slice(0, cut.lastIndexOf("\n"))}\n\n… catalogue tronqué (${rendered.length} caractères pour ${view.entries.length} entrées). Rappelle l'outil avec namespace: "<producteur>" ou q: "<termes>" pour voir le reste.`;
156
+ }
157
+ function stringTable(value, field, arrays) {
158
+ if (value === void 0 || value === null) return {
159
+ ok: true,
160
+ value: {}
161
+ };
162
+ if (typeof value !== "object" || Array.isArray(value)) return {
163
+ ok: false,
164
+ why: `« ${field} » doit être un objet { clé: valeur }`
165
+ };
166
+ const table = {};
167
+ for (const [key, raw] of Object.entries(value)) {
168
+ if (typeof raw === "string") {
169
+ table[key] = raw;
170
+ continue;
171
+ }
172
+ if (typeof raw === "number" || typeof raw === "boolean") {
173
+ table[key] = String(raw);
174
+ continue;
175
+ }
176
+ if (arrays && Array.isArray(raw) && raw.every((item) => typeof item === "string")) {
177
+ table[key] = raw;
178
+ continue;
179
+ }
180
+ return {
181
+ ok: false,
182
+ why: `« ${field}.${key} » n'est pas une valeur transportable — attendu du texte${arrays ? " ou une liste de textes" : ""}`
183
+ };
184
+ }
185
+ return {
186
+ ok: true,
187
+ value: table
188
+ };
189
+ }
190
+ /** Lignes de journal rapportées par requête en erreur. */
191
+ const LOG_LINES_PER_REQUEST = 8;
192
+ /** Lignes lues par requête avant filtrage par sévérité. */
193
+ const LOG_SCAN_PER_REQUEST = 50;
194
+ /** Longueur maximale d'une ligne de journal rapportée. */
195
+ const LOG_LINE_MAX = 300;
196
+ /**
197
+ * Sévérité la plus faible rapportée (RFC 5424 : 4 = WARNING). Le DEBUG d'une
198
+ * requête n'aide pas l'agent à trouver une erreur — et il peut porter ce qui
199
+ * ne doit pas entrer dans son contexte : la valeur d'un cookie de session
200
+ * posé par la réponse, par exemple.
201
+ */
202
+ const LOG_MAX_SEVERITY = 4;
203
+ /**
204
+ * Une ligne de journal lisible pour l'agent, ou `null` si elle est sous le
205
+ * seuil de sévérité ou illisible. Le texte vit dans `payload` (chaîne, ou
206
+ * erreur) ; `msg` est souvent vide, `msgid` dit le contexte.
207
+ */
208
+ function logLine(row) {
209
+ if (typeof row !== "object" || row === null) return null;
210
+ const severity = "severity" in row && typeof row.severity === "number" ? row.severity : 7;
211
+ if (severity > LOG_MAX_SEVERITY) return null;
212
+ const name = "severityName" in row && typeof row.severityName === "string" ? row.severityName : String(severity);
213
+ const where = "msgid" in row && typeof row.msgid === "string" && row.msgid.trim() !== "" ? row.msgid.trim() : "moduleName" in row && typeof row.moduleName === "string" ? row.moduleName : "?";
214
+ const payload = "payload" in row ? row.payload : void 0;
215
+ let text = "msg" in row && typeof row.msg === "string" ? row.msg : "";
216
+ if (typeof payload === "string" && payload !== "") text = payload;
217
+ else if (typeof payload === "object" && payload !== null && "message" in payload && typeof payload.message === "string") text = payload.message;
218
+ if (text === "") return null;
219
+ const line = `${name} [${where}] ${text.replace(/\s+/g, " ")}`;
220
+ return line.length > LOG_LINE_MAX ? `${line.slice(0, LOG_LINE_MAX)}…` : line;
221
+ }
222
+ /**
223
+ * Lit le journal d'UNE requête dans le syslog de l'application, par le plan
224
+ * d'administration — la même voie, et la même autorisation, que
225
+ * `nodefony_admin_call { namespace: "syslog", path: "logs" }`. Le driver de
226
+ * relecture actif répond (mémoire par défaut, fichier ou Redis si configuré).
227
+ *
228
+ * @returns les lignes, de la plus ancienne à la plus récente, ou la raison
229
+ * pour laquelle elles ne sont pas lisibles ici.
230
+ */
231
+ async function readRequestLogs(broker, requestId, caller) {
232
+ const admin = adminCallerFromMcp(caller);
233
+ if (findAdminReadEntry(broker, admin, "syslog", "logs") === null) return {
234
+ kind: "unavailable",
235
+ reason: broker?.list().some((api) => api.adminNamespace === "syslog") === true ? "journal réservé aux appelants authentifiés (scope admin:read) — présenter un jeton MCP : `npx nodefony security:mcp:token --write`" : "aucun journal lisible : le producteur `syslog` du plan d'administration n'est pas chargé (`@nodefony/framework` absent ?)"
236
+ };
237
+ const read = await callAdminEndpoint(broker, {
238
+ namespace: "syslog",
239
+ path: "logs",
240
+ method: "GET",
241
+ params: {},
242
+ query: {
243
+ requestId,
244
+ limit: String(LOG_SCAN_PER_REQUEST)
245
+ },
246
+ label: "syslog/logs"
247
+ }, admin);
248
+ if (!read.ok) return {
249
+ kind: "unavailable",
250
+ reason: read.message
251
+ };
252
+ const lines = [];
253
+ const rows = Array.isArray(read.data) ? read.data : [];
254
+ for (const row of rows) {
255
+ const line = logLine(row);
256
+ if (line !== null && lines.length < LOG_LINES_PER_REQUEST) lines.push(line);
257
+ }
258
+ const hidden = rows.length - lines.length;
259
+ lines.reverse();
260
+ return {
261
+ kind: "lines",
262
+ lines,
263
+ hidden
264
+ };
265
+ }
266
+ /**
267
+ * Préfixe des noms d'outils du framework — un agent voit à qui il parle. Sa
268
+ * réserve aux modules `@nodefony/*` vit dans `@nodefony/ai`, à la collecte.
269
+ */
270
+ const PREFIX = MCP_RESERVED_PREFIX;
271
+ /**
272
+ * Ce que promettent les outils intégrés : ils ne font que LIRE l'état de
273
+ * l'application (même `admin_call`, qui n'atteint que le catalogue de lecture),
274
+ * et ne sortent pas de l'application. Partagé : un objet figé ne retient rien.
275
+ */
276
+ const READ_ONLY_CLOSED = Object.freeze({
277
+ readOnlyHint: true,
278
+ openWorldHint: false
279
+ });
280
+ /**
281
+ * Clés des outils intégrés — ce que l'allowlist de configuration nomme.
282
+ *
283
+ * Publiée pour qu'un schéma de configuration puisse la valider au lieu de la
284
+ * retaper : une clé inconnue en config est silencieusement sans effet, et
285
+ * l'utilisateur croit avoir activé quelque chose.
286
+ */
287
+ const BUILTIN_MCP_TOOL_KEYS = [
288
+ "inspect",
289
+ "card",
290
+ "check",
291
+ "symbols",
292
+ "docs",
293
+ "admin_list",
294
+ "admin_call",
295
+ "observe",
296
+ "profile_route"
297
+ ];
298
+ /**
299
+ * Liste des sujets, rendue lisible pour la description de l'outil.
300
+ *
301
+ * Dérivée de {@link INSPECT_SUBJECTS} : ajouter un sujet au cœur le publie ici
302
+ * sans rien réécrire, et il ne peut pas exister de sujet annoncé qu'on ne
303
+ * saurait pas lire.
304
+ */
305
+ function subjectLines() {
306
+ return Object.entries(INSPECT_SUBJECTS).map(([key, spec]) => `- \`${key}\` : ${spec.summary}`).join("\n");
307
+ }
308
+ /**
309
+ * Regroupe les contrôles sautés qui disent exactement la même chose.
310
+ *
311
+ * Sur l'application de développement, sept familles sautées partagent trois
312
+ * raisons : les répéter famille par famille coûtait ~1 800 caractères à chaque
313
+ * appel, relus ensuite à chaque tour de l'agent.
314
+ *
315
+ * @param skipped - les contrôles sautés, dans l'ordre du rapport
316
+ * @returns un groupe par raison distincte, dans l'ordre de première apparition
317
+ */
318
+ function groupSkippedChecks(skipped) {
319
+ const groups = /* @__PURE__ */ new Map();
320
+ for (const { family, title, ...said } of skipped) {
321
+ const key = JSON.stringify(said);
322
+ const group = groups.get(key);
323
+ if (group) group.families[family] = title;
324
+ else groups.set(key, {
325
+ families: { [family]: title },
326
+ ...said
327
+ });
328
+ }
329
+ return [...groups.values()];
330
+ }
331
+ /**
332
+ * Taille le rapport de diagnostic pour un AGENT : les mêmes faits, sans redite.
333
+ *
334
+ * Le `--json` de `nodefony doctor` reste complet ; seule la porte MCP taille,
335
+ * parce que ce qu'elle rend se relit à chaque tour suivant de l'agent. Trois
336
+ * coupes : `execution` (que `skipped` résume déjà), les raisons de `skipped`
337
+ * regroupées ({@link groupSkippedChecks}), les liens `open` des derniers
338
+ * démarrages (faits pour un humain qui clique).
339
+ *
340
+ * @param report - le rapport de `collectDoctorReport`
341
+ * @returns le verdict, son compte, puis le rapport sans redite
342
+ */
343
+ function compactDoctorReport(report) {
344
+ const total = countCheckFindings(report);
345
+ const skipped = skippedChecks(report.execution);
346
+ const { execution: _execution, lastBoots, ...rest } = report;
347
+ return {
348
+ verdict: total > 0 ? "manquements" : skipped.length > 0 ? "ok-mais-incomplet" : "ok",
349
+ total,
350
+ skipped: groupSkippedChecks(skipped),
351
+ ...rest,
352
+ lastBoots: lastBoots.map(({ open: _open, ...boot }) => boot)
353
+ };
354
+ }
355
+ /**
356
+ * Catalogue intégré du framework, indexé par clé d'allowlist.
357
+ *
358
+ * Chaque handler capture ses dépendances par fermeture : c'est ce qui permet à
359
+ * ces outils d'être des {@link IMcpTool} ordinaires, du même type que
360
+ * ceux d'une application — le serveur n'a plus aucun cas particulier à traiter.
361
+ *
362
+ * @param deps - briques qui répondent réellement
363
+ */
364
+ function builtinMcpTools(deps) {
365
+ return {
366
+ inspect: {
367
+ name: `${PREFIX}inspect`,
368
+ annotations: READ_ONLY_CLOSED,
369
+ description: `Lit l'état RÉEL de cette application Nodefony : les routes réellement montées, les modules chargés, les services enregistrés, la configuration effective et la provenance de chaque valeur, les stores de données, les entités de l'ORM. À utiliser avant d'écrire du code qui suppose une route, un service ou une clé de configuration — la réponse vient de l'application qui tourne, pas d'une lecture des sources, donc elle ne peut pas se tromper sur ce qui est chargé.
370
+
371
+ Sujets disponibles :\n${subjectLines()}`,
372
+ inputSchema: {
373
+ type: "object",
374
+ properties: {
375
+ subject: {
376
+ type: "string",
377
+ enum: Object.keys(INSPECT_SUBJECTS),
378
+ description: "Ce qu'on veut voir"
379
+ },
380
+ target: {
381
+ type: "string",
382
+ description: "Paramètre du sujet, quand il en attend un (ex. le nom d'un module pour `module`)"
383
+ }
384
+ },
385
+ required: ["subject"]
386
+ },
387
+ handler: async (args, caller) => {
388
+ const subject = typeof args.subject === "string" ? args.subject : "";
389
+ const target = typeof args.target === "string" ? args.target : void 0;
390
+ const read = await readAdminSubject(deps.broker, subject, adminCallerFromMcp(caller), target);
391
+ return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
392
+ }
393
+ },
394
+ card: {
395
+ name: `${PREFIX}card`,
396
+ annotations: READ_ONLY_CLOSED,
397
+ description: "Carte de visite de l'application : son identité, les modules chargés, où trouver la documentation, et les commandes à lancer. À utiliser en ARRIVANT sur une application inconnue, avant toute autre exploration — elle dit en un appel ce qu'il y a et où aller ensuite.",
398
+ inputSchema: {
399
+ type: "object",
400
+ properties: {}
401
+ },
402
+ handler: () => mcpText(deps.getCard())
403
+ },
404
+ check: {
405
+ name: `${PREFIX}check`,
406
+ annotations: READ_ONLY_CLOSED,
407
+ description: "Diagnostic STATIQUE de l'application : classes écrites que rien n'enregistre (entité, controller ou service jamais câblé), paquets importés sans être déclarés, variables d'environnement requises absentes, modules du manifeste non installés, ports occupés, et le bilan du dernier démarrage. À utiliser APRÈS avoir écrit ou généré du code, avant de conclure que c'est fini : ni la compilation ni les tests ne voient qu'une classe n'est branchée à rien.",
408
+ inputSchema: {
409
+ type: "object",
410
+ properties: {}
411
+ },
412
+ handler: async () => mcpText(compactDoctorReport(await collectDoctorReport(deps.projectRoot)))
413
+ },
414
+ docs: {
415
+ name: `${PREFIX}docs`,
416
+ annotations: READ_ONLY_CLOSED,
417
+ description: "Cherche et lit la DOCUMENTATION des modules installés — la prose écrite par leurs auteurs : concepts, recettes, pièges, exemples. À utiliser AVANT d'écrire du code qui utilise une brique du framework, et avant de conclure qu'une capacité n'existe pas. ⭐ Cette documentation vit sous `node_modules`, que les outils de recherche de fichiers EXCLUENT par défaut (dossier ignoré par git) : elle est livrée avec les paquets mais introuvable autrement qu'ici.\n\nSans argument : ce que l'application documente, module par module. Avec `query` : les pages qui portent tous les termes, avec leurs extraits. Avec `module` et `slug` : la page. Une page pèse souvent plus que ce qu'une réponse peut porter — demander alors `outline` pour son plan, puis `section` pour le seul passage utile.",
418
+ inputSchema: {
419
+ type: "object",
420
+ properties: {
421
+ query: {
422
+ type: "string",
423
+ description: "Texte cherché dans toute la documentation ; les espaces séparent des termes CUMULATIFS (`session redis` = les deux)"
424
+ },
425
+ module: {
426
+ type: "string",
427
+ description: "Module dont on veut la documentation (`http`, `security`, `core`…)"
428
+ },
429
+ slug: {
430
+ type: "string",
431
+ description: "Page à lire, telle que la recherche ou l'index la nomme"
432
+ },
433
+ section: {
434
+ type: "string",
435
+ description: "Titre de la seule section voulue (approchant suffit : sans accent, sans casse, sans emoji)"
436
+ },
437
+ outline: {
438
+ type: "boolean",
439
+ description: "Rendre le PLAN de la page au lieu de son body — le premier geste sur une page volumineuse"
440
+ },
441
+ limit: {
442
+ type: "integer",
443
+ description: "Nombre maximum de documents rendus par `query` (défaut 20)"
444
+ }
445
+ }
446
+ },
447
+ handler: async (args, caller) => {
448
+ const admin = adminCallerFromMcp(caller);
449
+ const module = typeof args.module === "string" ? args.module : "";
450
+ const slug = typeof args.slug === "string" ? args.slug : "";
451
+ const query = typeof args.query === "string" ? args.query.trim() : "";
452
+ const section = typeof args.section === "string" ? args.section : "";
453
+ if (module !== "" && slug !== "") {
454
+ const read = await callAdminEndpoint(deps.broker, {
455
+ namespace: "kernel",
456
+ path: "module/{name}/docs/{slug}",
457
+ params: {
458
+ name: module,
459
+ slug
460
+ },
461
+ query: section !== "" ? { section } : args.outline === true ? { outline: "1" } : {},
462
+ label: `${module}/${slug}`
463
+ }, admin);
464
+ if (!read.ok) return mcpAdminFailure(read);
465
+ return mcpText(pageOrOutline(read.data, module, slug));
466
+ }
467
+ if (query !== "") {
468
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.trunc(args.limit) : null;
469
+ const read = await callAdminEndpoint(deps.broker, {
470
+ namespace: "kernel",
471
+ path: "docs/search",
472
+ query: limit !== null && limit > 0 ? {
473
+ q: query,
474
+ limit: String(limit)
475
+ } : { q: query },
476
+ label: `recherche « ${query} »`
477
+ }, admin);
478
+ return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
479
+ }
480
+ const read = module !== "" ? await callAdminEndpoint(deps.broker, {
481
+ namespace: "kernel",
482
+ path: "module/{name}/docs",
483
+ params: { name: module },
484
+ label: module
485
+ }, admin) : await callAdminEndpoint(deps.broker, {
486
+ namespace: "kernel",
487
+ path: "docs",
488
+ label: "documentation"
489
+ }, admin);
490
+ return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
491
+ }
492
+ },
493
+ symbols: {
494
+ name: `${PREFIX}symbols`,
495
+ annotations: READ_ONLY_CLOSED,
496
+ description: "Donne la SIGNATURE RÉELLE d'un symbole du framework — ses paramètres, ses types de retour, sa documentation — plus ce qu'il étend ou implémente et où il est défini. À utiliser AVANT d'appeler une classe, une interface ou une fonction du framework, plutôt que d'en deviner les arguments. ⭐ La déclaration est lue dans les types LIVRÉS du paquet installé (`node_modules`, que les outils de recherche de fichiers excluent) : c'est la version qui tourne vraiment, pas une supposition. Sans argument, rend le résumé du graphe ; avec `module`, la surface exportée d'un paquet.",
497
+ inputSchema: {
498
+ type: "object",
499
+ properties: {
500
+ name: {
501
+ type: "string",
502
+ description: "Nom exact du symbole (ex. `AbstractCrudService`, `IKernel`)"
503
+ },
504
+ module: {
505
+ type: "string",
506
+ description: "Paquet dont on veut la surface exportée (ex. `@nodefony/http`)"
507
+ }
508
+ }
509
+ },
510
+ handler: async (args, caller) => {
511
+ const graph = readSymbolsGraph(deps.projectRoot);
512
+ if (graph === null) return mcpText("aucun graphe symbolique atteignable — il est publié par le paquet `nodefony` (node_modules/nodefony/.ai/symbols.json) et régénérable dans ce dépôt par `npm run generate-symbols`", true);
513
+ const wanted = typeof args.name === "string" ? args.name : null;
514
+ const module = typeof args.module === "string" ? args.module : null;
515
+ if (wanted !== null) {
516
+ const sym = lookupSymbol(graph, wanted);
517
+ if (!sym) return mcpText(`« ${wanted} » est introuvable dans le graphe (${Object.keys(graph.symbols).length} symboles)`, true);
518
+ const declared = await callAdminEndpoint(deps.broker, {
519
+ namespace: "kernel",
520
+ path: "module/{name}/symbol/{symbol}",
521
+ params: {
522
+ name: moduleKey(sym.module),
523
+ symbol: sym.name
524
+ },
525
+ label: sym.name
526
+ }, adminCallerFromMcp(caller));
527
+ return mcpText(declared.ok ? {
528
+ ...sym,
529
+ ...declared.data
530
+ } : {
531
+ ...sym,
532
+ declaration: null,
533
+ why: declared.message
534
+ });
535
+ }
536
+ const entries = Object.values(graph.symbols).filter((s) => module === null || s.module === module || moduleKey(s.module) === moduleKey(module));
537
+ if (module !== null) {
538
+ if (entries.length === 0) {
539
+ const packages = [...new Set(Object.values(graph.symbols).map((s) => s.module))].sort();
540
+ return mcpText(`aucun symbole pour « ${module} » — paquets présents dans le graphe : ${packages.join(", ")}`, true);
541
+ }
542
+ return mcpText(entries.sort((a, b) => a.name.localeCompare(b.name)));
543
+ }
544
+ const perPackage = Object.create(null);
545
+ for (const sym of entries) perPackage[sym.module] = (perPackage[sym.module] ?? 0) + 1;
546
+ return mcpText({
547
+ total: entries.length,
548
+ perPackage
549
+ });
550
+ }
551
+ },
552
+ admin_list: {
553
+ name: `${PREFIX}admin_list`,
554
+ annotations: READ_ONLY_CLOSED,
555
+ description: "Catalogue des lectures d'ADMINISTRATION que cette application expose — bien au-delà des sujets de `nodefony_inspect` : journaux, sessions, comptes, clés d'API, webhooks, profileur, santé des connexions, entités, capacités de pagination. Chaque ligne est directement appelable par `nodefony_admin_call`. À utiliser dès que `nodefony_inspect` ne couvre pas ce qu'on cherche, plutôt que de supposer que la donnée n'existe pas.\n\nUn seul appel rend tout le catalogue ; `namespace` et `q` servent à le réduire quand on sait déjà ce qu'on cherche.",
556
+ inputSchema: {
557
+ type: "object",
558
+ properties: {
559
+ namespace: {
560
+ type: "string",
561
+ description: "Ne garder qu'un producteur (`kernel`, `http`, `security`, `syslog`, `user`, `orm`…)"
562
+ },
563
+ q: {
564
+ type: "string",
565
+ description: "Termes cherchés dans le chemin et le résumé ; les espaces séparent des termes CUMULATIFS (`session redis` = les deux)"
566
+ }
567
+ }
568
+ },
569
+ requiresAuth: true,
570
+ scopes: [ADMIN_SCOPE_READ],
571
+ handler: (args, caller) => mcpText(renderAdminCatalog(adminReadCatalog(deps.broker, adminCallerFromMcp(caller), {
572
+ namespace: typeof args.namespace === "string" ? args.namespace : void 0,
573
+ q: typeof args.q === "string" ? args.q : void 0
574
+ })))
575
+ },
576
+ admin_call: {
577
+ name: `${PREFIX}admin_call`,
578
+ annotations: READ_ONLY_CLOSED,
579
+ description: "Appelle une lecture d'administration, telle que `nodefony_admin_list` la nomme. La réponse vient de l'application qui TOURNE — pas d'une lecture des sources — et elle est identique à ce que rendrait la console d'administration, redaction des secrets comprise.\n\nLECTURE seule : les mutations du plan (créer, modifier, supprimer) ne passent pas par ici. Recopier `namespace` et `path` tels quels ; `params` porte les variables du chemin (`module/{name}` → `{ name: \"http\" }`), `query` les paramètres de requête que l'endpoint accepte (pagination `page`/`limit`, tri `sort`, recherche `q`). Le catalogue publie, sous chaque entrée qui rend une liste, les tris et filtres RÉELLEMENT acceptés — tout le reste est refusé en 400, jamais ignoré.",
580
+ inputSchema: {
581
+ type: "object",
582
+ properties: {
583
+ namespace: {
584
+ type: "string",
585
+ description: "Producteur, tel que le catalogue le titre"
586
+ },
587
+ path: {
588
+ type: "string",
589
+ description: "Chemin, recopié TEL QUEL du catalogue (variables `{…}` comprises)"
590
+ },
591
+ params: {
592
+ type: "object",
593
+ description: "Valeurs des variables du chemin, par nom (`{ name: \"http\" }`)",
594
+ additionalProperties: { type: "string" }
595
+ },
596
+ query: {
597
+ type: "object",
598
+ description: "Paramètres de requête, comme une URL les porterait (`{ limit: \"20\" }`)"
599
+ }
600
+ },
601
+ required: ["namespace", "path"]
602
+ },
603
+ requiresAuth: true,
604
+ scopes: [ADMIN_SCOPE_READ],
605
+ handler: async (args, caller) => {
606
+ const admin = adminCallerFromMcp(caller);
607
+ const namespace = typeof args.namespace === "string" ? args.namespace.trim() : "";
608
+ const path = typeof args.path === "string" ? args.path.trim() : "";
609
+ if (namespace === "" || path === "") return mcpText("« namespace » et « path » sont requis — demande d'abord nodefony_admin_list, puis recopie les deux tels quels", true);
610
+ const entry = findAdminReadEntry(deps.broker, admin, namespace, path);
611
+ if (!entry) {
612
+ const proches = adminReadCatalog(deps.broker, admin, { namespace });
613
+ return mcpText(proches.entries.length === 0 ? `« ${namespace}/${path} » n'est pas appelable ici, et « ${namespace} » ne propose aucune lecture — soit ce module n'est pas chargé, soit ce jeton ne l'ouvre pas. nodefony_admin_list rend le catalogue réel.` : `« ${namespace}/${path} » n'est pas appelable ici. Chemins de « ${namespace} » : ${proches.entries.map((other) => other.path).join(", ")}`, true);
614
+ }
615
+ const params = stringTable(args.params, "params", false);
616
+ if (!params.ok) return mcpText(params.why, true);
617
+ const query = stringTable(args.query, "query", true);
618
+ if (!query.ok) return mcpText(query.why, true);
619
+ const missing = entry.params.filter((name) => typeof params.value[name] !== "string");
620
+ if (missing.length > 0) return mcpText(`« ${namespace}/${path} » attend ${missing.length > 1 ? "les variables" : "la variable"} ${missing.map((name) => `« ${name} »`).join(", ")} dans « params »`, true);
621
+ const read = await callAdminEndpoint(deps.broker, {
622
+ namespace,
623
+ path,
624
+ method: "GET",
625
+ params: params.value,
626
+ query: query.value,
627
+ label: `${namespace}/${path}`
628
+ }, admin);
629
+ return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
630
+ }
631
+ },
632
+ observe: observeTool({
633
+ getProfiler: deps.getProfiler,
634
+ servesTraffic: deps.servesTraffic,
635
+ readRequestLogs: (requestId, caller) => readRequestLogs(deps.broker, requestId, caller),
636
+ onShutdown: deps.onShutdown
637
+ }),
638
+ profile_route: profileRouteTool({
639
+ getProfiler: deps.getProfiler,
640
+ servesTraffic: deps.servesTraffic,
641
+ getTarget: deps.profileRoute?.getTarget ?? (() => null),
642
+ issueTicket: deps.profileRoute?.issueTicket,
643
+ closeSession: deps.profileRoute?.closeSession,
644
+ rolesWithin: deps.profileRoute?.rolesWithin,
645
+ readRequestLogs: (requestId, caller) => readRequestLogs(deps.broker, requestId, caller),
646
+ onShutdown: deps.onShutdown
647
+ })
648
+ };
649
+ }
650
+ /**
651
+ * Présente un appelant MCP au plan d'administration.
652
+ *
653
+ * Aucune décision ici : les rôles ont été posés par la porte, qui seule sait si
654
+ * elle est protégée et par qui. Cette fonction ne fait que TRADUIRE — et c'est
655
+ * volontaire : la traduction est le seul endroit où une identité pourrait se
656
+ * perdre, alors elle ne fait rien d'autre que la transporter.
657
+ *
658
+ * @param caller - l'appelant établi par la porte.
659
+ * @returns la même identité, dans le vocabulaire du plan d'administration.
660
+ */
661
+ function adminCallerFromMcp(caller) {
662
+ return {
663
+ user: null,
664
+ roles: caller.roles,
665
+ label: caller.subject ? `le porteur « ${caller.subject} »` : caller.authenticated ? "un porteur authentifié" : "un appelant anonyme"
666
+ };
667
+ }
668
+ /**
669
+ * Les outils du catalogue que l'allowlist de configuration retient.
670
+ *
671
+ * C'est la forme sous laquelle `@nodefony/devkit` les déclare (`getMcpTools()`) :
672
+ * des {@link IMcpTool} ordinaires, collectés par `@nodefony/ai` comme ceux de
673
+ * n'importe quel module — seul le préfixe `nodefony_`, réservé aux modules
674
+ * `@nodefony/*`, les distingue.
675
+ *
676
+ * @param allowlist - clés courtes retenues (cf {@link BUILTIN_MCP_TOOL_KEYS})
677
+ * @param deps - briques qui répondent réellement
678
+ * @returns les outils retenus, dans l'ordre de l'allowlist
679
+ */
680
+ function selectBuiltinMcpTools(allowlist, deps) {
681
+ const builtin = builtinMcpTools(deps);
682
+ const selected = [];
683
+ for (const key of allowlist) if (isBuiltinKey(builtin, key)) selected.push(builtin[key]);
684
+ return selected;
685
+ }
686
+ /**
687
+ * La clé désigne-t-elle un outil du catalogue — en PROPRE, jamais par héritage ?
688
+ *
689
+ * @param catalog - le catalogue construit
690
+ * @param key - clé lue dans la configuration
691
+ * @returns `true` si `catalog` porte cette clé en propre
692
+ */
693
+ function isBuiltinKey(catalog, key) {
694
+ return Object.hasOwn(catalog, key);
695
+ }
696
+ export { BUILTIN_MCP_TOOL_KEYS, builtinMcpTools, compactDoctorReport, groupSkippedChecks, selectBuiltinMcpTools };