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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +14 -6
  2. package/dist/_virtual/{_@oxc-project_runtime@0.154.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +21 -14
  4. package/dist/nodefony/command/StdioCommand.js +75 -0
  5. package/dist/nodefony/config/config.js +141 -8
  6. package/dist/nodefony/config/configSchema.js +1 -2
  7. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  8. package/dist/nodefony/mcp/caller.js +38 -0
  9. package/dist/nodefony/mcp/gate.js +628 -0
  10. package/dist/nodefony/mcp/guard.js +82 -0
  11. package/dist/nodefony/mcp/progress.js +74 -0
  12. package/dist/nodefony/mcp/protocol.js +76 -0
  13. package/dist/nodefony/mcp/server.js +302 -0
  14. package/dist/nodefony/mcp/sessionTicket.js +89 -0
  15. package/dist/nodefony/mcp/stdio.js +64 -0
  16. package/dist/nodefony/mcp/stream.js +80 -0
  17. package/dist/nodefony/mcp/ticketSession.js +177 -0
  18. package/dist/nodefony/mcp/tools.js +485 -0
  19. package/dist/nodefony/src/errors/AiError.js +1 -2
  20. package/dist/types/index.d.ts +25 -0
  21. package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
  22. package/dist/types/nodefony/config/config.d.ts +28 -32
  23. package/dist/types/nodefony/mcp/caller.d.ts +54 -0
  24. package/dist/types/nodefony/mcp/gate.d.ts +235 -0
  25. package/dist/types/nodefony/mcp/guard.d.ts +89 -0
  26. package/dist/types/nodefony/mcp/progress.d.ts +48 -0
  27. package/dist/types/nodefony/mcp/protocol.d.ts +107 -0
  28. package/dist/types/nodefony/mcp/server.d.ts +156 -0
  29. package/dist/types/nodefony/mcp/sessionTicket.d.ts +60 -0
  30. package/dist/types/nodefony/mcp/stdio.d.ts +38 -0
  31. package/dist/types/nodefony/mcp/stream.d.ts +76 -0
  32. package/dist/types/nodefony/mcp/ticketSession.d.ts +174 -0
  33. package/dist/types/nodefony/mcp/tools.d.ts +232 -0
  34. package/docs/index.md +56 -0
  35. package/docs/mcp.md +754 -0
  36. package/package.json +13 -11
  37. package/dist/_virtual/_@oxc-project_runtime@0.154.0/helpers/esm/decorateMetadata.js +0 -6
  38. package/dist/nodefony/interfaces/IAiService.js +0 -1
  39. package/dist/nodefony/interfaces/index.js +0 -1
  40. package/dist/nodefony/service/AiService.js +0 -96
  41. package/dist/types/nodefony/interfaces/IAiService.d.ts +0 -15
  42. package/dist/types/nodefony/interfaces/index.d.ts +0 -1
  43. package/dist/types/nodefony/service/AiService.d.ts +0 -80
package/README.md CHANGED
@@ -30,8 +30,9 @@ comme le vôtre le sera.
30
30
 
31
31
  `@nodefony/ai` se charge comme `@nodefony/http` ou `@nodefony/framework` : toujours, dans
32
32
  tous les environnements. Les autres modules s'y appuient pour déclarer leurs outils. Il ne coûte
33
- rien tant qu'aucune capacité n'est configurée, et n'ouvre aucune porte en production sans
34
- autorisation.
33
+ rien tant qu'aucune capacité n'est configurée, et ne monte **aucune route** : une porte MCP,
34
+ c'est vous qui la déclarez — une porte `defineMcpGate()` servie par un controller, en développement ou en
35
+ production selon vos besoins.
35
36
 
36
37
  ## Ce qui est fait, ce qui vient
37
38
 
@@ -54,9 +55,17 @@ se lit dans le
54
55
 
55
56
  Chaque outil y annonce s'il ne fait que lire ou s'il modifie (indices `ToolAnnotations` de la
56
57
  spec, [#540](https://github.com/nodefony/nodefony-core/issues/540)), pour que l'agent sache quand
57
- demander confirmation. La boîte à outils MCP fonctionne déjà : elle sert aujourd'hui depuis le paquet `nodefony` (et la
58
- porte de développement de `@nodefony/devkit`). Elle rejoint ce paquet pour la `10.0.0`, qui en
59
- devient la seule adresse.
58
+ demander confirmation. La boîte à outils MCP vit dans ce paquet, qui en est la seule adresse. La porte de développement
59
+ de `@nodefony/devkit` (`/nodefony/mcp`) est bâtie sur la même mécanique que celle que vous
60
+ ouvrirez pour vos propres outils.
61
+
62
+ Un outil peut **agir au nom de l'utilisateur** qui l'appelle (`actsForUser`) : la porte retrouve
63
+ son compte avant le handler, et les services qu'il appelle le voient comme sous une route
64
+ protégée ([#648](https://github.com/nodefony/nodefony-core/issues/648)). Un paramètre qui élargit ce
65
+ que fait un outil exige son scope à l'appel (`scopesFor`) : l'outil reste visible, et l'agent
66
+ reçoit un défi `insufficient_scope` qui lui dit quel jeton demander
67
+ ([#650](https://github.com/nodefony/nodefony-core/issues/650)). Le détail :
68
+ [la boîte à outils MCP](./docs/mcp.md).
60
69
 
61
70
  ### Prévu
62
71
 
@@ -109,7 +118,6 @@ nommant le champ, au lieu d'être ignorée en silence.
109
118
  [livre blanc](https://github.com/nodefony/nodefony-core/blob/main/docs/ia/livre-blanc-couche-ia.md)
110
119
  - Les décisions d'architecture :
111
120
  [ADR](https://github.com/nodefony/nodefony-core/tree/main/docs/adr)
112
- - La documentation du module : [`docs/`](./docs/index.md)
113
121
 
114
122
  ## Licence
115
123
 
@@ -1,9 +1,8 @@
1
- //#region \0@oxc-project+runtime@0.154.0/helpers/esm/decorate.js
1
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
7
7
  }
8
- //#endregion
9
8
  export { __decorate as default };
package/dist/index.js CHANGED
@@ -1,14 +1,26 @@
1
- import defaults from "./nodefony/config/config.js";
2
- import { defineAiConfig } from "./nodefony/config/defineModuleConfig.js";
3
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.154.0/helpers/esm/decorateMetadata.js";
4
- import __decorate from "./_virtual/_@oxc-project_runtime@0.154.0/helpers/esm/decorate.js";
5
- import AiService_default from "./nodefony/service/AiService.js";
6
- import { Kernel, Module, services } from "nodefony";
1
+ import defaults, { aiConfigSchema, mcpConfigSchema } from "./nodefony/config/config.js";
2
+ import { aiConfigJsonSchema, defineAiConfig } from "./nodefony/config/defineModuleConfig.js";
3
+ import { mcpCallerRoles } from "./nodefony/mcp/caller.js";
4
+ import { RELAY_HEADERS, checkMcpAccess, isLocalAddress, isRelayedRequest } from "./nodefony/mcp/guard.js";
5
+ import { JsonRpcError, MCP_DEFAULT_NEGOTIATED_VERSION, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, McpProtocolError, isNotification, jsonRpcFailure, jsonRpcSuccess } from "./nodefony/mcp/protocol.js";
6
+ import { MCP_PROGRESS_MESSAGE_MAX, MCP_PROGRESS_MIN_INTERVAL_MS, createProgressReporter, readProgressToken } from "./nodefony/mcp/progress.js";
7
+ import { MCP_RESERVED_PREFIX, MCP_TEXT_MAX_CHARS, callMcpTool, collectMcpTools, declareMcpTools, insufficientScopeMessage, mcpCallScopes, mcpDeclaredScopes, mcpText, publishMcpTools } from "./nodefony/mcp/tools.js";
8
+ import { handleMcpMessage } from "./nodefony/mcp/server.js";
9
+ import { MCP_STREAM_GRACE_MS, createMcpResponseStream } from "./nodefony/mcp/stream.js";
10
+ import { MCP_STREAM_MAX_EVENT_BYTES, defineMcpGate, mcpGateSchema } from "./nodefony/mcp/gate.js";
11
+ import { serveMcpStdio } from "./nodefony/mcp/stdio.js";
12
+ import StdioCommand from "./nodefony/command/StdioCommand.js";
13
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
14
+ import { SESSION_TICKET_HEADER, SESSION_TICKET_LENGTH, SESSION_TICKET_MAX_PENDING, SESSION_TICKET_TTL_MS, SessionTicketStore, fingerprintSessionTicket } from "./nodefony/mcp/sessionTicket.js";
15
+ import { TICKET_SESSION_REFUSED, defineTicketSessionGate, isSessionFlowView, isSessionServiceView, openTicketSession, readTicketSessionVerdict, ticketSessionGateSchema, toTicketSessionUser } from "./nodefony/mcp/ticketSession.js";
16
+ import { AiError } from "./nodefony/src/errors/AiError.js";
17
+ import { Module } from "nodefony";
7
18
  import { controllers } from "@nodefony/framework";
8
- //#region index.ts
19
+ // index.ts
9
20
  let AiModule = class AiModule extends Module {
10
21
  constructor(kernel) {
11
22
  super("ai", kernel, import.meta.url, defaults);
23
+ this.addCommand(StdioCommand);
12
24
  }
13
25
  /**
14
26
  * Valide la config au boot — défauts du schéma fusionnés avec ce que l'app
@@ -20,11 +32,6 @@ let AiModule = class AiModule extends Module {
20
32
  return this;
21
33
  }
22
34
  };
23
- AiModule = __decorate([
24
- controllers([]),
25
- services([AiService_default]),
26
- __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])
27
- ], AiModule);
35
+ AiModule = __decorate([controllers([])], AiModule);
28
36
  var ai_default = AiModule;
29
- //#endregion
30
- export { ai_default as default };
37
+ export { AiError, JsonRpcError, MCP_DEFAULT_NEGOTIATED_VERSION, MCP_PROGRESS_MESSAGE_MAX, MCP_PROGRESS_MIN_INTERVAL_MS, MCP_PROTOCOL_VERSION, MCP_RESERVED_PREFIX, MCP_STREAM_GRACE_MS, MCP_STREAM_MAX_EVENT_BYTES, MCP_SUPPORTED_VERSIONS, MCP_TEXT_MAX_CHARS, META_PROTOCOL_VERSION, META_SERVER_INFO, McpProtocolError, RELAY_HEADERS, SESSION_TICKET_HEADER, SESSION_TICKET_LENGTH, SESSION_TICKET_MAX_PENDING, SESSION_TICKET_TTL_MS, SessionTicketStore, TICKET_SESSION_REFUSED, aiConfigJsonSchema, aiConfigSchema, callMcpTool, checkMcpAccess, collectMcpTools, createMcpResponseStream, createProgressReporter, declareMcpTools, ai_default as default, defineAiConfig, defineMcpGate, defineTicketSessionGate, fingerprintSessionTicket, handleMcpMessage, insufficientScopeMessage, isLocalAddress, isNotification, isRelayedRequest, isSessionFlowView, isSessionServiceView, jsonRpcFailure, jsonRpcSuccess, mcpCallScopes, mcpCallerRoles, mcpConfigSchema, mcpDeclaredScopes, mcpGateSchema, mcpText, openTicketSession, publishMcpTools, readProgressToken, readTicketSessionVerdict, serveMcpStdio, ticketSessionGateSchema, toTicketSessionUser };
@@ -0,0 +1,75 @@
1
+ import { defineMcpGate } from "../mcp/gate.js";
2
+ import { serveMcpStdio } from "../mcp/stdio.js";
3
+ import { Command, STDERR_LOG_SINK, Syslog, localOperatorCaller } from "nodefony";
4
+ // nodefony/command/StdioCommand.ts
5
+ const options = {
6
+ helpGroup: "AGENTS ET OUTILLAGE",
7
+ showBanner: false,
8
+ kernelEvent: "onPostReady",
9
+ quietBoot: true,
10
+ examples: ["nodefony ai:stdio", "nodefony ai:stdio 2> mcp-stdio.log"]
11
+ };
12
+ /**
13
+ * Ce que l'agent lit en arrivant : ce process n'est PAS le serveur de
14
+ * l'application, et ce qui en découle.
15
+ */
16
+ const STDIO_INSTRUCTIONS = "Outils de cette application Nodefony, servis par un process lancé pour toi (transport stdio). Ils répondent depuis le code tel qu'il a démarré — sans rechargement à chaud — et ce process ne sert aucune requête : ce qui mesure le trafic réel exige l'application démarrée et sa porte HTTP.";
17
+ /**
18
+ * Sert le MCP de l'application sur l'entrée et la sortie standard —
19
+ * `nodefony ai:stdio`.
20
+ *
21
+ * Pour le client qui lance lui-même son serveur MCP et lui parle par stdio
22
+ * (agent de CI, interface en ligne de commande, agent cloud `"type": "local"`) :
23
+ * rien n'y écoute sur `localhost`, la route `/nodefony/mcp` lui est hors
24
+ * d'atteinte.
25
+ *
26
+ * Servi dans TOUS les environnements : les outils des modules chargés, avec la
27
+ * posture de l'OPÉRATEUR LOCAL — celle de toute commande du CLI
28
+ * (`localOperatorCaller`, qui dit pourquoi). Qui lance la commande a déjà un
29
+ * terminal dans l'application : `nodefony inspect` lui rend les mêmes données.
30
+ * Ce rôle n'ouvre que l'administration du framework (`admin:read`/`admin:write`
31
+ * se traduisent en lui, `rolesFromScopes`) ; un outil à scope APPLICATIF, un
32
+ * outil `requiresAuth` ou `actsForUser` reste retenu — il exige un jeton, donc
33
+ * une porte HTTP protégée. Les outils `nodefony_*` n'y paraissent que lorsque
34
+ * `@nodefony/devkit` est chargé (développement).
35
+ *
36
+ * Limite : le code servi est celui du boot, sans rechargement à chaud — la
37
+ * porte HTTP de développement reste celle qui suit les sources.
38
+ */
39
+ var StdioCommand = class extends Command {
40
+ constructor(cli) {
41
+ super("ai:stdio", "sert le MCP de l'app en stdio, sans serveur", cli, options);
42
+ }
43
+ async generate() {
44
+ const stdout = process.stdout;
45
+ const original = stdout.write.bind(stdout);
46
+ const protocolWrite = original;
47
+ Syslog.setLogSink(STDERR_LOG_SINK);
48
+ stdout.write = process.stderr.write.bind(process.stderr);
49
+ const onOutputError = (error) => {
50
+ if (error.code !== "EPIPE") this.log(`MCP stdio — sortie standard : ${error.message}`, "ERROR");
51
+ };
52
+ stdout.on("error", onOutputError);
53
+ try {
54
+ await serveMcpStdio(defineMcpGate({
55
+ tools: "modules",
56
+ instructions: STDIO_INSTRUCTIONS,
57
+ unprotectedRoles: [...localOperatorCaller().roles]
58
+ }), {
59
+ input: process.stdin,
60
+ write: (line) => {
61
+ protocolWrite(line);
62
+ },
63
+ runtime: {
64
+ modules: this.kernel?.modules,
65
+ log: (message, severity) => this.log(message, severity)
66
+ }
67
+ });
68
+ } finally {
69
+ stdout.write = original;
70
+ stdout.removeListener("error", onOutputError);
71
+ }
72
+ return this;
73
+ }
74
+ };
75
+ export { StdioCommand as default };
@@ -1,5 +1,6 @@
1
+ import { canonicalResourceUri } from "nodefony";
1
2
  import { z } from "zod";
2
- //#region nodefony/config/config.ts
3
+ // nodefony/config/config.ts
3
4
  /**
4
5
  * @nodefony/ai — CONFIGURATION DU MODULE (schéma Zod = source unique).
5
6
  *
@@ -36,13 +37,145 @@ import { z } from "zod";
36
37
  * ceci l'attrape au DÉMARRAGE, pour tout ce qui contourne le compilateur — une
37
38
  * config chargée d'un fichier, un `as never`, un override d'environnement.
38
39
  */
39
- const aiConfigSchema = z.strictObject({
40
- /** Interrupteur du module — l'app peut le charger sans l'activer. */
41
- enabled: z.boolean().default(true).describe("Active les fonctionnalités du module ai"),
42
- /** Exemple : à remplacer par la vraie config du module. */
43
- greeting: z.string().min(1).default("Bonjour de ai").describe("Message renvoyé par le service (exemple de champ configurable)")
40
+ /**
41
+ * Rôle **serveur de ressource OAuth 2.1** de la porte MCP — éteint par défaut.
42
+ *
43
+ * ⭐ **Un seul réglage commande : {@link authorizationServers}.** Vide, la porte
44
+ * reste anonyme — aucune métadonnée publiée, aucun `401`. Non vide, le rôle
45
+ * s'allume ENTIÈREMENT. Un interrupteur séparé aurait permis deux états qui se
46
+ * contredisent (« protégé mais sans serveur d'autorisation », « métadonnées
47
+ * publiées mais jetons ignorés »), et c'est précisément ce genre d'état que
48
+ * personne ne diagnostique.
49
+ *
50
+ * ⚠️ **Allumer ce rôle ne suffit pas à protéger quoi que ce soit** : il faut
51
+ * aussi qu'un service du conteneur sache VÉRIFIER un jeton. S'il n'y en a pas,
52
+ * la porte refuse de servir (et le dit fort) plutôt que d'accepter n'importe
53
+ * quel porteur.
54
+ */
55
+ const mcpAuthorizationSchema = z.strictObject({
56
+ /**
57
+ * Serveurs d'autorisation capables d'émettre un jeton pour cette porte.
58
+ *
59
+ * Vide (défaut) = rôle éteint. Non vide, la spécification MCP impose que le
60
+ * document de métadonnées les publie (« MUST include the
61
+ * `authorization_servers` field containing at least one authorization
62
+ * server ») : sans eux, un client apprendrait qu'un jeton est nécessaire
63
+ * sans jamais pouvoir en obtenir un.
64
+ */
65
+ authorizationServers: z.array(z.string()).default([]).describe("Émetteurs de jetons acceptés (issuer OAuth) ; vide = aucune autorisation"),
66
+ /**
67
+ * URI canonique publique de cette porte — l'audience que les jetons doivent
68
+ * porter (RFC 8707).
69
+ *
70
+ * 🔴 **Elle s'écrit, elle ne se devine pas depuis l'en-tête `Host`.** Si
71
+ * l'URI publiée et l'audience attendue venaient toutes deux de la requête,
72
+ * un `Host` forgé obtiendrait un jeton d'audience arbitraire *et* passerait
73
+ * la vérification : la liaison d'audience, dont c'est l'unique raison
74
+ * d'être, ne protégerait plus rien.
75
+ *
76
+ * Ex. `https://mon-app.example/nodefony/mcp`.
77
+ */
78
+ resource: z.string().default("").describe("URI publique de la porte MCP (audience attendue des jetons)"),
79
+ /**
80
+ * Autres adresses de CETTE MÊME porte, dont les jetons sont aussi acceptés.
81
+ *
82
+ * Le cas courant : l'application sert la porte en clair sur un port et en
83
+ * TLS sur un autre. Un jeton émis pour l'une était alors refusé sur l'autre
84
+ * — la liaison d'audience (RFC 8707) faisant son travail. Nommer la seconde
85
+ * adresse est la bonne réponse ; relâcher la liaison n'en serait pas une.
86
+ *
87
+ * 🔴 Ces valeurs s'ÉCRIVENT. Dérivées du `Host`, un en-tête forgé
88
+ * obtiendrait un jeton d'audience arbitraire ET passerait la vérification.
89
+ * Seule `resource` est publiée en RFC 9728 : le document n'admet qu'une
90
+ * valeur, et un client n'a besoin que d'une adresse.
91
+ *
92
+ * ⚠️ L'ÉMETTEUR doit servir ces audiences (`security.jwt.audiences`), sinon
93
+ * il refuse de les émettre — `invalid_target`, et à juste titre.
94
+ */
95
+ additionalResources: z.array(z.string()).default([]).describe("Autres URI de la même porte dont les jetons sont acceptés (jamais dérivées du Host)"),
96
+ /** Nom lisible, affiché pendant le consentement. */
97
+ resourceName: z.string().optional().describe("Nom affiché au consentement"),
98
+ /** Page de documentation destinée à un humain. */
99
+ resourceDocumentation: z.string().optional().describe("URL de documentation de la ressource"),
100
+ /**
101
+ * Servir une requête SANS jeton comme anonyme, au lieu de la refuser.
102
+ *
103
+ * `false` (défaut) : sans jeton, `401` + `WWW-Authenticate` — c'est ce qui
104
+ * apprend au client qu'une autorisation existe et où l'obtenir.
105
+ * `true` : la porte reste ouverte aux outils publics, et les outils
106
+ * réservés restent retenus. Utile en développement ; c'est un choix qui
107
+ * s'écrit, jamais un comportement dont on hérite.
108
+ */
109
+ anonymous: z.boolean().default(false).describe("Tolérer les requêtes sans jeton (outils publics seulement)")
110
+ }).superRefine((value, ctx) => {
111
+ if (value.authorizationServers.length === 0) return;
112
+ if (value.resource.length === 0) {
113
+ ctx.addIssue({
114
+ code: "custom",
115
+ path: ["resource"],
116
+ message: "`authorization.resource` est requis dès qu'un serveur d'autorisation est déclaré : c'est l'URI publique de la porte, et l'audience que les jetons doivent porter (RFC 8707)."
117
+ });
118
+ return;
119
+ }
120
+ try {
121
+ canonicalResourceUri(value.resource);
122
+ } catch (error) {
123
+ ctx.addIssue({
124
+ code: "custom",
125
+ path: ["resource"],
126
+ message: error instanceof Error ? error.message : String(error)
127
+ });
128
+ }
129
+ });
130
+ /**
131
+ * Réglages de la porte MCP (`POST /nodefony/mcp`) — la SOURCE UNIQUE du schéma
132
+ * `mcp.*`, que tout module qui sert ou configure la porte importe d'ici.
133
+ *
134
+ * Les outils exposés ne se règlent PAS ici : chaque module déclare les siens
135
+ * (`getMcpTools()`), et un module qui veut filtrer son propre catalogue porte ce
136
+ * réglage dans SA config.
137
+ *
138
+ * Tant qu'aucun serveur d'autorisation n'est déclaré, la porte n'est protégée
139
+ * que par les deux gardes que la spec impose au transport ({@link allowedOrigins},
140
+ * {@link allowRemote}) — qui visent le risque réel d'une porte locale : une page
141
+ * web ouverte dans le navigateur du développeur.
142
+ *
143
+ * ⚠️ **Piège Zod 4** : un `.default({})` posé à plat sur un sous-objet NE
144
+ * ré-applique PAS les défauts de ses champs. Le pattern du dépôt est donc
145
+ * `sous.default(() => sous.parse({}))` — le callback force la ré-évaluation, et
146
+ * une application qui n'écrit rien obtient bien les valeurs d'usine.
147
+ */
148
+ const mcpConfigSchema = z.strictObject({
149
+ /** Répond-on aux requêtes MCP ? Coupé, la route rend `404`. */
150
+ enabled: z.boolean().default(true).describe("Active la porte MCP ; coupée, elle répond 404"),
151
+ /**
152
+ * Origines acceptées quand l'en-tête `Origin` est PRÉSENT.
153
+ *
154
+ * ⭐ La subtilité qui fait toute la sécurité : un client MCP natif est un
155
+ * process, pas un navigateur — **il n'envoie pas d'`Origin`**. Une page web
156
+ * malveillante, elle, en envoie toujours un vers `localhost`. La règle est
157
+ * donc : *absent → on passe* ; *présent et hors de cette liste → `403`*.
158
+ * C'est ce que la spec exige (`transports/streamable-http` §Security :
159
+ * « Servers MUST validate the `Origin` header […] MUST respond with HTTP 403
160
+ * Forbidden »), et c'est ce qui ferme le DNS rebinding.
161
+ *
162
+ * Vide (défaut) = aucune origine de navigateur n'est admise.
163
+ */
164
+ allowedOrigins: z.array(z.string()).default([]).describe("Origines navigateur admises ; vide = aucune"),
165
+ /**
166
+ * Accepter un appel dont l'adresse distante n'est pas locale.
167
+ *
168
+ * La spec recommande de n'écouter que sur la boucle locale ; le serveur de
169
+ * l'application, lui, écoute pour tout le monde. La garde est donc portée
170
+ * par la route : toute adresse non locale reçoit `403`.
171
+ */
172
+ allowRemote: z.boolean().default(false).describe("Autorise les appels depuis une adresse non locale"),
173
+ /** Rôle serveur de ressource OAuth 2.1 — **éteint par défaut**. */
174
+ authorization: mcpAuthorizationSchema.default(() => mcpAuthorizationSchema.parse({})).describe("Serveur de ressource OAuth 2.1 (RFC 9728/6750/8707)")
44
175
  });
176
+ const aiConfigSchema = z.strictObject({
177
+ /** Interrupteur du module — l'app peut le charger sans l'activer. */
178
+ enabled: z.boolean().default(true).describe("Active les fonctionnalités du module ai") });
45
179
  /** Défauts matérialisés (passés au `super()` du Module). */
46
180
  const defaults = aiConfigSchema.parse({});
47
- //#endregion
48
- export { aiConfigSchema, defaults as default };
181
+ export { aiConfigSchema, defaults as default, mcpConfigSchema };
@@ -1,6 +1,6 @@
1
1
  import { aiConfigSchema } from "./config.js";
2
2
  import { z } from "zod";
3
- //#region nodefony/config/configSchema.ts
3
+ // nodefony/config/configSchema.ts
4
4
  /**
5
5
  * JSON Schema de la configuration de @nodefony/ai — lu SANS démarrer
6
6
  * l'application, par `nodefony env` et la notice `.env.example`, qui y
@@ -15,5 +15,4 @@ import { z } from "zod";
15
15
  function configJsonSchema() {
16
16
  return z.toJSONSchema(aiConfigSchema);
17
17
  }
18
- //#endregion
19
18
  export { configJsonSchema };
@@ -1,7 +1,7 @@
1
1
  import { aiConfigSchema } from "./config.js";
2
2
  import { configJsonSchema } from "./configSchema.js";
3
3
  import { parseModuleConfig } from "nodefony";
4
- //#region nodefony/config/defineModuleConfig.ts
4
+ // nodefony/config/defineModuleConfig.ts
5
5
  /**
6
6
  * @nodefony/ai — LE COMMENT : builder PUR de la config.
7
7
  *
@@ -48,5 +48,4 @@ function defineAiConfig(config = {}) {
48
48
  function aiConfigJsonSchema() {
49
49
  return configJsonSchema();
50
50
  }
51
- //#endregion
52
51
  export { aiConfigJsonSchema, defineAiConfig };
@@ -0,0 +1,38 @@
1
+ import { rolesFromScopes } from "nodefony";
2
+ // nodefony/mcp/caller.ts
3
+ const NO_ROLES = [];
4
+ /**
5
+ * Quels rôles porte l'appelant d'une porte MCP — **la règle, à un seul endroit**.
6
+ *
7
+ * ⭐ Toute porte MCP passe par ici, y compris celles qu'un module créera plus
8
+ * tard (gestion d'agents, passerelles métier). C'est délibéré : la question
9
+ * « que vaut un appelant sur ma porte » a exactement une bonne réponse, et
10
+ * laisser chaque porte la réécrire garantit qu'une copie finira plus permissive
11
+ * que les autres sans que rien ne le signale — c'est déjà ce qui s'est produit
12
+ * quand la lecture d'administration fabriquait un administrateur.
13
+ *
14
+ * Les trois postures :
15
+ *
16
+ * | La porte | L'appelant | Ce qu'il porte |
17
+ * | --- | --- | --- |
18
+ * | non protégée | quiconque l'atteint | `operatorRoles` — AUCUN par défaut ; une porte dont le PÉRIMÈTRE protège l'élargit |
19
+ * | protégée | jeton vérifié | ce que ses SCOPES ouvrent, rien de plus |
20
+ * | protégée | anonyme toléré | **rien** — la déclaration d'autorisation serait vidée de son sens |
21
+ *
22
+ * Le premier cas n'accorde RIEN par défaut : une porte non protégée peut être
23
+ * celle d'une application, ouverte en production. Seule une porte que son
24
+ * PÉRIMÈTRE protège — la porte de développement de devkit, module
25
+ * `policy: "dev"`, dont l'appelant lit déjà les sources — passe le rôle
26
+ * d'opérateur, et l'écrit. Ce qui compte, c'est que ce soit un CHOIX écrit par
27
+ * la porte — pas un administrateur fabriqué au fond d'une fonction de lecture.
28
+ *
29
+ * @param posture - ce que la porte sait d'elle-même et de cet appel.
30
+ * @param operatorRoles - ce qu'accorde une porte NON protégée ; aucun rôle par
31
+ * défaut.
32
+ * @returns les rôles à poser sur `IMcpCaller.roles`.
33
+ */
34
+ function mcpCallerRoles(posture, operatorRoles = NO_ROLES) {
35
+ if (!posture.protected) return operatorRoles;
36
+ return posture.authenticated ? rolesFromScopes(posture.scopes) : [];
37
+ }
38
+ export { mcpCallerRoles };