@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.
- package/README.md +14 -6
- package/dist/_virtual/{_@oxc-project_runtime@0.154.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +21 -14
- package/dist/nodefony/command/StdioCommand.js +75 -0
- package/dist/nodefony/config/config.js +141 -8
- package/dist/nodefony/config/configSchema.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/mcp/caller.js +38 -0
- package/dist/nodefony/mcp/gate.js +628 -0
- package/dist/nodefony/mcp/guard.js +82 -0
- package/dist/nodefony/mcp/progress.js +74 -0
- package/dist/nodefony/mcp/protocol.js +76 -0
- package/dist/nodefony/mcp/server.js +302 -0
- package/dist/nodefony/mcp/sessionTicket.js +89 -0
- package/dist/nodefony/mcp/stdio.js +64 -0
- package/dist/nodefony/mcp/stream.js +80 -0
- package/dist/nodefony/mcp/ticketSession.js +177 -0
- package/dist/nodefony/mcp/tools.js +485 -0
- package/dist/nodefony/src/errors/AiError.js +1 -2
- package/dist/types/index.d.ts +25 -0
- package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +28 -32
- package/dist/types/nodefony/mcp/caller.d.ts +54 -0
- package/dist/types/nodefony/mcp/gate.d.ts +235 -0
- package/dist/types/nodefony/mcp/guard.d.ts +89 -0
- package/dist/types/nodefony/mcp/progress.d.ts +48 -0
- package/dist/types/nodefony/mcp/protocol.d.ts +107 -0
- package/dist/types/nodefony/mcp/server.d.ts +156 -0
- package/dist/types/nodefony/mcp/sessionTicket.d.ts +60 -0
- package/dist/types/nodefony/mcp/stdio.d.ts +38 -0
- package/dist/types/nodefony/mcp/stream.d.ts +76 -0
- package/dist/types/nodefony/mcp/ticketSession.d.ts +174 -0
- package/dist/types/nodefony/mcp/tools.d.ts +232 -0
- package/docs/index.md +56 -0
- package/docs/mcp.md +754 -0
- package/package.json +13 -11
- package/dist/_virtual/_@oxc-project_runtime@0.154.0/helpers/esm/decorateMetadata.js +0 -6
- package/dist/nodefony/interfaces/IAiService.js +0 -1
- package/dist/nodefony/interfaces/index.js +0 -1
- package/dist/nodefony/service/AiService.js +0 -96
- package/dist/types/nodefony/interfaces/IAiService.d.ts +0 -15
- package/dist/types/nodefony/interfaces/index.d.ts +0 -1
- package/dist/types/nodefony/service/AiService.d.ts +0 -80
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/`)
|