@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/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
|
|
34
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|