@nodefony/ai 10.0.0-beta.3 → 10.0.0-beta.4

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 (43) hide show
  1. package/README.md +14 -6
  2. package/dist/_virtual/{_@oxc-project_runtime@0.154.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +21 -14
  4. package/dist/nodefony/command/StdioCommand.js +75 -0
  5. package/dist/nodefony/config/config.js +141 -8
  6. package/dist/nodefony/config/configSchema.js +1 -2
  7. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  8. package/dist/nodefony/mcp/caller.js +38 -0
  9. package/dist/nodefony/mcp/gate.js +628 -0
  10. package/dist/nodefony/mcp/guard.js +82 -0
  11. package/dist/nodefony/mcp/progress.js +74 -0
  12. package/dist/nodefony/mcp/protocol.js +76 -0
  13. package/dist/nodefony/mcp/server.js +302 -0
  14. package/dist/nodefony/mcp/sessionTicket.js +89 -0
  15. package/dist/nodefony/mcp/stdio.js +64 -0
  16. package/dist/nodefony/mcp/stream.js +80 -0
  17. package/dist/nodefony/mcp/ticketSession.js +177 -0
  18. package/dist/nodefony/mcp/tools.js +485 -0
  19. package/dist/nodefony/src/errors/AiError.js +1 -2
  20. package/dist/types/index.d.ts +25 -0
  21. package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
  22. package/dist/types/nodefony/config/config.d.ts +28 -32
  23. package/dist/types/nodefony/mcp/caller.d.ts +54 -0
  24. package/dist/types/nodefony/mcp/gate.d.ts +235 -0
  25. package/dist/types/nodefony/mcp/guard.d.ts +89 -0
  26. package/dist/types/nodefony/mcp/progress.d.ts +48 -0
  27. package/dist/types/nodefony/mcp/protocol.d.ts +107 -0
  28. package/dist/types/nodefony/mcp/server.d.ts +156 -0
  29. package/dist/types/nodefony/mcp/sessionTicket.d.ts +60 -0
  30. package/dist/types/nodefony/mcp/stdio.d.ts +38 -0
  31. package/dist/types/nodefony/mcp/stream.d.ts +76 -0
  32. package/dist/types/nodefony/mcp/ticketSession.d.ts +174 -0
  33. package/dist/types/nodefony/mcp/tools.d.ts +232 -0
  34. package/docs/index.md +56 -0
  35. package/docs/mcp.md +754 -0
  36. package/package.json +13 -11
  37. package/dist/_virtual/_@oxc-project_runtime@0.154.0/helpers/esm/decorateMetadata.js +0 -6
  38. package/dist/nodefony/interfaces/IAiService.js +0 -1
  39. package/dist/nodefony/interfaces/index.js +0 -1
  40. package/dist/nodefony/service/AiService.js +0 -96
  41. package/dist/types/nodefony/interfaces/IAiService.d.ts +0 -15
  42. package/dist/types/nodefony/interfaces/index.d.ts +0 -1
  43. package/dist/types/nodefony/service/AiService.d.ts +0 -80
@@ -0,0 +1,485 @@
1
+ import { missingScopes, rolesFromScopes } from "nodefony";
2
+ // nodefony/mcp/tools.ts
3
+ /**
4
+ * Outils MCP : la **collecte** de ceux qu'une application déclare, leur
5
+ * publication et leur exécution — la part générique de toute porte MCP.
6
+ *
7
+ * Les CONTRATS (`IMcpTool` et ses voisins) vivent au cœur, parce qu'un module
8
+ * les implémente par {@link IModule.getMcpTools} sans dépendre d'aucune porte ;
9
+ * la MÉCANIQUE vit ici. Le catalogue `nodefony_*` n'est pas ici non plus : il
10
+ * appartient à `@nodefony/devkit`, qui le déclare comme n'importe quel module.
11
+ *
12
+ * ## Pourquoi la collecte, et pas un registre
13
+ *
14
+ * Un module déclare ses outils par {@link IModule.getMcpTools} ; ils sont
15
+ * ramassés **au moment de servir la requête**, jamais stockés. C'est
16
+ * délibérément l'inverse du data plane admin ({@link IAdminRegistry}), qui a
17
+ * besoin d'un registre parce qu'il monte des ROUTES au boot et qu'un module
18
+ * arrivé trop tard n'aurait plus rien à monter. Un outil MCP n'a rien à monter :
19
+ * il est lu à la demande. D'où trois propriétés gagnées gratuitement — aucune
20
+ * structure allouée au démarrage (donc coût nul en production, où aucune porte
21
+ * MCP n'existe), aucun ordre de `register()` à respecter, et une fraîcheur qui
22
+ * est une propriété du mécanisme plutôt qu'une discipline à tenir.
23
+ */
24
+ /**
25
+ * Au-delà de quoi une réponse cesse d'être une réponse.
26
+ *
27
+ * Mesuré, pas choisi en l'air : `inspect routes` rendait 47 000 caractères et
28
+ * `inspect config` 190 730 — le second dépassait ce que le client accepte, il
29
+ * partait sur disque, et l'agent brûlait vingt tours à le relire au `jq`. Le
30
+ * premier passait, et ne servait à rien pour autant : à qui demandait le NOMBRE
31
+ * de routes, il fallait compter 119 objets dans un mur de JSON.
32
+ *
33
+ * La borne est donc largement SOUS la limite des clients : ce qu'on protège
34
+ * n'est pas le transport, c'est la capacité de l'agent à répondre sans relire.
35
+ */
36
+ const MCP_TEXT_MAX_CHARS = 32e3;
37
+ /** Ce qu'on garde d'une valeur dans une vue d'ensemble : la surface. */
38
+ const SCALAR_MAX_CHARS = 120;
39
+ /**
40
+ * La SURFACE d'un objet — ses champs scalaires courts, sans sa profondeur.
41
+ *
42
+ * Ce qui pèse dans un inventaire n'est presque jamais ce qu'on y cherche : sur
43
+ * une route, `name`/`path`/`controller` tiennent en cent caractères, et c'est la
44
+ * description, les tableaux de tags et les objets imbriqués qui font les 47 ko.
45
+ * On jette donc la profondeur et on garde la surface — TOUTES les entrées
46
+ * restent visibles, ce qu'un échantillon des N premières ne permet pas : une
47
+ * question portant sur la 100ᵉ recevrait une réponse fausse, et l'agent n'aurait
48
+ * aucun moyen de le savoir.
49
+ */
50
+ function surfaceOf(value) {
51
+ if (value === null || typeof value !== "object") return value;
52
+ if (Array.isArray(value)) return `[${value.length}]`;
53
+ const kept = {};
54
+ for (const [key, v] of Object.entries(value)) if (v === null || typeof v === "number" || typeof v === "boolean") kept[key] = v;
55
+ else if (typeof v === "string" && v.length <= SCALAR_MAX_CHARS) kept[key] = v;
56
+ else if (Array.isArray(v) && v.every((e) => typeof e === "string")) {
57
+ if (v.join(",").length <= SCALAR_MAX_CHARS) kept[key] = v;
58
+ }
59
+ return kept;
60
+ }
61
+ /**
62
+ * Une donnée trop volumineuse, ramenée à une RÉPONSE.
63
+ *
64
+ * Deux formes, selon ce qu'on a sous la main :
65
+ * - **un tableau** rend son `count` — la première chose qu'on veut d'une liste,
66
+ * et celle qu'un modèle calcule mal et cher — puis ses entrées en surface ;
67
+ * - **un objet** rend ses clés de premier niveau : c'est la carte qui permet de
68
+ * demander ensuite la bonne branche (pour `config`, `inspect module <nom>`).
69
+ *
70
+ * Dans les deux cas la réponse DIT ce qu'elle a fait, et elle le dit EN TÊTE.
71
+ * Une troncature muette est pire que pas de troncature — l'agent croit tout
72
+ * tenir ; une troncature annoncée APRÈS vingt mille caractères d'entrées ne vaut
73
+ * guère mieux, il a déjà décidé quoi faire de ce qu'il a lu. Mesuré : sur la
74
+ * tâche « annonce le nombre de routes », l'agent recevait `count` puis 88
75
+ * entrées puis, tout en bas, l'avertissement — et repartait recompter les
76
+ * entrées visibles au lieu de lire le compte exact qu'on venait de lui donner.
77
+ *
78
+ * Et la note ne renvoie PAS vers un affinement : `routes`, `services` ou
79
+ * `modules` n'acceptent aucun filtre, si bien que « affine la demande » nommait
80
+ * un geste qui n'existe pas. Elle désigne ce qui EST utilisable — `count`, exact
81
+ * et portant sur la liste entière.
82
+ */
83
+ function summarize(value, size) {
84
+ if (Array.isArray(value)) {
85
+ const items = value.map(surfaceOf);
86
+ let rendered = JSON.stringify({
87
+ count: value.length,
88
+ note: `count = ${value.length} : c'est le nombre EXACT, il porte sur la liste entière. Les entrées ci-dessous sont ramenées à leur surface (${size} caractères au complet) — demande le détail d'une entrée précise plutôt que la liste entière`,
89
+ items
90
+ }, null, 2);
91
+ if (rendered.length <= 32e3) return rendered;
92
+ let keptCount = items.length;
93
+ while (keptCount > 1 && rendered.length > 32e3) {
94
+ keptCount = Math.floor(keptCount / 2);
95
+ rendered = JSON.stringify({
96
+ count: value.length,
97
+ note: `count = ${value.length} : c'est le nombre EXACT, il porte sur la liste entière. Seules ${keptCount} entrées sont montrées (la liste pèse ${size} caractères) — toute affirmation sur le NOMBRE se fonde sur count, jamais sur les entrées visibles.`,
98
+ items: items.slice(0, keptCount)
99
+ }, null, 2);
100
+ }
101
+ return rendered;
102
+ }
103
+ const keys = value && typeof value === "object" ? Object.keys(value) : void 0;
104
+ return JSON.stringify({
105
+ keys,
106
+ note: `réponse de ${size} caractères, trop volumineuse pour être rendue entière — demande une branche précise (par exemple le module qui t'intéresse)`
107
+ }, null, 2);
108
+ }
109
+ /**
110
+ * Rend un résultat d'outil — les données partent en JSON COMPACT.
111
+ *
112
+ * Le lecteur est un agent : il relit le JSON compact sans peine, et chaque
113
+ * caractère rendu se repaie à chaque tour suivant de sa conversation.
114
+ * L'indentation représentait ~25 % d'un rapport `nodefony_check`.
115
+ *
116
+ * Exporté parce qu'une application qui déclare un outil en a besoin : sans lui,
117
+ * chacune réinventerait l'enveloppe `content[]`, et une enveloppe mal formée
118
+ * n'échoue pas — elle rend un résultat vide que l'agent prend pour une réponse.
119
+ *
120
+ * **Un résultat est BORNÉ** ({@link MCP_TEXT_MAX_CHARS}) : au-delà, il rend un
121
+ * résumé plutôt qu'un déversement (voir {@link summarize}). La garde vit ICI, et
122
+ * non dans chaque outil, pour qu'un outil déclaré par une
123
+ * application en hérite sans le savoir — c'est le genre de règle qu'on
124
+ * n'applique jamais deux fois de la même façon si on la laisse à l'appelant.
125
+ * En deçà de la borne — le cas courant — la donnée part TELLE QUELLE : un
126
+ * consommateur qui la parse n'a rien à changer, et on ne paie pas la garde sur
127
+ * 100 % des appels pour 1 % de gros sujets.
128
+ *
129
+ * @param value - donnée à rendre (sérialisée si ce n'est pas déjà du texte)
130
+ * @param isError - marque un échec MÉTIER (pas une faute de protocole)
131
+ */
132
+ function mcpText(value, isError = false) {
133
+ const rendered = typeof value === "string" ? value : JSON.stringify(value);
134
+ const text = isError || rendered.length <= 32e3 ? rendered : summarize(value, rendered.length);
135
+ return isError ? {
136
+ content: [{
137
+ type: "text",
138
+ text
139
+ }],
140
+ isError: true
141
+ } : { content: [{
142
+ type: "text",
143
+ text
144
+ }] };
145
+ }
146
+ /**
147
+ * Forme admise pour un nom d'outil.
148
+ *
149
+ * Ce nom ne reste pas dans le serveur : il voyage jusque dans le contexte du
150
+ * modèle, et sert d'identifiant d'appel. Un nom porteur d'espace, de guillemet
151
+ * ou de saut de ligne ne provoque pas d'erreur — il produit des appels que rien
152
+ * ne résout, ce qui est bien pire à diagnostiquer. La borne haute est du même
153
+ * ordre : un nom de 4 000 caractères passerait, et mangerait le contexte.
154
+ */
155
+ const NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
156
+ /** Appelant anonyme — le défaut, et le seul défaut sûr. */
157
+ const ANONYMOUS = {
158
+ authenticated: false,
159
+ scopes: [],
160
+ roles: []
161
+ };
162
+ /**
163
+ * Préfixe RÉSERVÉ aux modules du framework (`@nodefony/*`) — un agent qui lit
164
+ * `nodefony_inspect` doit pouvoir croire que c'est le framework qui répond.
165
+ *
166
+ * Sans la réserve, un module d'application pourrait déclarer `nodefony_doc` et
167
+ * répondre à la place de l'outil que l'agent a lu dans la documentation du
168
+ * framework : l'ordre de chargement des modules ne protège rien, il change
169
+ * avec le manifeste.
170
+ */
171
+ const MCP_RESERVED_PREFIX = "nodefony_";
172
+ /** Portée npm des modules du framework, seuls détenteurs du préfixe réservé. */
173
+ const FRAMEWORK_SCOPE = "@nodefony/";
174
+ /**
175
+ * Le module a-t-il droit au préfixe {@link MCP_RESERVED_PREFIX} ?
176
+ *
177
+ * Lit `getModuleName()` (le nom npm du paquet), défensivement : un module écrit
178
+ * en JavaScript peut ne pas l'avoir, et une absence vaut refus.
179
+ *
180
+ * @param module - module chargé, de forme non garantie
181
+ * @returns `true` si le module appartient à la portée `@nodefony/`
182
+ */
183
+ function ownsReservedPrefix(module) {
184
+ if (typeof module !== "object" || module === null) return false;
185
+ if (!("getModuleName" in module)) return false;
186
+ const getName = module.getModuleName;
187
+ if (typeof getName !== "function") return false;
188
+ const name = getName.call(module);
189
+ return typeof name === "string" && name.startsWith(FRAMEWORK_SCOPE);
190
+ }
191
+ /**
192
+ * L'appelant satisfait-il ce que l'outil exige ?
193
+ *
194
+ * @returns `null` s'il passe, sinon le motif de la rétention
195
+ */
196
+ function withholdReason(tool, caller) {
197
+ const needsScopes = tool.scopes !== void 0 && tool.scopes.length > 0;
198
+ if (!needsScopes && tool.requiresAuth !== true) return null;
199
+ if (!caller.authenticated) {
200
+ if (tool.actsForUser === true) return "agit au nom d'un compte, l'appelant est anonyme";
201
+ const opened = needsScopes ? rolesFromScopes(tool.scopes) : [];
202
+ if (opened.length > 0 && opened.every((role) => caller.roles.includes(role))) return null;
203
+ return "exige une identité prouvée, l'appelant est anonyme";
204
+ }
205
+ if (!needsScopes) return null;
206
+ const missing = tool.scopes.filter((scope) => !caller.scopes.includes(scope));
207
+ return missing.length === 0 ? null : `scopes manquants : ${missing.join(", ")}`;
208
+ }
209
+ /**
210
+ * Motif pour lequel une valeur rendue par `getMcpTools()` n'est pas un outil
211
+ * servable, ou `null` si elle en est un.
212
+ *
213
+ * Un module peut être écrit en JavaScript : le contrat `IMcpTool` n'y est
214
+ * vérifié par personne. `scopes` et `requiresAuth` portent l'autorisation —
215
+ * `requiresAuth: "oui"` ne vaut pas `true` et servirait l'outil à un anonyme.
216
+ * Une forme fausse se REFUSE à la déclaration, elle ne s'interprète jamais.
217
+ *
218
+ * @param tool - valeur déclarée par un module
219
+ * @returns le motif du refus, ou `null`
220
+ */
221
+ function mcpToolFault(tool) {
222
+ if (typeof tool !== "object" || tool === null) return "pas un objet";
223
+ if (!("name" in tool) || typeof tool.name !== "string" || !NAME_PATTERN.test(tool.name)) return `nom absent ou hors forme — attendu ${NAME_PATTERN.source}`;
224
+ if (!("handler" in tool) || typeof tool.handler !== "function") return "sans handler";
225
+ if (!("description" in tool) || typeof tool.description !== "string") return "`description` n'est pas une chaîne";
226
+ if (!("inputSchema" in tool) || typeof tool.inputSchema !== "object" || tool.inputSchema === null || Array.isArray(tool.inputSchema)) return "`inputSchema` n'est pas un objet";
227
+ if ("annotations" in tool && tool.annotations !== void 0 && (typeof tool.annotations !== "object" || tool.annotations === null)) return "`annotations` n'est pas un objet";
228
+ if ("scopes" in tool && tool.scopes !== void 0 && !(Array.isArray(tool.scopes) && tool.scopes.every((scope) => typeof scope === "string"))) return "`scopes` n'est pas un tableau de chaînes";
229
+ if ("scopesFor" in tool && tool.scopesFor !== void 0 && typeof tool.scopesFor !== "function") return "`scopesFor` n'est pas une fonction";
230
+ if ("requiresAuth" in tool && tool.requiresAuth !== void 0 && typeof tool.requiresAuth !== "boolean") return "`requiresAuth` n'est pas un booléen";
231
+ if ("actsForUser" in tool && tool.actsForUser !== void 0) {
232
+ if (typeof tool.actsForUser !== "boolean") return "`actsForUser` n'est pas un booléen";
233
+ if (tool.actsForUser && !("scopes" in tool && Array.isArray(tool.scopes) && tool.scopes.length > 0)) return "`actsForUser` exige des `scopes` : les rôles du compte ne sont pas bornés par le jeton";
234
+ }
235
+ return null;
236
+ }
237
+ /** La valeur déclarée a la forme d'un outil servable (cf {@link mcpToolFault}). */
238
+ function isMcpTool(tool) {
239
+ return mcpToolFault(tool) === null;
240
+ }
241
+ /**
242
+ * Énumère ce que cette application DÉCLARE — les outils de ses modules, lus
243
+ * par `getMcpTools()` — sans rien retenir sur l'identité de qui que ce soit.
244
+ *
245
+ * Cinq refus, tous énoncés par `onSkip` plutôt que silencieux :
246
+ * - **nom hors forme** ({@link NAME_PATTERN}) — il ne serait pas appelable ;
247
+ * - **forme fausse** ({@link mcpToolFault}) — handler, description, schéma ou
248
+ * autorisation qui ne respectent pas `IMcpTool` ;
249
+ * - **préfixe réservé** ({@link MCP_RESERVED_PREFIX}) pris par un module hors de
250
+ * `@nodefony/` — un module d'application ne peut donc pas se substituer à
251
+ * `nodefony_inspect` et répondre à sa place ;
252
+ * - **collision** — le premier inscrit garde le nom ;
253
+ * - **déclaration en échec** — un `getMcpTools()` qui lève ne doit pas priver
254
+ * l'agent des outils de tous les autres modules.
255
+ *
256
+ * 🔴 **Ce que rend cette fonction n'est PAS servable tel quel** : elle contient
257
+ * les outils réservés. Toute porte passe par {@link collectMcpTools}, qui filtre
258
+ * sur l'appelant. Le seul usage légitime de la liste brute est de raisonner sur
259
+ * le CODE — ce que la porte EXIGE ({@link mcpDeclaredScopes}) — jamais de
260
+ * répondre à une requête.
261
+ *
262
+ * @param options - modules et journal des écarts
263
+ * @returns les outils déclarés, dans l'ordre où ils seraient publiés
264
+ */
265
+ function declareMcpTools(options) {
266
+ const declared = [];
267
+ const seen = /* @__PURE__ */ new Set();
268
+ const add = (tool, origin, ownsPrefix) => {
269
+ const name = typeof tool === "object" && tool !== null && "name" in tool ? tool.name : void 0;
270
+ if (typeof name !== "string" || !NAME_PATTERN.test(name)) {
271
+ options.onSkip?.(`outil écarté (${origin}) : nom absent ou hors forme — attendu ${NAME_PATTERN.source}`);
272
+ return;
273
+ }
274
+ if (!ownsPrefix && name.startsWith("nodefony_")) {
275
+ options.onSkip?.(`outil « ${name} » écarté (${origin}) : le préfixe « ${MCP_RESERVED_PREFIX} » est réservé aux modules ${FRAMEWORK_SCOPE}*`);
276
+ return;
277
+ }
278
+ if (!isMcpTool(tool)) {
279
+ options.onSkip?.(`outil « ${name} » écarté (${origin}) : ${mcpToolFault(tool)}`);
280
+ return;
281
+ }
282
+ if (seen.has(tool.name)) {
283
+ options.onSkip?.(`outil « ${tool.name} » écarté (${origin}) : ce nom est déjà pris`);
284
+ return;
285
+ }
286
+ seen.add(tool.name);
287
+ declared.push(tool);
288
+ };
289
+ for (const [name, module] of Object.entries(options.modules ?? {})) {
290
+ if (typeof module !== "object" || module === null || !("getMcpTools" in module)) continue;
291
+ const declare = module.getMcpTools;
292
+ if (typeof declare !== "function") continue;
293
+ let tools;
294
+ try {
295
+ tools = declare.call(module);
296
+ } catch (error) {
297
+ options.onSkip?.(`module « ${name} » : getMcpTools() a échoué — ${error instanceof Error ? error.message : String(error)}`);
298
+ continue;
299
+ }
300
+ if (!Array.isArray(tools)) {
301
+ options.onSkip?.(`module « ${name} » : getMcpTools() doit rendre un tableau`);
302
+ continue;
303
+ }
304
+ const ownsPrefix = ownsReservedPrefix(module);
305
+ for (const tool of tools) add(tool, `module ${name}`, ownsPrefix);
306
+ }
307
+ return declared;
308
+ }
309
+ /**
310
+ * Ramasse les outils SERVIS à cet appelant.
311
+ *
312
+ * Aux trois refus de {@link declareMcpTools} s'ajoute une RÉTENTION, qui n'est
313
+ * pas une faute : un outil dont l'appelant ne satisfait pas les exigences
314
+ * ({@link IMcpTool.scopes}, {@link IMcpTool.requiresAuth}) ne sort pas d'ici.
315
+ * ⭐ **Filtrer À LA COLLECTE protège `tools/list` ET `tools/call` d'un seul
316
+ * geste** : le protocole ne reçoit que les outils servis, donc un outil retenu
317
+ * est « inconnu » pour lui — indistinguable d'un outil inexistant, ce qui ne
318
+ * révèle même pas son existence. Filtrer la liste sans filtrer l'appel n'aurait
319
+ * été qu'un rideau, et c'est l'erreur classique : deux points de décision, dont
320
+ * un qu'on oublie.
321
+ *
322
+ * @param options - modules, appelant et journaux
323
+ * @returns les outils exécutables, dans l'ordre où ils seront publiés
324
+ */
325
+ function collectMcpTools(options) {
326
+ const caller = options.caller ?? ANONYMOUS;
327
+ const served = [];
328
+ for (const tool of declareMcpTools(options)) {
329
+ const withheld = withholdReason(tool, caller);
330
+ if (withheld !== null) {
331
+ options.onWithheld?.(tool.name, withheld);
332
+ continue;
333
+ }
334
+ served.push(tool);
335
+ }
336
+ return served;
337
+ }
338
+ /**
339
+ * Le vocabulaire de scopes que cette porte EXIGE — l'union de ce que ses outils
340
+ * déclarent, jamais une liste écrite à côté.
341
+ *
342
+ * 🔴 **C'est ce qu'un client doit demander**, et c'est à ce titre qu'il est
343
+ * publié (RFC 9728 `scopes_supported`) et nommé dans le défi (RFC 6750
344
+ * `scope`). Une liste de configuration disait autre chose que le code par
345
+ * simple oubli, dans les deux sens et sans qu'aucun contrôle ne l'aperçoive :
346
+ * un scope publié que rien n'exige envoie demander un droit qui n'ouvre rien ;
347
+ * un scope exigé que rien ne publie laisse le client deviner ce qu'on attend de
348
+ * lui — et un outil de module en était systématiquement là.
349
+ *
350
+ * L'énumération est délibérément celle des outils DÉCLARÉS, pas de ceux servis
351
+ * à un appelant : le document se lit avant d'avoir un jeton, et un catalogue
352
+ * filtré n'annoncerait à l'anonyme que ce dont il n'a pas besoin.
353
+ *
354
+ * @param options - modules et journal des écarts
355
+ * @returns les scopes, dédupliqués et triés ; vide si la porte n'exige rien
356
+ */
357
+ function mcpDeclaredScopes(options) {
358
+ const vocabulaire = /* @__PURE__ */ new Set();
359
+ for (const tool of declareMcpTools(options)) for (const scope of tool.scopes ?? []) vocabulaire.add(scope);
360
+ return [...vocabulaire].sort();
361
+ }
362
+ /**
363
+ * Projette les outils pour `tools/list` — sans leur implémentation.
364
+ *
365
+ * Le `handler` est une fermeture sur l'état du serveur ; `JSON.stringify` le
366
+ * laisserait tomber en silence, mais compter là-dessus reviendrait à publier
367
+ * par accident ce qu'on ne publie pas exprès.
368
+ */
369
+ function publishMcpTools(tools) {
370
+ return tools.map(({ name, description, inputSchema, annotations }) => annotations === void 0 ? {
371
+ name,
372
+ description,
373
+ inputSchema
374
+ } : {
375
+ name,
376
+ description,
377
+ inputSchema,
378
+ annotations
379
+ });
380
+ }
381
+ /**
382
+ * Exécute un outil par son nom public.
383
+ *
384
+ * Un échec métier (sujet inconnu, module absent) rend un résultat `isError`,
385
+ * **pas** une erreur JSON-RPC : le protocole réserve celle-ci aux fautes de
386
+ * protocole. La distinction compte pour l'agent — une erreur de protocole
387
+ * signifie « tu t'y prends mal », un `isError` signifie « ta demande est
388
+ * recevable, voici pourquoi elle n'aboutit pas » ; c'est la seconde qu'il peut
389
+ * corriger seul.
390
+ *
391
+ * ⭐ **Aucun contrôle d'autorisation ici, et c'est voulu** : `tools` ne contient
392
+ * que ce que {@link collectMcpTools} a servi à CET appelant. Refaire la
393
+ * vérification à ce niveau créerait un second point de décision — deux copies
394
+ * d'une même règle, qui divergent en silence. Le corollaire tient en une
395
+ * phrase : **ne jamais passer ici une liste non filtrée.**
396
+ *
397
+ * @param name - nom public de l'outil (`nodefony_inspect`…)
398
+ * @param args - arguments fournis par l'agent
399
+ * @param tools - outils servis, tels que {@link collectMcpTools} les a ramassés
400
+ * @param caller - appelant établi, transmis au handler (anonyme par défaut)
401
+ * @param run - annulation et progression de CET appel (détaché par défaut)
402
+ * @returns le résultat de l'outil, ou `null` si le nom n'est pas exposé
403
+ */
404
+ async function callMcpTool(name, args, tools, caller = ANONYMOUS, run = DETACHED_RUN) {
405
+ const tool = tools.find((candidate) => candidate.name === name);
406
+ if (!tool) return null;
407
+ const refusal = unknownArguments(tool, args);
408
+ if (refusal !== null) return mcpText(refusal, true);
409
+ const scopes = mcpCallScopes(tool, args);
410
+ if (scopes === null) return mcpText(`« ${tool.name} » : les scopes de cet appel sont indéterminables (\`scopesFor\` a levé ou rendu une forme fausse) — appel refusé`, true);
411
+ const missing = missingScopes(caller.scopes, scopes);
412
+ if (missing.length > 0) return mcpText(insufficientScopeMessage(tool.name, missing), true);
413
+ return tool.handler(args, caller, run);
414
+ }
415
+ /**
416
+ * Tous les scopes qu'exige UN appel : ceux de l'outil, puis ceux que ses
417
+ * arguments ajoutent ({@link IMcpTool.scopesFor}). Seule source de la règle,
418
+ * lue par l'appel en process ET par le défi `insufficient_scope` d'une porte.
419
+ *
420
+ * @param tool - outil servi
421
+ * @param args - arguments de l'appel
422
+ * @returns les scopes exigés, sans doublon ; `null` si `scopesFor` lève ou rend
423
+ * une forme fausse (l'appel est alors refusé, jamais servi sans borne)
424
+ */
425
+ function mcpCallScopes(tool, args) {
426
+ const base = tool.scopes ?? [];
427
+ if (tool.scopesFor === void 0) return base;
428
+ let extra;
429
+ try {
430
+ extra = tool.scopesFor(args);
431
+ } catch {
432
+ return null;
433
+ }
434
+ if (!Array.isArray(extra) || !extra.every((scope) => typeof scope === "string")) return null;
435
+ return extra.length === 0 ? base : [.../* @__PURE__ */ new Set([...base, ...extra])];
436
+ }
437
+ /**
438
+ * Le message d'un appel refusé faute de scope — le même pour le résultat
439
+ * `isError` et la description du défi `403`.
440
+ *
441
+ * @param name - nom de l'outil
442
+ * @param missing - scopes absents du jeton
443
+ * @returns l'explication, qui nomme les scopes à demander
444
+ */
445
+ function insufficientScopeMessage(name, missing) {
446
+ return `« ${name} » avec ces arguments exige le(s) scope(s) ${missing.join(" ")}, absent(s) du jeton présenté — redemander un jeton qui les porte`;
447
+ }
448
+ /**
449
+ * L'exécution d'un appel SANS porte qui l'observe (appel direct, test) : jamais
450
+ * annulé, progression muette. Partagé — un signal jamais abattu ne retient rien.
451
+ */
452
+ const DETACHED_RUN = {
453
+ signal: new AbortController().signal,
454
+ progress: () => {}
455
+ };
456
+ /**
457
+ * Arguments que l'outil ne déclare pas — refusés, jamais ignorés.
458
+ *
459
+ * ⚠️ Un paramètre accepté puis jeté est pire qu'un refus : la réponse a l'air
460
+ * d'avoir honoré la demande. Vécu sur cette porte — un `limit` passé à la
461
+ * recherche documentaire revenait avec vingt résultats, sans un mot, et
462
+ * l'appelant en concluait que la borne ne marchait pas plutôt que de voir
463
+ * qu'elle n'avait jamais été transmise. Le même silence accueillait une clé
464
+ * mal orthographiée : l'outil répondait comme si de rien n'était.
465
+ *
466
+ * La garde vit ICI, une fois, plutôt que dans chaque handler — et elle
467
+ * s'applique donc aussi aux outils qu'une application déclare. Un outil qui
468
+ * veut réellement des arguments libres l'écrit dans son schéma
469
+ * (`additionalProperties: true`), ce qui en fait un choix relu, pas un défaut.
470
+ *
471
+ * @param tool - l'outil visé, avec son schéma d'entrée.
472
+ * @param args - ce que l'agent a passé.
473
+ * @returns le message de refus, ou `null` si tout est déclaré.
474
+ */
475
+ function unknownArguments(tool, args) {
476
+ const schema = tool.inputSchema;
477
+ if (schema?.additionalProperties === true) return null;
478
+ const declared = schema?.properties;
479
+ if (!declared || typeof declared !== "object") return null;
480
+ const intruders = Object.keys(args).filter((key) => !Object.hasOwn(declared, key));
481
+ if (intruders.length === 0) return null;
482
+ const known = Object.keys(declared);
483
+ return `« ${tool.name} » ne connaît pas ${intruders.map((c) => `« ${c} »`).join(", ")}. ` + (known.length > 0 ? `Arguments acceptés : ${known.join(", ")}.` : "Cet outil ne prend aucun argument.");
484
+ }
485
+ export { MCP_RESERVED_PREFIX, MCP_TEXT_MAX_CHARS, callMcpTool, collectMcpTools, declareMcpTools, insufficientScopeMessage, mcpCallScopes, mcpDeclaredScopes, mcpText, publishMcpTools };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/errors/AiError.ts
1
+ // nodefony/src/errors/AiError.ts
2
2
  /**
3
3
  * Erreur du module ai.
4
4
  *
@@ -17,5 +17,4 @@ var AiError = class extends Error {
17
17
  this.name = "AiError";
18
18
  }
19
19
  };
20
- //#endregion
21
20
  export { AiError, AiError as default };
@@ -29,3 +29,28 @@ declare class AiModule extends Module {
29
29
  }
30
30
  export default AiModule;
31
31
  export type { IAiConfigInput } from "./nodefony/config/config.js";
32
+ export { aiConfigSchema, mcpConfigSchema, type IAiConfig, type IMcpConfig, } from "./nodefony/config/config.js";
33
+ export { defineAiConfig, aiConfigJsonSchema, } from "./nodefony/config/defineModuleConfig.js";
34
+ export { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_VERSIONS, MCP_DEFAULT_NEGOTIATED_VERSION, META_PROTOCOL_VERSION, META_SERVER_INFO, McpProtocolError, JsonRpcError, jsonRpcSuccess, jsonRpcFailure, isNotification, } from "./nodefony/mcp/protocol.js";
35
+ export type { IMcpHttpReply, JsonRpcId, IJsonRpcMessage, IJsonRpcSuccess, IJsonRpcFailure, McpProtocolVersion, } from "./nodefony/mcp/protocol.js";
36
+ export { handleMcpMessage } from "./nodefony/mcp/server.js";
37
+ export type { IMcpServerContext, IMcpHeaders, IMcpTransport, IServerInfo, IMcpCacheHints, } from "./nodefony/mcp/server.js";
38
+ export { defineMcpGate, mcpGateSchema, MCP_STREAM_MAX_EVENT_BYTES, } from "./nodefony/mcp/gate.js";
39
+ export type { IMcpGate, IMcpGateDefinition, IMcpGateSettings, IMcpGateRequest, IMcpGateRuntime, IMcpGateResponse, IMcpStdioRequest, McpGateSeverity, } from "./nodefony/mcp/gate.js";
40
+ export { serveMcpStdio } from "./nodefony/mcp/stdio.js";
41
+ export type { IMcpStdioOptions } from "./nodefony/mcp/stdio.js";
42
+ export { createProgressReporter, readProgressToken, MCP_PROGRESS_MIN_INTERVAL_MS, MCP_PROGRESS_MESSAGE_MAX, } from "./nodefony/mcp/progress.js";
43
+ export type { IMcpProgressReporter, McpProgressToken, } from "./nodefony/mcp/progress.js";
44
+ export { createMcpResponseStream, MCP_STREAM_GRACE_MS, } from "./nodefony/mcp/stream.js";
45
+ export type { IMcpEventSink, IMcpResponseStream, IMcpResponseStreamOptions, } from "./nodefony/mcp/stream.js";
46
+ export { checkMcpAccess, isLocalAddress, isRelayedRequest, RELAY_HEADERS, } from "./nodefony/mcp/guard.js";
47
+ export type { GuardVerdict, IGuardInput, IGuardPolicy, } from "./nodefony/mcp/guard.js";
48
+ export { SESSION_TICKET_HEADER, SESSION_TICKET_LENGTH, SESSION_TICKET_MAX_PENDING, SESSION_TICKET_TTL_MS, SessionTicketStore, fingerprintSessionTicket, } from "./nodefony/mcp/sessionTicket.js";
49
+ export type { ISessionTicketStoreOptions } from "./nodefony/mcp/sessionTicket.js";
50
+ export { TICKET_SESSION_REFUSED, defineTicketSessionGate, isSessionFlowView, isSessionServiceView, openTicketSession, readTicketSessionVerdict, ticketSessionGateSchema, toTicketSessionUser, } from "./nodefony/mcp/ticketSession.js";
51
+ export type { ISessionFlowView, ISessionServiceView, ITicketSessionDeps, ITicketSessionGate, ITicketSessionGateDefinition, ITicketSessionGateSettings, ITicketSessionRequest, ITicketSessionUser, TicketSessionVerdict, } from "./nodefony/mcp/ticketSession.js";
52
+ export { mcpCallerRoles } from "./nodefony/mcp/caller.js";
53
+ export type { IMcpGatePosture } from "./nodefony/mcp/caller.js";
54
+ export { mcpText, declareMcpTools, collectMcpTools, mcpDeclaredScopes, mcpCallScopes, insufficientScopeMessage, publishMcpTools, callMcpTool, MCP_TEXT_MAX_CHARS, MCP_RESERVED_PREFIX, } from "./nodefony/mcp/tools.js";
55
+ export type { IMcpTool, IMcpToolAnnotations, IMcpToolDefinition, IMcpToolResult, IMcpCaller, IMcpToolRun, IMcpDeclareOptions, IMcpCollectOptions, } from "./nodefony/mcp/tools.js";
56
+ export { AiError } from "./nodefony/src/errors/AiError.js";
@@ -0,0 +1,28 @@
1
+ import { Command, CliKernel } from "nodefony";
2
+ /**
3
+ * Sert le MCP de l'application sur l'entrée et la sortie standard —
4
+ * `nodefony ai:stdio`.
5
+ *
6
+ * Pour le client qui lance lui-même son serveur MCP et lui parle par stdio
7
+ * (agent de CI, interface en ligne de commande, agent cloud `"type": "local"`) :
8
+ * rien n'y écoute sur `localhost`, la route `/nodefony/mcp` lui est hors
9
+ * d'atteinte.
10
+ *
11
+ * Servi dans TOUS les environnements : les outils des modules chargés, avec la
12
+ * posture de l'OPÉRATEUR LOCAL — celle de toute commande du CLI
13
+ * (`localOperatorCaller`, qui dit pourquoi). Qui lance la commande a déjà un
14
+ * terminal dans l'application : `nodefony inspect` lui rend les mêmes données.
15
+ * Ce rôle n'ouvre que l'administration du framework (`admin:read`/`admin:write`
16
+ * se traduisent en lui, `rolesFromScopes`) ; un outil à scope APPLICATIF, un
17
+ * outil `requiresAuth` ou `actsForUser` reste retenu — il exige un jeton, donc
18
+ * une porte HTTP protégée. Les outils `nodefony_*` n'y paraissent que lorsque
19
+ * `@nodefony/devkit` est chargé (développement).
20
+ *
21
+ * Limite : le code servi est celui du boot, sans rechargement à chaud — la
22
+ * porte HTTP de développement reste celle qui suit les sources.
23
+ */
24
+ declare class StdioCommand extends Command {
25
+ constructor(cli: CliKernel);
26
+ generate(): Promise<this>;
27
+ }
28
+ export default StdioCommand;
@@ -1,44 +1,40 @@
1
1
  import { z } from "zod";
2
2
  /**
3
- * @nodefony/ai — CONFIGURATION DU MODULE (schéma Zod = source unique).
3
+ * Réglages de la porte MCP (`POST /nodefony/mcp`) — la SOURCE UNIQUE du schéma
4
+ * `mcp.*`, que tout module qui sert ou configure la porte importe d'ici.
4
5
  *
5
- * ⭐ TL;DR : CE SCHÉMA EST LA CONFIG. Chaque `.default(...)` est la valeur d'usine ;
6
- * changer un défaut du module = ÉDITER ICI (et nulle part ailleurs). L'app, elle,
7
- * ne surcharge que ses écarts, via `use("@nodefony/ai", { … })`.
6
+ * Les outils exposés ne se règlent PAS ici : chaque module déclare les siens
7
+ * (`getMcpTools()`), et un module qui veut filtrer son propre catalogue porte ce
8
+ * réglage dans SA config.
8
9
  *
9
- * RÈGLE D'OR (ADR-0006) : ce fichier porte le schéma commenté (type + validation +
10
- * défaut + doc) ET matérialise les défauts par `parse({})`. Le builder
11
- * (`defineAiConfig`) importe le schéma d'ici et ne retape aucune valeur —
12
- * un défaut écrit à deux endroits finit toujours par diverger.
10
+ * Tant qu'aucun serveur d'autorisation n'est déclaré, la porte n'est protégée
11
+ * que par les deux gardes que la spec impose au transport ({@link allowedOrigins},
12
+ * {@link allowRemote}) — qui visent le risque réel d'une porte locale : une page
13
+ * web ouverte dans le navigateur du développeur.
13
14
  *
14
- * SURCHARGE (précédence croissante) :
15
- * • défauts du schéma (ce fichier) ;
16
- * • app : `use("@nodefony/ai", { … })` dans `nodefony.config.ts` ;
17
- * • déploiement : un NOM COURT, si le champ en déclare un — `.meta({ env: "NF_AI_HOTE" })`
18
- * sur le champ suffit : le Kernel l'applique, `nodefony env` et `.env.example`
19
- * le montrent. C'est le contrat de PRODUCTION (nom stable, documenté) ;
20
- * • banc, dépannage : `NF__AI__<CHEMIN>=valeur` (override générique, `__` = niveau).
21
- *
22
- * Le schéma reste **PUR** : aucune lecture de `process.env`, aucun accès au kernel
23
- * (il est évalué à l'import — le kernel n'existe pas encore).
24
- *
25
- * Champ sensible (clé, mot de passe) → `.meta({ secret: true })` EN DERNIER de la
26
- * chaîne : chaque méthode zod clone, une `.default()` posée après `.meta()` perdrait
27
- * la métadonnée. Ces flags ressortent dans le JSON Schema lu par Studio.
28
- */
29
- /**
30
- * ⚠️ `strictObject`, et non `object` : une clé INCONNUE doit être REFUSÉE, pas
31
- * retirée en silence. C'est le défaut par défaut de Zod, et il coûte cher —
32
- * `use("@nodefony/ai", { gretting: "…" })` produisait une application qui
33
- * démarre en IGNORANT ce que l'utilisateur a écrit, sans un mot. Le typage du
34
- * registre `NodefonyModuleConfig` attrape la faute de frappe à la COMPILATION ;
35
- * ceci l'attrape au DÉMARRAGE, pour tout ce qui contourne le compilateur — une
36
- * config chargée d'un fichier, un `as never`, un override d'environnement.
15
+ * ⚠️ **Piège Zod 4** : un `.default({})` posé à plat sur un sous-objet NE
16
+ * ré-applique PAS les défauts de ses champs. Le pattern du dépôt est donc
17
+ * `sous.default(() => sous.parse({}))` — le callback force la ré-évaluation, et
18
+ * une application qui n'écrit rien obtient bien les valeurs d'usine.
37
19
  */
20
+ export declare const mcpConfigSchema: z.ZodObject<{
21
+ enabled: z.ZodDefault<z.ZodBoolean>;
22
+ allowedOrigins: z.ZodDefault<z.ZodArray<z.ZodString>>;
23
+ allowRemote: z.ZodDefault<z.ZodBoolean>;
24
+ authorization: z.ZodDefault<z.ZodObject<{
25
+ authorizationServers: z.ZodDefault<z.ZodArray<z.ZodString>>;
26
+ resource: z.ZodDefault<z.ZodString>;
27
+ additionalResources: z.ZodDefault<z.ZodArray<z.ZodString>>;
28
+ resourceName: z.ZodOptional<z.ZodString>;
29
+ resourceDocumentation: z.ZodOptional<z.ZodString>;
30
+ anonymous: z.ZodDefault<z.ZodBoolean>;
31
+ }, z.core.$strict>>;
32
+ }, z.core.$strict>;
38
33
  export declare const aiConfigSchema: z.ZodObject<{
39
34
  enabled: z.ZodDefault<z.ZodBoolean>;
40
- greeting: z.ZodDefault<z.ZodString>;
41
35
  }, z.core.$strict>;
36
+ /** Réglages `mcp.*` tels que le CODE les lit — défauts appliqués. */
37
+ export type IMcpConfig = z.output<typeof mcpConfigSchema>;
42
38
  /** Config telle que l'APP l'écrit dans `use()` — tous les champs optionnels. */
43
39
  export type IAiConfigInput = z.input<typeof aiConfigSchema>;
44
40
  /** Config telle que le CODE la lit — défauts appliqués, rien d'optionnel. */