@nodefony/ai 0.0.0-stage → 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 (40) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +123 -2
  3. package/dist/_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js +8 -0
  4. package/dist/index.js +37 -0
  5. package/dist/nodefony/command/StdioCommand.js +75 -0
  6. package/dist/nodefony/config/config.js +181 -0
  7. package/dist/nodefony/config/configSchema.js +18 -0
  8. package/dist/nodefony/config/defineModuleConfig.js +51 -0
  9. package/dist/nodefony/mcp/caller.js +38 -0
  10. package/dist/nodefony/mcp/gate.js +628 -0
  11. package/dist/nodefony/mcp/guard.js +82 -0
  12. package/dist/nodefony/mcp/progress.js +74 -0
  13. package/dist/nodefony/mcp/protocol.js +76 -0
  14. package/dist/nodefony/mcp/server.js +302 -0
  15. package/dist/nodefony/mcp/sessionTicket.js +89 -0
  16. package/dist/nodefony/mcp/stdio.js +64 -0
  17. package/dist/nodefony/mcp/stream.js +80 -0
  18. package/dist/nodefony/mcp/ticketSession.js +177 -0
  19. package/dist/nodefony/mcp/tools.js +485 -0
  20. package/dist/nodefony/src/errors/AiError.js +20 -0
  21. package/dist/types/index.d.ts +56 -0
  22. package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
  23. package/dist/types/nodefony/config/config.d.ts +44 -0
  24. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  25. package/dist/types/nodefony/config/defineModuleConfig.d.ts +43 -0
  26. package/dist/types/nodefony/mcp/caller.d.ts +54 -0
  27. package/dist/types/nodefony/mcp/gate.d.ts +235 -0
  28. package/dist/types/nodefony/mcp/guard.d.ts +89 -0
  29. package/dist/types/nodefony/mcp/progress.d.ts +48 -0
  30. package/dist/types/nodefony/mcp/protocol.d.ts +107 -0
  31. package/dist/types/nodefony/mcp/server.d.ts +156 -0
  32. package/dist/types/nodefony/mcp/sessionTicket.d.ts +60 -0
  33. package/dist/types/nodefony/mcp/stdio.d.ts +38 -0
  34. package/dist/types/nodefony/mcp/stream.d.ts +76 -0
  35. package/dist/types/nodefony/mcp/ticketSession.d.ts +174 -0
  36. package/dist/types/nodefony/mcp/tools.d.ts +232 -0
  37. package/dist/types/nodefony/src/errors/AiError.d.ts +14 -0
  38. package/docs/index.md +56 -0
  39. package/docs/mcp.md +754 -0
  40. package/package.json +83 -4
package/docs/mcp.md ADDED
@@ -0,0 +1,754 @@
1
+ ---
2
+ title: "Boîte à outils MCP — exposer les outils d'une application à un agent"
3
+ navTitle: Boîte à outils MCP
4
+ lang: fr
5
+ module: "@nodefony/ai"
6
+ topic: mcp
7
+ coverageModule: ai
8
+ coverageFiles: "ai/nodefony/mcp/server.ts, ai/nodefony/mcp/tools.ts, ai/nodefony/mcp/progress.ts, ai/nodefony/mcp/stream.ts, ai/nodefony/mcp/guard.ts, ai/nodefony/mcp/caller.ts, ai/nodefony/mcp/gate.ts, ai/nodefony/mcp/ticketSession.ts, ai/nodefony/mcp/sessionTicket.ts, ai/nodefony/mcp/stdio.ts, devkit/nodefony/mcp/tools.ts"
9
+ section: "Intelligence artificielle"
10
+ audience: [developer]
11
+ tags: [mcp, agent, ia, json-rpc, sse, outils, progression, annulation, oauth]
12
+ version: "doc"
13
+ status: stable
14
+ updated: 2026-10-22
15
+ source: "src/packages/@nodefony/ai/docs/mcp.md"
16
+ ---
17
+
18
+ # Boîte à outils MCP — exposer les outils d'une application à un agent
19
+
20
+ > Un agent d'intelligence artificielle (Claude Code, Cursor, un agent maison) appelle les
21
+ > **outils** d'un logiciel par le _Model Context Protocol_. Cette boîte à outils fait de chaque
22
+ > module Nodefony un fournisseur d'outils : tu écris une fonction, elle décrit ce qu'elle rend, et
23
+ > le protocole — versions, erreurs, autorisation, progression, annulation — est tenu pour toi, une
24
+ > fois, selon la norme. Ancrée sur `src/packages/@nodefony/ai/nodefony/mcp/` et `src/nodefony/src/types/IMcpTool.ts`.
25
+
26
+ 📍 [Documentation](../../../../../docs/index.md) › [@nodefony/ai](index.md) › **Boîte à outils MCP**
27
+
28
+ ## 🗺️ Schéma général
29
+
30
+ ```mermaid
31
+ sequenceDiagram
32
+ participant A as Agent
33
+ participant P as Porte
34
+ participant H as Protocole
35
+ participant T as Ton outil
36
+ A->>P: tools/call + progressToken
37
+ P->>H: outils servis à cet appelant
38
+ H->>T: handler(args, caller, run)
39
+ T-->>A: notifications/progress (flux SSE)
40
+ T->>H: résultat
41
+ H->>A: réponse finale, qui clôt le flux
42
+ ```
43
+
44
+ Trois étages, trois responsabilités : le **protocole** (ce fichier le décrit), la **porte** (une
45
+ route HTTP qui le sert), les **outils** (ce que tes modules déclarent). Le protocole ne sait rien
46
+ du transport ; la porte ne sait rien des outils ; un outil ne sait rien des deux.
47
+
48
+ ## 📖 Lexique
49
+
50
+ | Terme | Développé | En une ligne |
51
+ | ------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------- |
52
+ | **MCP** | _Model Context Protocol_ | le protocole par lequel un agent découvre et appelle les outils d'un logiciel |
53
+ | **JSON-RPC** | _JSON Remote Procedure Call_ | le format des messages : une requête `{ id, method, params }`, une réponse `{ id, result }` ou `{ error }` |
54
+ | **SSE** | _Server-Sent Events_ | une réponse HTTP qui reste ouverte et pousse des événements au fil de l'eau, dans un seul sens |
55
+ | **Porte** | — | la route qui reçoit les messages MCP et les confie au protocole |
56
+ | **Outil** | _tool_ | une capacité appelable par l'agent : un nom, une description, un schéma d'arguments, une fonction |
57
+ | **Appelant** | _caller_ | ce que la porte a ÉTABLI de qui appelle : authentifié ou non, ses scopes, ses rôles |
58
+ | **Scope** | — | un droit porté par un jeton OAuth (`admin:read`) ; un outil peut en exiger |
59
+ | **Jeton de progression** | `progressToken` | l'étiquette que l'agent pose sur sa requête pour recevoir des nouvelles en route |
60
+ | **Révision** | _protocol version_ | la version datée de la norme (`2026-07-28`) que client et serveur négocient |
61
+ | **RFC 9728** | _OAuth Protected Resource Metadata_ | le document qui dit à un client quel serveur d'autorisation délivre les jetons de cette porte |
62
+
63
+ ## Qu'est-ce que MCP — et pourquoi un agent en a besoin
64
+
65
+ Un agent qui travaille sur ton application a deux façons de la connaître : **lire ses sources**, ou
66
+ **lui demander**. Lire les sources donne ce que le code _dit_ ; demander à l'application qui tourne
67
+ donne ce qu'elle _fait_ — les routes réellement montées, la configuration effective, la donnée en
68
+ base. Un guichet plutôt qu'un plan : le plan peut être périmé, le guichetier ne l'est jamais.
69
+
70
+ MCP standardise ce guichet. L'agent demande la liste des outils (`tools/list`), lit leur description
71
+ — c'est elle qui décide s'il s'en servira — puis en appelle un (`tools/call`) avec des arguments
72
+ conformes au schéma publié. La réponse est du contenu (souvent du texte), et un échec métier se dit
73
+ dans la réponse (`isError`) plutôt que par une erreur de protocole : « ta demande est recevable,
74
+ voici pourquoi elle n'aboutit pas » est une phrase que l'agent sait corriger seul.
75
+
76
+ ## La vision Nodefony
77
+
78
+ - **Le protocole est une fonction pure.** `handleMcpMessage()` (`server.ts#handleMcpMessage`) reçoit un message
79
+ et rend un statut et un corps : ni socket, ni conteneur, ni horloge. Tout le protocole s'éprouve
80
+ sans serveur, et une seconde porte (HTTP de production, `stdio`) appelle la même fonction.
81
+ - **Un outil est un objet ordinaire.** Le contrat `IMcpTool` (`IMcpTool.ts#IMcpTool`) est le même pour
82
+ les outils du framework et pour les tiens : le serveur n'a aucun cas particulier.
83
+ - **Rien n'est enregistré au démarrage.** La porte ramasse les outils à chaque requête
84
+ (`collectMcpTools()`, `src/packages/@nodefony/ai/nodefony/mcp/tools.ts#collectMcpTools`) en parcourant les modules : un module ajouté ou rechargé
85
+ apparaît sans cache à invalider. La révision `2026-07-28` a retiré les sessions, et c'est ce qui
86
+ rend ce choix possible : chaque requête porte tout ce qu'il faut pour être servie.
87
+ - **L'autorisation se décide à la collecte, une fois.** Un outil réservé est absent de
88
+ `tools/list` ET inappelable en le nommant ; le refus dit « outil inconnu », pas « interdit ».
89
+ - **Les règles de la norme vivent dans la boîte à outils, pas dans les outils.** Progression,
90
+ annulation, forme des résultats selon la révision du client, refus normés : un outil n'écrit que
91
+ sa logique, et ne peut pas enfreindre la norme par oubli.
92
+
93
+ ## 🚀 Démarrage rapide
94
+
95
+ **Ouvrir la porte MCP de ton application** — elle se génère, avec deux outils d'exemple et son
96
+ test :
97
+
98
+ ```bash
99
+ npx nodefony create controller mcp --kind mcp # porte locale : la machine seulement
100
+ npx nodefony create controller mcp --kind mcp --expose oauth # production : jeton exigé (401 sans)
101
+ npx nodefony create controller mcp --kind mcp --expose mixte # outils publics sans jeton, les autres réservés à un scope
102
+ ```
103
+
104
+ Tu ajoutes ensuite tes outils à la liste de `nodefony/mcp/mcp-tools.ts` ; la porte
105
+ (`nodefony/mcp/mcp-gate.ts`) publie cette liste, et rien d'autre. Chaque outil y porte ses
106
+ `annotations` — dit s'il lit ou s'il modifie — et le test généré (`tests/mcp-gate.test.ts`)
107
+ refuse celui qui les omet : [Dire ce que fait l'outil](#dire-ce-que-fait-loutil--les-annotations).
108
+ Détail des deux expositions : [Monter une porte](#-monter-une-porte).
109
+
110
+ **Offrir des outils sans ouvrir de porte** — un module les DÉCLARE par `getMcpTools()`, et toute
111
+ porte en `tools: "modules"` les ramasse : c'est le cas de la porte de développement de
112
+ `@nodefony/devkit`, donc de l'agent de code du développeur. L'exemple ci-dessous expose ainsi deux
113
+ outils : une lecture courte, et une opération longue qui signale son avancement et s'arrête si
114
+ l'agent abandonne.
115
+
116
+ ```ts
117
+ import { Kernel, Module, type IMcpTool } from "nodefony";
118
+ import { mcpText } from "@nodefony/ai";
119
+
120
+ /** Le module « shop » d'une application — il offre deux outils à l'agent. */
121
+ export class ShopModule extends Module {
122
+ constructor(kernel: Kernel) {
123
+ super("shop", kernel, import.meta.url, {});
124
+ }
125
+
126
+ /** Les outils MCP de ce module — relus à chaque requête de la porte. */
127
+ getMcpTools(): IMcpTool[] {
128
+ return [
129
+ {
130
+ name: "shop_stock",
131
+ description:
132
+ "Stock réel d'une référence produit, lu en base. À utiliser avant " +
133
+ "de proposer une commande — la réponse ne vient pas d'un cache.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: { sku: { type: "string", description: "Référence" } },
137
+ required: ["sku"],
138
+ },
139
+ // Lecture dans la base de l'application : rien n'est modifié.
140
+ annotations: { readOnlyHint: true, openWorldHint: false },
141
+ handler: (args) => {
142
+ const sku = typeof args.sku === "string" ? args.sku : "";
143
+ if (sku === "") return mcpText("`sku` est requis", true);
144
+ return mcpText({ sku, available: 12 });
145
+ },
146
+ },
147
+ {
148
+ name: "shop_reindex",
149
+ description:
150
+ "Réindexe le catalogue, lot par lot. Long : signale chaque lot, et " +
151
+ "s'arrête proprement si l'appel est abandonné.",
152
+ inputSchema: { type: "object", properties: {} },
153
+ // Une écriture qui reconstruit l'index sans rien perdre, et que
154
+ // rejouer laisse dans le même état.
155
+ annotations: {
156
+ readOnlyHint: false,
157
+ destructiveHint: false,
158
+ idempotentHint: true,
159
+ openWorldHint: false,
160
+ },
161
+ handler: async (_args, _caller, run) => {
162
+ const lots = 5;
163
+ for (let i = 1; i <= lots; i++) {
164
+ if (run?.signal.aborted) return mcpText("interrompu", true);
165
+ await new Promise((resolve) => setTimeout(resolve, 200));
166
+ run?.progress(i, lots, `lot ${i}/${lots} réindexé`);
167
+ }
168
+ return mcpText(`${lots} lots réindexés`);
169
+ },
170
+ },
171
+ ];
172
+ }
173
+ }
174
+ ```
175
+
176
+ Ce qu'on observe sur la porte de développement (`@nodefony/devkit`, `POST /nodefony/mcp`) — en
177
+ JSON pour la lecture courte, en flux pour l'outil long quand l'agent le demande :
178
+
179
+ ```bash
180
+ # Lecture courte : une réponse JSON
181
+ curl -sk https://localhost:5152/nodefony/mcp -H 'content-type: application/json' \
182
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"shop_stock","arguments":{"sku":"A-12"}}}'
183
+
184
+ # Outil long : jeton de progression + flux accepté → notifications, puis la réponse qui clôt le flux
185
+ curl -skN https://localhost:5152/nodefony/mcp -H 'content-type: application/json' \
186
+ -H 'accept: application/json, text/event-stream' \
187
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"shop_reindex","arguments":{},"_meta":{"progressToken":"r1"}}}'
188
+ # data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"r1","progress":1,"total":5,"message":"lot 1/5 réindexé"}}
189
+ # …
190
+ # data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"5 lots réindexés"}]}}
191
+ ```
192
+
193
+ Interrompre le second `curl` (Ctrl+C) ferme le flux : `run.signal` est abattu, et l'outil rend la
194
+ main au lot suivant.
195
+
196
+ **Sans jeton de progression, un outil long répond quand même en flux.** Au-delà du délai de grâce
197
+ (`MCP_STREAM_GRACE_MS`, 5 s), la porte ouvre le flux sans rien y écrire — le flux bat de lui-même
198
+ — et la réponse finale y part. Ce n'est pas une option : la norme (`transports/streamable-http`)
199
+ oblige le client à accepter une réponse en flux, et seul un flux ouvert désarme le délai
200
+ d'inactivité de la requête (30 s par défaut). Une réponse JSON qui tarde tomberait en `408`, quel
201
+ que soit le travail en cours. Un outil qui répond avant le délai de grâce reste en JSON.
202
+
203
+ ## ✍️ Écrire un outil
204
+
205
+ ### Le contrat
206
+
207
+ | Champ | Rôle |
208
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
209
+ | `name` | identifiant d'appel, forme `[a-zA-Z0-9_-]{1,64}` (`NAME_PATTERN`, `src/packages/@nodefony/ai/nodefony/mcp/tools.ts#NAME_PATTERN`) ; préfixe-le par ton module |
210
+ | `description` | **le premier critère de déclenchement** : ce que l'outil REND et QUAND s'en servir |
211
+ | `inputSchema` | schéma JSON des arguments ; tout argument non déclaré est REFUSÉ, jamais ignoré |
212
+ | `annotations` | ce que l'outil FAIT (lit ou modifie, efface ou ajoute, sort ou non de l'application) — à poser sur chaque outil : [les annotations](#dire-ce-que-fait-loutil--les-annotations) |
213
+ | `scopes` | scopes OAuth exigés — tous ; absent = outil public |
214
+ | `requiresAuth` | exige une identité prouvée, sans scope particulier |
215
+ | `handler` | `(args, caller, run?) => IMcpToolResult` — synchrone ou promesse |
216
+
217
+ Un module écrit en JavaScript n'est contrôlé par aucun compilateur : un champ de mauvaise forme
218
+ (`requiresAuth: "oui"`, `scopes: "shop:read"`, `description` absente) fait **écarter l'outil à la
219
+ déclaration**, motif dit au journal : elle n'est jamais interprétée. Côté appel, des `arguments` qui ne sont
220
+ pas un objet sont refusés en `-32602`.
221
+
222
+ Trois règles qui évitent les défauts les plus coûteux :
223
+
224
+ - **Un échec métier se rend en `isError`, jamais en exception.** `mcpText(message, true)`
225
+ (`src/packages/@nodefony/ai/nodefony/mcp/tools.ts#mcpText`). Une exception devient une erreur de protocole (`-32603`), que l'agent lit comme
226
+ « le serveur est cassé » au lieu de « corrige ta demande ». Elle est de plus **opaque** : l'agent
227
+ ne reçoit qu'un message qui nomme l'outil, le message de l'exception part au journal du serveur
228
+ (`ERROR`) — il pourrait porter un chemin ou un secret. `isError` est le seul canal qui parvient à
229
+ l'agent.
230
+ - **Un argument non déclaré est refusé.** `unknownArguments()` (`src/packages/@nodefony/ai/nodefony/mcp/tools.ts#unknownArguments`) rend un `isError`
231
+ qui le nomme : un paramètre accepté puis jeté donnerait une réponse qui _a l'air_ d'avoir honoré la
232
+ demande. Un outil qui veut des arguments libres l'écrit (`additionalProperties: true`).
233
+ - **Un outil n'invente rien.** Il appelle la brique qui répond déjà à une autre porte (un service,
234
+ le plan d'administration) : un outil qui recalcule sa réponse finit par diverger de la commande
235
+ du même nom.
236
+
237
+ Un outil écarté à la collecte — nom hors forme, nom déjà pris, handler absent, déclaration qui
238
+ lève — le dit en `WARNING` dans le journal du serveur ; les outils intégrés gagnent toute collision.
239
+
240
+ ### Dire ce que fait l'outil — les annotations
241
+
242
+ Avant d'appeler un outil, l'agent (ou son client) doit décider s'il demande confirmation à
243
+ l'humain. La spec lui donne pour cela des **indices de comportement**, les `ToolAnnotations`
244
+ (révision `2026-07-28`, `schema/schema.ts` ; `spec/server/tools.mdx`, champ `annotations` de
245
+ l'outil). Nodefony les porte dans le contrat (`IMcpToolAnnotations`,
246
+ `src/nodefony/src/types/IMcpTool.ts#IMcpToolAnnotations`) et les publie dans `tools/list`.
247
+
248
+ | Indice | Ce qu'il promet | Défaut de la spec | N'a de sens que… |
249
+ | ----------------- | ---------------------------------------------------------------- | ----------------- | --------------------------- |
250
+ | `title` | un titre lisible, préféré au `name` par les interfaces | — | toujours |
251
+ | `readOnlyHint` | l'outil ne modifie **rien** de son environnement | `false` | toujours |
252
+ | `destructiveHint` | une écriture peut **détruire** ; `false` = il ne fait qu'ajouter | `true` | si `readOnlyHint` = `false` |
253
+ | `idempotentHint` | le rappeler avec les mêmes arguments n'a **aucun effet de plus** | `false` | si `readOnlyHint` = `false` |
254
+ | `openWorldHint` | il touche un monde **extérieur ouvert** (web, API tierce) | `true` | toujours |
255
+
256
+ **Lire les défauts, c'est comprendre pourquoi il faut annoter.** Un outil sans annotation est,
257
+ pour la spec, un outil qui **écrit, détruit, et sort de l'application**. Un client prudent demande
258
+ alors confirmation pour chaque lecture, ce qui le rend inutilisable ; un client pressé n'en demande
259
+ jamais, ce qui le rend dangereux. Deux règles en découlent :
260
+
261
+ - **un outil qui ne fait que lire le DIT** : `readOnlyHint: true`, et `openWorldHint: false` s'il ne
262
+ quitte pas l'application ;
263
+ - **un outil qui modifie le DÉCLARE** : `readOnlyHint: false`, puis `destructiveHint` et
264
+ `idempotentHint` selon ce qu'il fait vraiment (un remboursement : `destructiveHint: false`,
265
+ `idempotentHint: false` — l'appeler deux fois rembourse deux fois).
266
+
267
+ **Choisir les indices — partir de ce que fait le handler, jamais de son nom.** Une seule question
268
+ par indice, posée sur le CODE de l'outil :
269
+
270
+ | Le handler… | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` |
271
+ | ------------------------------------------------------------ | -------------- | ----------------- | ---------------- | --------------- |
272
+ | lit la base ou un service de l'application, calcule, formate | `true` | — | — | `false` |
273
+ | lit une API tierce ou le web (météo, cours de bourse) | `true` | — | — | `true` |
274
+ | crée une ligne, envoie un courriel, rembourse, met en file | `false` | `false` | `false` | selon la cible |
275
+ | remplace une valeur par une autre (`set`, `upsert`, `PUT`) | `false` | `true` | `true` | selon la cible |
276
+ | supprime (`delete`, purge, révocation) | `false` | `true` | `true` | selon la cible |
277
+ | reconstruit un dérivé sans perte (index, cache, vue) | `false` | `false` | `true` | `false` |
278
+
279
+ `openWorldHint` vaut `true` dès qu'un appel quitte l'application vers un monde qu'elle ne
280
+ maîtrise pas : envoyer un courriel, appeler une API de paiement, publier un message. Une écriture
281
+ dans sa propre base reste `false`. Un « — » : l'indice n'a pas de sens pour une lecture, le
282
+ client l'ignore ; ne pas le poser.
283
+
284
+ Les erreurs qui coûtent, de la plus grave à la plus bénigne :
285
+
286
+ 1. **`readOnlyHint: true` sur un outil qui écrit**, même un peu (un compteur de consultation, un
287
+ horodatage « vu le ») : le client exécute sans confirmation ce que l'humain aurait voulu
288
+ valider. C'est un mensonge au client — l'annotation doit décrire le pire de ce que fait le
289
+ handler, pas son intention principale.
290
+ 2. **`destructiveHint: false` sur un outil qui écrase** : « mettre à jour l'adresse » détruit
291
+ l'ancienne adresse. Remplacer, c'est effacer.
292
+ 3. **Aucune annotation** : le client tient l'outil pour le pire cas — exactement le problème que
293
+ les annotations résolvent. Le test généré par `nodefony create controller … --kind mcp`
294
+ (`tests/<nom>-gate.test.ts`) refuse un outil sans `readOnlyHint` ni `openWorldHint`, et un
295
+ outil qui écrit sans `destructiveHint` : la question se pose à l'ajout, pas en production.
296
+ 4. **Recopier les annotations d'un outil voisin** : elles décrivent CE handler. Un outil copié
297
+ puis modifié garde des promesses qui ne sont plus vraies.
298
+
299
+ Un outil qui change de nature (une lecture qui se met à écrire) change d'annotations dans le même
300
+ commit : c'est un changement de comportement que l'agent ne peut voir que par elles.
301
+
302
+ ```ts
303
+ const refund: IMcpTool = {
304
+ name: "shop_refund",
305
+ description: "Rembourse une commande du client authentifié.",
306
+ inputSchema: { type: "object", properties: { id: { type: "string" } } },
307
+ scopes: ["shop:billing"],
308
+ annotations: {
309
+ title: "Rembourser une commande",
310
+ readOnlyHint: false,
311
+ destructiveHint: false,
312
+ idempotentHint: false,
313
+ openWorldHint: false,
314
+ },
315
+ handler: (args) => mcpText({ refunded: args.id }),
316
+ };
317
+ ```
318
+
319
+ Ce que Nodefony fait, et ne fait pas :
320
+
321
+ - **il ne publie que ce qui est déclaré** (`publishMcpTools`, `src/packages/@nodefony/ai/nodefony/mcp/tools.ts#publishMcpTools`) :
322
+ sans annotation, le champ n'est pas émis et les défauts de la spec s'appliquent. Aucune promesse
323
+ n'est inventée au nom de l'auteur de l'outil ;
324
+ - **les sept outils intégrés** (`nodefony_inspect`, `_card`, `_check`, `_symbols`, `_docs`,
325
+ `_admin_list`, `_admin_call`) se publient `readOnlyHint: true`, `openWorldHint: false` : ils lisent
326
+ l'état de l'application et n'en sortent pas — `admin_call` n'atteint que le catalogue de lecture
327
+ du plan d'administration ;
328
+ - **un indice n'est JAMAIS une autorisation.** La spec l'impose au client : « clients **MUST**
329
+ consider tool annotations to be untrusted unless they come from trusted servers ». Côté serveur,
330
+ la seule décision reste la collecte : un outil à `scopes` n'est servi qu'à l'appelant qui les
331
+ porte, qu'il soit annoté `readOnlyHint: true` ou non.
332
+
333
+ ### Réserver un outil
334
+
335
+ ```ts
336
+ const invoice: IMcpTool = {
337
+ name: "shop_invoice",
338
+ description: "Facture d'une commande du client authentifié.",
339
+ inputSchema: { type: "object", properties: { id: { type: "string" } } },
340
+ scopes: ["shop:billing"],
341
+ annotations: { readOnlyHint: true, openWorldHint: false },
342
+ handler: (args, caller) =>
343
+ mcpText({ id: args.id, owner: caller.subject ?? null }),
344
+ };
345
+ ```
346
+
347
+ `caller` (`IMcpCaller`, `IMcpTool.ts#IMcpCaller`) porte ce que la porte a **établi** — jamais ce que la
348
+ requête prétend : `authenticated`, `scopes`, `roles`, `subject`. Une porte qui n'authentifie personne
349
+ rend un appelant anonyme, et un outil à scopes n'y sort jamais : fermé par défaut — à savoir
350
+ avant de chercher une panne.
351
+
352
+ ### Agir au nom de l'utilisateur — `actsForUser`
353
+
354
+ `caller` dit ce que la PORTE a prouvé : un sujet et des scopes. Presque toute logique métier veut
355
+ davantage — le COMPTE, ses vrais rôles, et des services qui le voient (« mes commandes », un droit,
356
+ une donnée filtrée par propriétaire). Un outil le demande en une ligne :
357
+
358
+ ```ts
359
+ import { RequestContext } from "nodefony";
360
+
361
+ const myOrders: IMcpTool = {
362
+ name: "shop_my_orders",
363
+ description: "Commandes du client au nom duquel l'agent agit.",
364
+ inputSchema: { type: "object", properties: {} },
365
+ annotations: { readOnlyHint: true, openWorldHint: false },
366
+ actsForUser: true, // le compte du propriétaire du jeton, posé AVANT le handler
367
+ scopes: ["shop:read"], // OBLIGATOIRE avec actsForUser — voir ci-dessous
368
+ handler: async () => {
369
+ const user = RequestContext.getAuthenticatedUser(); // le compte, rôles compris
370
+ return mcpText(await orders.listFor(user)); // le service le voit aussi
371
+ },
372
+ };
373
+ ```
374
+
375
+ Ce que la porte fait pour un tel outil (`gate.ts#runAsAccount`) :
376
+
377
+ - **Elle retrouve le compte avant le handler**, par le résolveur de l'application
378
+ (`accessAccountResolver`, posé par `@nodefony/security` : compte lié au jeton, actif, non
379
+ verrouillé ; sujet d'un émetteur externe rattaché à son compte). Puis elle exécute le handler
380
+ dans une **requête à lui** : `RequestContext.getAuthenticatedUser()`, tout service appelé et
381
+ `authorization.decide()` y voient ce compte, comme sous une route protégée. Le journal de requête
382
+ nomme qui a agi.
383
+ - **Les rôles sont ceux du COMPTE, entiers** — c'est la sémantique d'une zone `jwt` : le jeton ne
384
+ borne pas `ROLE_*`, ses scopes ne mordent que sur les attributs de scope (`@RequireScope`).
385
+ C'est pourquoi **`actsForUser` exige des `scopes`** : sans eux, un jeton étroit d'un compte
386
+ administrateur verrait tout ce que l'administrateur voit. L'outil est refusé au démarrage s'il
387
+ n'en déclare pas.
388
+ - **Une identité PROUVÉE, sinon l'outil n'existe pas** : un appelant anonyme, un jeton rejeté servi
389
+ en anonyme, l'opérateur d'une porte non protégée — l'outil reste retenu (absent de `tools/list`,
390
+ « outil inconnu » à l'appel).
391
+ - **Fermé à l'échec** : compte introuvable ou verrouillé → `isError` au message uniforme ;
392
+ résolveur absent → `isError` et journal `CRITIC`. Jamais une exécution en anonyme.
393
+ - **L'outil meurt avec son jeton** : son `run.signal` s'abat à l'expiration du jeton. Un jeton
394
+ RÉVOQUÉ pendant l'appel n'interrompt pas l'appel en cours — la même fenêtre qu'une socket ouverte.
395
+ - **Appel en process** (un agent de l'application, sans porte) : aucune requête n'est ouverte pour
396
+ lui ; l'appelant passe un `caller` authentifié explicite, ou l'outil est retenu.
397
+
398
+ ### Un paramètre qui élargit l'outil — `scopesFor`
399
+
400
+ Un outil qu'un jeton de LECTURE doit voir peut avoir un paramètre qui fait davantage : agir pour un
401
+ autre compte, écrire au lieu de lire. Le mettre dans `scopes` le cacherait à ce jeton. Le scope se
402
+ déclare alors à l'APPEL, selon les arguments :
403
+
404
+ ```ts
405
+ const report: IMcpTool = {
406
+ name: "shop_report",
407
+ // …
408
+ scopes: ["shop:read"], // pour voir l'outil
409
+ scopesFor: (args) => (args.publish === true ? ["shop:write"] : []), // pour CET appel
410
+ handler: (args) => mcpText(args.publish === true ? publish() : preview()),
411
+ };
412
+ ```
413
+
414
+ - **Porte protégée** : il manque un scope → `403` et
415
+ `WWW-Authenticate: Bearer error="insufficient_scope", scope="shop:read shop:write"` — TOUS les
416
+ scopes de l'opération en un seul défi, comme la spécification le demande (« Runtime Insufficient
417
+ Scope Errors »). Un client qui sait s'élever redemande un jeton et rejoue ; un client à jeton fixe
418
+ reçoit le message entier, qui nomme le scope à demander. Sans jeton sur une porte mixte : `401`,
419
+ sans code d'erreur.
420
+ - **Sans porte protégée, ou en process** : la même règle (`mcpCallScopes`, `tools.ts#mcpCallScopes`)
421
+ rend un résultat `isError`, et le handler ne s'exécute pas.
422
+ - **Ces scopes n'entrent pas dans `scopes_supported`** : la spécification veut ce document
423
+ minimal (« the minimal set of scopes necessary for basic functionality »). Un client sans défi en
424
+ demanderait sinon tout, écriture comprise.
425
+ - **`scopesFor` est une fonction pure et synchrone.** Elle lève, ou rend autre chose qu'un tableau
426
+ de chaînes : l'appel est refusé, jamais servi sans borne.
427
+
428
+ ### Un outil LONG — progression et annulation
429
+
430
+ Le troisième paramètre, `run` (`IMcpToolRun`, `IMcpTool.ts#IMcpToolRun`), sert l'outil qui dure :
431
+
432
+ - **`run.signal`** est abattu quand le client annule : il ferme le flux de la réponse (révision
433
+ courante de MCP), ou envoie `notifications/cancelled` (révision précédente, que le SDK officiel
434
+ emploie encore). Une annulation n'atteint que les appels de l'appelant qui l'envoie — le titulaire
435
+ du jeton, ou à défaut l'adresse distante. Le consulter entre deux étapes, ou le
436
+ passer à ce qu'on attend (`fetch`, une requête de base) ;
437
+ - **`run.progress(progress, total?, message?)`** donne signe de vie. Il est sans effet quand
438
+ l'agent n'en a pas demandé, quand la porte ne sait pas tenir de flux, ou une fois l'outil
439
+ terminé : l'outil n'a jamais à s'en soucier.
440
+
441
+ `run` est optionnel pour l'appelant (un test qui appelle `handler(args, caller)` directement s'en
442
+ passe), d'où l'écriture `run?.progress(…)`.
443
+
444
+ Ce qu'il ne faut PAS chercher : le journal `notifications/message`. La révision `2026-07-28` le
445
+ déprécie (« New implementations SHOULD NOT adopt it ») ; ce qu'un outil veut dire en route passe
446
+ par le `message` de la progression, et ce qui doit atteindre le **modèle** va dans la réponse
447
+ finale — rien ne garantit qu'un client fasse entrer les notifications dans son contexte.
448
+
449
+ ## 🚪 Monter une porte
450
+
451
+ `@nodefony/ai` ne monte **aucune** route : une porte se **déclare**, par qui en a besoin. La porte
452
+ de développement est celle de `@nodefony/devkit` (`DevkitService.mcpGate()`,
453
+ `DevkitService.ts#DevkitService.mcpGate`, servie par `McpController.mcp()`, `McpController.ts#McpController.mcp`, `policy: "dev"`).
454
+ Une application ouvre la SIENNE — en développement ou en production, selon ses besoins — en deux
455
+ temps : la porte se **définit une fois** (`defineMcpGate()`, `gate.ts#defineMcpGate`), puis se **sert** d'une
456
+ ligne depuis une action de controller. `npx nodefony create controller <nom> --kind mcp` écrit les
457
+ deux, avec ses outils d'exemple et son test ; `--expose` choisit qui peut frapper :
458
+
459
+ | `--expose` | Qui passe | Ce qui est écrit en plus |
460
+ | ---------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
461
+ | `local` (défaut) | la machine seulement, sans jeton ; page web → `403` | rien : en production derrière un relais, tout appelant est distant, donc refusé |
462
+ | `oauth` | quiconque présente un jeton émis pour la porte | l'émetteur et l'URI publique (`publicBaseUrl`), `allowRemote`, le service qui publie le document RFC 9728 du défi `401`, et l'outil `<nom>_me` qui agit au nom du compte (`actsForUser`, scope `<nom>:read`) |
463
+ | `mixte` | tout le monde pour les outils publics ; un jeton porteur de leurs scopes pour les autres | ce qu'écrit `oauth`, plus `anonymous: true` et `scopes: ["<nom>:write"]` sur l'outil long — le jeton s'émet avec `--scope <nom>:write` |
464
+
465
+ **Une porte publie ce qu'elle CHOISIT** : une liste d'outils importée, comme ci-dessous. `tools:
466
+ "modules"` servirait en plus tout outil qu'un module installé déclare, aujourd'hui ou demain — à
467
+ réserver à l'outillage, jamais à une porte exposée. Aucune ligne de protocole à écrire :
468
+
469
+ ```ts
470
+ import { Controller, route, controller, Body } from "@nodefony/framework";
471
+ import { defineMcpGate } from "@nodefony/ai";
472
+ import { stockTool, orderTool } from "./tools";
473
+
474
+ // Définie UNE fois : validée au chargement — une clé mal écrite, une révision
475
+ // du protocole inconnue, un outil mal formé arrêtent le démarrage en la nommant.
476
+ const shopGate = defineMcpGate({
477
+ name: "shop", // le nom que l'agent voit
478
+ instructions: "Stock et commandes du magasin.", // QUAND l'agent doit s'en servir
479
+ tools: [stockTool, orderTool], // ce que l'agent peut appeler
480
+ });
481
+
482
+ @controller("/api")
483
+ class ShopMcpController extends Controller {
484
+ // POST sert le message ; GET et DELETE répondent 405 (verbes retirés par la spec).
485
+ @route("shop-mcp", {
486
+ path: "/mcp",
487
+ requirements: { methods: ["POST", "GET", "DELETE"] },
488
+ })
489
+ mcp(@Body() body: unknown) {
490
+ return shopGate.serve(this, body);
491
+ }
492
+ }
493
+ ```
494
+
495
+ | Réglage | Défaut | Ce qu'il décide |
496
+ | --------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
497
+ | `tools` | — (requis) | une liste d'outils, ou `"modules"` pour ceux que déclarent les modules chargés (`getMcpTools()`) |
498
+ | `name`, `version`, `instructions` | nom du projet, version de Nodefony, phrase neutre | ce que l'agent lit en arrivant |
499
+ | `allowRemote`, `allowedOrigins` | `false`, `[]` | qui peut frapper : la machine locale seulement, aucune page web |
500
+ | `authorization` | aucune | serveur(s) d'autorisation et URI de la porte : un jeton est alors exigé |
501
+ | `unprotectedRoles` | `[]` | rôles accordés SANS jeton — refusé à côté d'une `authorization` |
502
+ | `protocol.versions` | toutes celles du paquet | figer les révisions acceptées (porte de production) |
503
+ | `cache` | `{ ttlMs: 0, scope: "private" }` | indices de cache des listes, servis aux clients modernes |
504
+ | `enabled` | `true` | coupée, la porte répond `404` |
505
+
506
+ ⚠️ Figer `protocol.versions` sur la seule révision `2026-07-28` coupe les clients qui parlent encore
507
+ l'ancienne forme (`initialize`) — c'est le cas des clients déployés aujourd'hui.
508
+
509
+ Ce que la mécanique tient pour toi (`gate.respond()`, `gate.ts#IMcpGate.respond`, éprouvable sans serveur) :
510
+
511
+ - **`checkMcpAccess()`** (`guard.ts#checkMcpAccess`) : un client MCP légitime est un _process_ et n'envoie
512
+ aucun `Origin` ; une page web malveillante en pose toujours un. _Absent_ → on passe, _présent et
513
+ hors allowlist_ → `403`. C'est ce qui referme le DNS rebinding contre un serveur local.
514
+ - **Une porte locale refuse un appel RELAYÉ** (`403`) : la requête porte `Forwarded`,
515
+ `X-Forwarded-For`, `X-Real-IP` ou `Via`. Un relais posé sur la même machine (le frontal de
516
+ `proxy:generate`) se connecte depuis le loopback ; sans `trustProxy`, l'adresse vue serait la
517
+ sienne, et la porte servirait Internet sans jeton. La localité ne se prouve pas à travers un
518
+ relais : une porte joignable par un proxy se déclare `allowRemote` et se protège par jeton.
519
+ - **`createMcpResponseStream()`** (`stream.ts#createMcpResponseStream`) ouvre le flux SSE au **premier** envoi seulement
520
+ (et seulement si le client l'accepte), enchaîne les envois dans l'ordre, et la réponse finale le
521
+ clôt. Rien envoyé en route → réponse JSON. Pas de reprise (`Last-Event-ID`) : la spec la retire,
522
+ le client rejoue sa requête.
523
+ - **L'annulation** est le signal de la requête : fermer le flux de réponse EST l'annulation, que le
524
+ noyau transmet à l'outil — aucun écouteur à ajouter.
525
+ - **Une porte non protégée n'accorde AUCUN rôle** (`mcpCallerRoles()`, `caller.ts#mcpCallerRoles`) : ses
526
+ outils réservés (`scopes`) restent retenus. Seule une porte que son PÉRIMÈTRE protège l'élargit,
527
+ et l'écrit (`unprotectedRoles`) — c'est le cas de la porte de développement de devkit.
528
+ - **Une porte protégée** (`authorization.authorizationServers`) dérive l'appelant d'un jeton OAuth
529
+ (`authorizeProtectedResource()`, `protectedResource.ts#authorizeProtectedResource`), répond `401` avec
530
+ `resource_metadata` sans jeton, et tire les rôles des scopes. Son document RFC 9728 se publie par
531
+ un service qui expose `publishedProtectedResources()` en rendant `gate.protectedResources()` :
532
+ les scopes publiés sont ceux que le défi exige.
533
+ - **Le jeton est jugé par le vérificateur de l'application** (`accessTokenVerifier`, posé par
534
+ `@nodefony/security`), avec l'URI de la porte comme audience. Un jeton que l'application signe
535
+ elle-même (`npx nodefony security:mcp:token --resource <URI de la porte>`) y est **toujours**
536
+ reconnu — sans rien déclarer dans `resourceServer.issuers` — et jugé par les règles de sa zone
537
+ `jwt` : un jeton révoqué, ou celui d'un compte désactivé, est refusé avant son expiration. Les
538
+ neuf contrôles, leur ordre et la réponse de la porte à chaque verdict :
539
+ [Ce que la porte vérifie](../../security/docs/obtenir-un-jeton.md#-ce-que-la-porte-vérifie--un-jeton-de-cette-application-face-à-une-ressource).
540
+ - **Mixte — des outils publics ET des outils réservés sur la même porte** :
541
+ `authorization.anonymous: true` sert l'appel SANS jeton avec les seuls outils publics, et un
542
+ outil se réserve par ses `scopes` (absent du catalogue de qui ne les porte pas). Un jeton rejeté
543
+ est alors servi comme un inconnu — jamais mieux —, et le refus part au journal.
544
+
545
+ ### Niveau 2 — de vraies requêtes HTTP au nom d'un compte
546
+
547
+ `actsForUser` suffit tant que l'outil appelle des services. Un outil qui doit TIRER de vraies
548
+ requêtes vers l'application (profiler une page protégée, rejouer un parcours) a besoin d'une
549
+ **session** : celle que le pare-feu reconnaît, ouverte par le flux de connexion de l'application,
550
+ qui revérifie le compte et écrit l'audit. `defineTicketSessionGate` (`ticketSession.ts#defineTicketSessionGate`)
551
+ en est la mécanique, sans route — comme `defineMcpGate` :
552
+
553
+ ```ts
554
+ import { defineTicketSessionGate } from "@nodefony/ai";
555
+
556
+ // Le facteur nomme la connexion dans l'audit (`login.success`, raison « replay »).
557
+ export const replayGate = defineTicketSessionGate({ factor: "replay" });
558
+
559
+ @controller("/api/replay")
560
+ class ReplaySessionController extends Controller {
561
+ // Chemin LITTÉRAL : un inventaire des routes doit le voir sans exécuter.
562
+ @route("replay-session", {
563
+ path: "/session",
564
+ requirements: { methods: ["POST"] },
565
+ })
566
+ session() {
567
+ return replayGate.serve(this);
568
+ }
569
+ }
570
+ ```
571
+
572
+ L'outil émet un ticket (`replayGate.issue(identifiant)`), le présente en boucle locale dans
573
+ l'en-tête `x-nodefony-session-ticket`, récupère le cookie de la réponse, tire ses requêtes, puis
574
+ détruit la session (`replayGate.close(sessions, ref)`). La réponse se lit par
575
+ `readTicketSessionVerdict`, écrit à côté de qui la produit.
576
+
577
+ - **Le ticket est la garde** : 256 bits d'aléa, usage unique, dix secondes, huit en attente au
578
+ plus, rangé par empreinte — jamais affiché ni journalisé. L'identité est fixée à l'émission : la
579
+ requête de rachat n'en porte aucune, donc n'en choisit aucune.
580
+ - **Locale par défaut** (`localOnly: true`) : un appel distant, ou RELAYÉ par un proxy de la même
581
+ machine, est refusé avant que le ticket ne soit consommé — la même règle que la porte MCP
582
+ (`guard.ts#isRelayedRequest`). Une porte distante doit écrire `localOnly: false`.
583
+ - **Un refus dit la même chose quelle qu'en soit la cause** (`403`, `TICKET_SESSION_REFUSED`) ;
584
+ `503` quand l'application n'a pas de flux de session (`@nodefony/security` absent).
585
+ - **Qui a droit à quel compte n'est PAS décidé ici** : c'est à l'outil. Celui de devkit
586
+ (`nodefony_profile_route`) joue le propriétaire du jeton, ou un autre compte avec `as` — scope
587
+ d'écriture exigé par `scopesFor`, et rôles visés inclus dans ceux du propriétaire (règle
588
+ `rolesWithin`, portée par `@nodefony/security`).
589
+
590
+ ## 🔌 Deux transports — HTTP ou stdio
591
+
592
+ La norme MCP définit deux façons de joindre un serveur, et **le client ne choisit pas : sa
593
+ configuration le dit**. Une entrée qui porte une `url` se joint en HTTP ; une entrée qui porte une
594
+ `command` est un programme que le client LANCE lui-même, et auquel il parle sur l'entrée et la
595
+ sortie standard (stdio).
596
+
597
+ | L'agent travaille… | Transport | Ce qu'on écrit |
598
+ | --------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------- |
599
+ | sur ta machine, application démarrée (`nodefony dev`) | **HTTP** — la porte `/nodefony/mcp` de devkit | `npx nodefony ai:mcp` écrit `.mcp.json`, rien à choisir |
600
+ | là où aucun serveur ne tourne : CI, agent cloud dans un runner, client stdio seul | **stdio** — `npx nodefony ai:stdio` | la commande, dans la configuration MCP de l'agent |
601
+
602
+ **Pour qui stdio ?** Un agent qui code dans une machine virtuelle — l'agent de GitHub qu'on assigne
603
+ à une issue, un agent de relecture lancé en CI — n'a pas de `nodefony dev` derrière lui :
604
+ `localhost` n'y répond pas, et sans stdio il travaille sans aucun outil de l'application. Avec
605
+ stdio, il lance la commande, l'application démarre en console (aucun port ouvert), et il retrouve
606
+ les mêmes outils :
607
+
608
+ ```json
609
+ {
610
+ "mcpServers": {
611
+ "nodefony": { "command": "npx", "args": ["nodefony", "ai:stdio"] }
612
+ }
613
+ }
614
+ ```
615
+
616
+ (Un client qui type ses entrées — `"type": "local"`, `"type": "stdio"` — prend la même `command` et
617
+ les mêmes `args`.)
618
+
619
+ **Ce que stdio sert, et ce qu'il ne sert pas** :
620
+
621
+ - **Les outils des modules chargés, dans TOUS les environnements**, avec la posture de
622
+ l'**opérateur local** — celle de toute commande du CLI (`adminCaller.ts#localOperatorCaller`) :
623
+ qui lance la commande a déjà un terminal dans l'application, et `nodefony inspect` lui rend
624
+ les mêmes données. Ce rôle n'ouvre que l'**administration du framework** (lecture des routes,
625
+ de la configuration, de la documentation, des symboles ; secrets masqués). Un outil à scope
626
+ **applicatif** (`orders:write`), un outil `requiresAuth` ou `actsForUser` reste **retenu** : il
627
+ exige un jeton, donc une porte HTTP protégée. Les outils `nodefony_*` n'y paraissent que
628
+ lorsque `@nodefony/devkit` est chargé (développement).
629
+ - **Le code du démarrage, sans rechargement à chaud** : une modification des sources ne se voit
630
+ qu'au lancement suivant. La porte HTTP, elle, suit les sources.
631
+ - **Aucun trafic** : ce process ne sert aucune requête. Les outils de MESURE
632
+ (`nodefony_observe`, `nodefony_profile_route`) le disent et renvoient à la porte HTTP, au lieu de
633
+ rendre « aucune requête » avec aplomb. Lire le code, la configuration, les routes, la base, la
634
+ documentation marche à l'identique.
635
+ - **La sortie standard EST le protocole** (norme MCP : rien d'autre qu'un message n'y passe). Le
636
+ journal part sur la sortie d'erreur, et tout `console.log` d'un outil y est détourné : son auteur
637
+ le voit encore, le client n'en sait rien.
638
+ - **L'annulation** passe par `notifications/cancelled` — il n'y a pas de flux par requête à fermer.
639
+ Un appel annulé ne reçoit plus RIEN : ni progression, ni réponse.
640
+ - **La fin** : le client ferme l'entrée standard ; les appels en cours sont abattus, la commande
641
+ sort en `0`.
642
+
643
+ Une application qui veut SA commande stdio, sur sa propre porte, n'a qu'une ligne à écrire —
644
+ `serveMcpStdio(gate, { input: process.stdin, write })` (`stdio.ts#serveMcpStdio`) ; la règle vit
645
+ dans la porte (`gate.respondStdio`, `gate.ts#defineMcpGate`), la boucle ne fait que lire et écrire
646
+ des lignes.
647
+
648
+ ## 📏 Normes appliquées — ce que la boîte à outils tient pour toi
649
+
650
+ Révision de référence : `2026-07-28` (`MCP_PROTOCOL_VERSION`, `protocol.ts#MCP_PROTOCOL_VERSION`), servie avec les
651
+ révisions antérieures que les clients déployés emploient encore (`MCP_SUPPORTED_VERSIONS`,
652
+ `protocol.ts#MCP_SUPPORTED_VERSIONS`).
653
+
654
+ | Exigence de la norme | Comment elle est tenue |
655
+ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
656
+ | une notification acceptée → `202` sans corps | `handleMcpMessage()` rend `{ status: 202, body: null }` |
657
+ | l'`id` d'une requête est une chaîne ou un entier, jamais `null` | refus `400` + `-32600` avant tout autre contrôle |
658
+ | `params` est un objet nommé | un tableau → `-32602`, une valeur primitive → `-32600` |
659
+ | en-tête `MCP-Protocol-Version` ≠ `_meta` → refus | `400` + `-32020` (`McpProtocolError`, `protocol.ts#McpProtocolError`) — deux sources de vérité pour une même requête |
660
+ | révision inconnue → refus avec la liste servie | `400` + `-32022` et `supported` (`checkProtocolVersion()`, `server.ts#checkProtocolVersion`) |
661
+ | `initialize` répond la révision DEMANDÉE quand elle est servie | `negotiateVersion()` (`server.ts#negotiateVersion`) — annoncer la sienne rendait la porte injoignable |
662
+ | ère moderne : `resultType` sur chaque résultat | `eraResult()` (`server.ts#eraResult`) donne au résultat la forme de l'ère du CLIENT |
663
+ | méthode non implémentée → `404` + `-32601` | cas par défaut du protocole |
664
+ | progression : jeton fourni, valeur croissante, cadence bornée, arrêt à la fin | `createProgressReporter()` (`src/packages/@nodefony/ai/nodefony/mcp/progress.ts#createProgressReporter`) ; arrêt dans le `finally` de `tools/call` (`server.ts#handleMcpMessage`) |
665
+ | flux : notifications liées à la requête, réponse finale qui le termine | `createMcpResponseStream()` (`stream.ts#createMcpResponseStream`) |
666
+ | sur HTTP, fermer le flux = annuler | `run.signal` = le signal de la requête |
667
+ | le catalogue peut varier selon l'autorisation, jamais selon la connexion | collecte à chaque requête, sur les identifiants de CETTE requête |
668
+
669
+ ## 🚫 Ce qui n'est pas servi — et pourquoi
670
+
671
+ | Méthode / capacité | Réponse | Raison |
672
+ | --------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
673
+ | `subscriptions/listen` | `404` + `-32601`, raison dite | le catalogue ne change qu'au rechargement de l'application, qui redémarre son process : le flux casserait au lieu de notifier ; le `tools/list` suivant est à jour |
674
+ | `notifications/message` (journal) | jamais émis | déprécié par la révision `2026-07-28` |
675
+ | `server/discover` | `-32601` | servi, il faisait basculer le client dominant sur un fil qu'il n'achève pas (aucun outil enregistré) ; le repli vers `initialize` que prévoit la norme, lui, fonctionne |
676
+ | `GET` / `DELETE` sur la porte | `405` + `Allow: POST` | flux autonome et fin de session, retirés par la révision `2026-07-28` |
677
+
678
+ ## 🔐 Sécurité
679
+
680
+ - **Fermé par défaut.** Appelant anonyme tant que la porte n'authentifie pas ; un outil qui exige
681
+ quoi que ce soit n'est alors ni listé ni appelable.
682
+ - **Le catalogue ne se divulgue pas.** Un outil retenu répond « outil inconnu » ; l'annonce
683
+ d'`initialize` dit seulement _combien_ d'outils sont réservés — de quoi faire demander un jeton à
684
+ un agent, sans nommer ce qu'on protège.
685
+ - **Un outil lit ce que l'appelant peut lire.** Les outils d'administration intégrés exigent
686
+ `admin:read` et rendent les réponses du plan d'administration, redaction des secrets comprise.
687
+ - **En production, le jeton vient d'un serveur d'autorisation EXTERNE** (`authorizationServers`,
688
+ document RFC 9728 publié). `security:mcp:token` est un outil de poste et d'exploitation : qui
689
+ détient `NF_JWT_KEYSET` émet un jeton pour N'IMPORTE QUEL compte — avec `actsForUser`, c'est agir
690
+ en son nom.
691
+ - **Avant d'exposer une donnée**, se demander si elle supporterait d'être lue par qui atteint la
692
+ porte. En développement, la porte est bornée par sa garde de transport et par la politique
693
+ `dev` de son module.
694
+
695
+ ## ⚡ Performance & mémoire
696
+
697
+ - **Rien au démarrage, rien entre deux appels** : aucun registre, aucun cache, aucun état retenu.
698
+ - **Une lecture courte ne paie pas le flux** : il n'est ouvert qu'au premier envoi.
699
+ - **La progression ne réveille rien par défaut** : sans jeton ou sans flux, le rapporteur est un
700
+ objet partagé qui ne fait rien ; avec, il coûte un appel d'horloge et se tait sous 100 ms
701
+ d'intervalle (`MCP_PROGRESS_MIN_INTERVAL_MS`, `src/packages/@nodefony/ai/nodefony/mcp/progress.ts#MCP_PROGRESS_MIN_INTERVAL_MS`).
702
+ - **Aucun écouteur ajouté pour l'annulation** : le signal est celui du contexte, alloué à la
703
+ première lecture et abattu par le noyau.
704
+
705
+ ## ⚠️ Pièges
706
+
707
+ | Symptôme | Cause | Correction |
708
+ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
709
+ | mon outil n'apparaît pas dans `tools/list` | il exige des scopes, et la porte n'authentifie pas — ou son nom est hors forme | lire le journal : `WARNING` (écarté) ou `DEBUG` (retenu, avec la raison) |
710
+ | l'agent reçoit « outil inconnu » sur un outil existant | même cause : un outil retenu est inconnu pour cet appelant, c'est voulu | présenter un jeton portant les scopes déclarés |
711
+ | aucune notification de progression n'arrive | la requête ne porte pas `_meta.progressToken`, ou le client n'accepte pas `text/event-stream` | les deux sont nécessaires ; sans jeton, un outil de plus de 5 s répond quand même en flux, mais sans notifications — c'est le comportement normal |
712
+ | un outil long reçoit `408` au bout de 30 s | le client n'accepte pas `text/event-stream` : la porte ne peut pas ouvrir de flux, la réponse JSON tombe sous le délai d'inactivité | un client conforme l'accepte toujours ; sinon, borner l'outil sous le délai (`http.timeout`) |
713
+ | la progression s'arrête alors que l'outil avance | des valeurs qui ne croissent pas, ou plus d'une par 100 ms | passer une valeur strictement croissante ; regrouper les étapes rapprochées |
714
+ | un argument passé « ne fait rien » | il n'est pas déclaré dans `inputSchema` — l'appel est refusé en `isError` | le déclarer, ou `additionalProperties: true` si l'outil prend des arguments libres |
715
+ | un échec métier apparaît comme une panne serveur (`-32603`) | le handler a levé une exception | rendre `mcpText(message, true)` |
716
+ | le client demande confirmation pour une simple lecture | l'outil ne porte pas `annotations` : la spec le tient pour une écriture destructive | poser `readOnlyHint: true` (et `openWorldHint: false` s'il reste dans l'application) |
717
+ | le client exécute sans confirmation un outil qui écrit | `readOnlyHint: true` posé sur un handler qui modifie quelque chose | `readOnlyHint: false`, puis `destructiveHint` selon ce qu'il efface |
718
+ | l'outil continue de tourner après l'abandon de l'agent | il ne consulte pas `run.signal` | le tester entre deux étapes, ou le passer aux appels qui l'acceptent |
719
+
720
+ ## 🧪 Tests & couverture
721
+
722
+ Trois étages, chacun dans le paquet qui porte ce qu'il éprouve :
723
+
724
+ - **Sans serveur — `@nodefony/ai` (`npm test`)** : `tests/gate.test.ts` (la porte : définition
725
+ refusée au démarrage, gardes, statuts, appelant, versions par porte, cache, instructions, flux),
726
+ `mcpTools`, `mcpGuard`, `mcpCaller`, `mcpProgress`, `mcpStream` (`tests/*.test.ts`) ;
727
+ - **Sans serveur — `@nodefony/devkit` (`npm test`)** : le catalogue `nodefony_*`
728
+ (`tests/mcp.test.ts`, `tests/mcpCatalog.test.ts`), les outils d'administration face à un appelant
729
+ sans droits (`tests/mcpAdminPlane.attack.test.ts`), le document RFC 9728
730
+ (`tests/oauthMetadata.test.ts`) ;
731
+ - **Serveur réel — `npm run test:integration`**, lancé par la CI avec le serveur démarré :
732
+ - `@nodefony/ai` `tests/integration/gate-http.test.ts` — la porte témoin d'APPLICATION du
733
+ module `test` (`policy: "dev"`) : requêtes forgées (statuts, codes) et client OFFICIEL
734
+ `@modelcontextprotocol/sdk` (connexion, catalogue, appel, progression SSE) ;
735
+ - `@nodefony/devkit` `tests/integration/mcp-http.test.ts` — la porte de développement : route
736
+ montée, garde `Origin`, autorisation, outils d'un module de l'application, flux SSE et annulation,
737
+ client officiel.
738
+
739
+ Ces deux suites ne se SAUTENT pas en silence : sans serveur, elles le disent, et en CI leur
740
+ rapporteur transforme le saut en échec (« couverture incomplète »).
741
+
742
+ Chaque garde de la progression et du flux a été vue échouer en la débranchant. Ce qui **n'est pas
743
+ couvert** : aucun banc de charge dédié à la porte, et aucun test du flux derrière un proxy inverse
744
+ (la tenue d'un flux SSE par un proxy est traitée dans [Server-Sent Events](../../http/docs/sse.md)).
745
+ Couverture : `npm run coverage` dans `src/packages/@nodefony/ai`.
746
+
747
+ ## 🔗 Pour aller plus loin
748
+
749
+ - ⬆️ **Retour au hub** : [@nodefony/ai — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
750
+ - La porte de développement, ses outils intégrés et son autorisation OAuth →
751
+ [devkit](../../devkit/docs/index.md)
752
+ - Le flux d'événements serveur sur lequel la porte répond → [Server-Sent Events](../../http/docs/sse.md)
753
+ - Le contexte de requête, dont le signal sert l'annulation → [Contexte de requête](../../../../nodefony/docs/request-context.md)
754
+ - La norme, hors ligne : skill `nodefony-rfc` (`references/mcp-2026-07-28/`)