@nodefony/ai 0.0.0-stage → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +123 -2
- package/dist/_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js +8 -0
- package/dist/index.js +37 -0
- package/dist/nodefony/command/StdioCommand.js +75 -0
- package/dist/nodefony/config/config.js +181 -0
- package/dist/nodefony/config/configSchema.js +18 -0
- package/dist/nodefony/config/defineModuleConfig.js +51 -0
- 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 +20 -0
- package/dist/types/index.d.ts +56 -0
- package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +44 -0
- package/dist/types/nodefony/config/configSchema.d.ts +12 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +43 -0
- 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/dist/types/nodefony/src/errors/AiError.d.ts +14 -0
- package/docs/index.md +56 -0
- package/docs/mcp.md +754 -0
- package/package.json +83 -4
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { IAiConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* @nodefony/ai — LE COMMENT : builder PUR de la config.
|
|
4
|
+
*
|
|
5
|
+
* ⭐ TL;DR : ce fichier VALIDE et GÈLE. Il ne porte AUCUNE valeur — les défauts
|
|
6
|
+
* vivent dans le schéma de `./config.ts` (règle d'or ADR-0006). Pour changer un
|
|
7
|
+
* défaut, c'est là-bas ; ici, on ne fait que le faire respecter.
|
|
8
|
+
*
|
|
9
|
+
* Le nom du FICHIER est le même dans tous les modules Nodefony
|
|
10
|
+
* (`defineModuleConfig.ts`) ; la FONCTION, elle, est préfixée par le module —
|
|
11
|
+
* deux modules importés côte à côte ne se marchent pas dessus.
|
|
12
|
+
*
|
|
13
|
+
* L'environnement ne se lit JAMAIS ici. La surcharge générique
|
|
14
|
+
* (`NF__AI__<CHEMIN>`) comme un nom court (`NF_AI_<RÉGLAGE>`) sont
|
|
15
|
+
* posés par le Kernel avant cette validation. Un nom court se DÉCLARE sur son
|
|
16
|
+
* champ, dans `./config.ts` : `.meta({ env: "NF_AI_<RÉGLAGE>" })`.
|
|
17
|
+
*
|
|
18
|
+
* 🔴 **La validation passe par `parseModuleConfig`, du cœur** — un seul endroit
|
|
19
|
+
* pour une seule règle. Il lève une `BootConfigurationError`, et pas une `Error`
|
|
20
|
+
* ordinaire : le kernel distingue les deux. Un hook de cycle de vie qui lève une
|
|
21
|
+
* erreur QUELCONQUE est absorbé en développement (fail-soft — il protège la DX
|
|
22
|
+
* d'un module optionnel cassé), tandis qu'une erreur de CONFIGURATION interrompt
|
|
23
|
+
* le boot dans TOUS les environnements. Mesuré sur un module généré : avec une
|
|
24
|
+
* `Error`, `use("@nodefony/ai", { gretting: "…" })` laissait l'application
|
|
25
|
+
* démarrer, servir, et rendre 0 — la faute de frappe n'atteignait personne. Une
|
|
26
|
+
* configuration explicite qu'on ne peut pas honorer ne se répare pas en
|
|
27
|
+
* continuant.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ Ne PAS réécrire ce bloc à la main : les dix modules du framework portaient
|
|
30
|
+
* chacun leur copie du `try`/`catch`, et elles avaient divergé sur les deux
|
|
31
|
+
* points qui comptent — le type de l'erreur levée et la langue du message.
|
|
32
|
+
*
|
|
33
|
+
* @param config - config brute venue de `use("@nodefony/ai", { … })`.
|
|
34
|
+
* @returns config validée et gelée.
|
|
35
|
+
* @throws BootConfigurationError si un champ est invalide ou inconnu (toutes les
|
|
36
|
+
* erreurs Zod agrégées, par champ) — le boot s'interrompt, en dev comme en prod.
|
|
37
|
+
*/
|
|
38
|
+
export declare function defineAiConfig(config?: unknown): IAiConfig;
|
|
39
|
+
/**
|
|
40
|
+
* JSON Schema de la config — Studio en dérive son formulaire d'édition
|
|
41
|
+
* (libellés, types, défauts, descriptions), sans une ligne d'UI écrite à la main.
|
|
42
|
+
*/
|
|
43
|
+
export declare function aiConfigJsonSchema(): unknown;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ce qu'une porte MCP sait d'elle-même et de son appelant, au moment de dire
|
|
3
|
+
* quels rôles celui-ci porte.
|
|
4
|
+
*
|
|
5
|
+
* Les trois champs sont séparés parce qu'ils répondent à trois questions
|
|
6
|
+
* distinctes que confondre a déjà coûté : « cette porte exige-t-elle une
|
|
7
|
+
* autorisation ? », « une identité a-t-elle été prouvée ? », « qu'ouvre-t-elle ? ».
|
|
8
|
+
*/
|
|
9
|
+
export interface IMcpGatePosture {
|
|
10
|
+
/**
|
|
11
|
+
* La porte déclare-t-elle un serveur d'autorisation ?
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ À ne PAS confondre avec `authenticated`. Une porte protégée peut tolérer
|
|
14
|
+
* l'anonyme ; une porte non protégée n'a personne à authentifier. Ce sont ces
|
|
15
|
+
* deux cas — tous deux « non authentifiés » — qui doivent recevoir des
|
|
16
|
+
* réponses opposées.
|
|
17
|
+
*/
|
|
18
|
+
protected: boolean;
|
|
19
|
+
/** Un jeton a-t-il été vérifié pour cet appel ? */
|
|
20
|
+
authenticated: boolean;
|
|
21
|
+
/** Scopes que le serveur d'autorisation a réellement accordés. */
|
|
22
|
+
scopes: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Quels rôles porte l'appelant d'une porte MCP — **la règle, à un seul endroit**.
|
|
26
|
+
*
|
|
27
|
+
* ⭐ Toute porte MCP passe par ici, y compris celles qu'un module créera plus
|
|
28
|
+
* tard (gestion d'agents, passerelles métier). C'est délibéré : la question
|
|
29
|
+
* « que vaut un appelant sur ma porte » a exactement une bonne réponse, et
|
|
30
|
+
* laisser chaque porte la réécrire garantit qu'une copie finira plus permissive
|
|
31
|
+
* que les autres sans que rien ne le signale — c'est déjà ce qui s'est produit
|
|
32
|
+
* quand la lecture d'administration fabriquait un administrateur.
|
|
33
|
+
*
|
|
34
|
+
* Les trois postures :
|
|
35
|
+
*
|
|
36
|
+
* | La porte | L'appelant | Ce qu'il porte |
|
|
37
|
+
* | --- | --- | --- |
|
|
38
|
+
* | non protégée | quiconque l'atteint | `operatorRoles` — AUCUN par défaut ; une porte dont le PÉRIMÈTRE protège l'élargit |
|
|
39
|
+
* | protégée | jeton vérifié | ce que ses SCOPES ouvrent, rien de plus |
|
|
40
|
+
* | protégée | anonyme toléré | **rien** — la déclaration d'autorisation serait vidée de son sens |
|
|
41
|
+
*
|
|
42
|
+
* Le premier cas n'accorde RIEN par défaut : une porte non protégée peut être
|
|
43
|
+
* celle d'une application, ouverte en production. Seule une porte que son
|
|
44
|
+
* PÉRIMÈTRE protège — la porte de développement de devkit, module
|
|
45
|
+
* `policy: "dev"`, dont l'appelant lit déjà les sources — passe le rôle
|
|
46
|
+
* d'opérateur, et l'écrit. Ce qui compte, c'est que ce soit un CHOIX écrit par
|
|
47
|
+
* la porte — pas un administrateur fabriqué au fond d'une fonction de lecture.
|
|
48
|
+
*
|
|
49
|
+
* @param posture - ce que la porte sait d'elle-même et de cet appel.
|
|
50
|
+
* @param operatorRoles - ce qu'accorde une porte NON protégée ; aucun rôle par
|
|
51
|
+
* défaut.
|
|
52
|
+
* @returns les rôles à poser sur `IMcpCaller.roles`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function mcpCallerRoles(posture: IMcpGatePosture, operatorRoles?: readonly string[]): readonly string[];
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { IAccessAccountResolver, IAccessTokenVerifier, IProtectedResourceInput, IMcpTool } from "nodefony";
|
|
3
|
+
import type { Controller } from "@nodefony/framework";
|
|
4
|
+
import type { IServerInfo } from "./server.js";
|
|
5
|
+
import { type IMcpEventSink } from "./stream.js";
|
|
6
|
+
/**
|
|
7
|
+
* Une porte MCP sur HTTP — la mécanique, sans la route.
|
|
8
|
+
*
|
|
9
|
+
* `@nodefony/ai` ne monte AUCUNE route : une porte se DÉCLARE, par qui en a
|
|
10
|
+
* besoin — devkit pour l'outillage de développement, une application pour ses
|
|
11
|
+
* propres outils, en développement ou en production selon son choix. On la
|
|
12
|
+
* définit une fois ({@link defineMcpGate}), on la sert d'une ligne
|
|
13
|
+
* (`gate.serve(this, body)`). Gardes d'origine et de localité, validation du
|
|
14
|
+
* jeton, défi `401` avec `resource_metadata`, traduction HTTP ↔ JSON-RPC, flux
|
|
15
|
+
* SSE, refus `405` des verbes retirés : une porte qui recoderait l'une de ces
|
|
16
|
+
* règles finirait plus permissive que les autres sans que rien ne le dise.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Plafond d'un événement sur le flux de réponse MCP, octets UTF-8 — celui de la
|
|
20
|
+
* réponse finale, qui porte le résultat entier de l'outil (16 Mio).
|
|
21
|
+
*/
|
|
22
|
+
export declare const MCP_STREAM_MAX_EVENT_BYTES: number;
|
|
23
|
+
/** Sévérités qu'une porte MCP écrit au journal. */
|
|
24
|
+
export type McpGateSeverity = "DEBUG" | "WARNING" | "ERROR" | "CRITIC";
|
|
25
|
+
/**
|
|
26
|
+
* Le schéma d'une porte — les réglages de transport et d'autorisation de
|
|
27
|
+
* {@link mcpConfigSchema} (les MÊMES clés que `devkit.mcp.*`), plus ce que la
|
|
28
|
+
* porte publie et comment. Strict : une clé inconnue arrête le démarrage en la
|
|
29
|
+
* nommant, au lieu d'être ignorée.
|
|
30
|
+
*/
|
|
31
|
+
export declare const mcpGateSchema: z.ZodObject<{
|
|
32
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
33
|
+
allowedOrigins: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
34
|
+
allowRemote: z.ZodDefault<z.ZodBoolean>;
|
|
35
|
+
authorization: z.ZodDefault<z.ZodObject<{
|
|
36
|
+
authorizationServers: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
37
|
+
resource: z.ZodDefault<z.ZodString>;
|
|
38
|
+
additionalResources: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
39
|
+
resourceName: z.ZodOptional<z.ZodString>;
|
|
40
|
+
resourceDocumentation: z.ZodOptional<z.ZodString>;
|
|
41
|
+
anonymous: z.ZodDefault<z.ZodBoolean>;
|
|
42
|
+
}, z.core.$strict>>;
|
|
43
|
+
name: z.ZodOptional<z.ZodString>;
|
|
44
|
+
version: z.ZodOptional<z.ZodString>;
|
|
45
|
+
instructions: z.ZodOptional<z.ZodString>;
|
|
46
|
+
tools: z.ZodUnion<readonly [z.ZodLiteral<"modules">, z.ZodArray<z.ZodCustom<IMcpTool, IMcpTool>>]>;
|
|
47
|
+
unprotectedRoles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
48
|
+
protocol: z.ZodDefault<z.ZodObject<{
|
|
49
|
+
versions: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
50
|
+
"2024-11-05": "2024-11-05";
|
|
51
|
+
"2025-03-26": "2025-03-26";
|
|
52
|
+
"2025-06-18": "2025-06-18";
|
|
53
|
+
"2025-11-25": "2025-11-25";
|
|
54
|
+
"2026-07-28": "2026-07-28";
|
|
55
|
+
}>>>;
|
|
56
|
+
}, z.core.$strict>>;
|
|
57
|
+
cache: z.ZodDefault<z.ZodObject<{
|
|
58
|
+
ttlMs: z.ZodDefault<z.ZodNumber>;
|
|
59
|
+
scope: z.ZodDefault<z.ZodEnum<{
|
|
60
|
+
private: "private";
|
|
61
|
+
public: "public";
|
|
62
|
+
}>>;
|
|
63
|
+
}, z.core.$strict>>;
|
|
64
|
+
}, z.core.$strict>;
|
|
65
|
+
/** Ce qu'on écrit pour définir une porte (défauts compris). */
|
|
66
|
+
export type IMcpGateDefinition = z.input<typeof mcpGateSchema>;
|
|
67
|
+
/** Les réglages EFFECTIFS d'une porte, validés et figés. */
|
|
68
|
+
export type IMcpGateSettings = z.output<typeof mcpGateSchema>;
|
|
69
|
+
/** Ce que la porte a reçu — lu par elle, jamais par la mécanique. */
|
|
70
|
+
export interface IMcpGateRequest<S extends IMcpEventSink = IMcpEventSink> {
|
|
71
|
+
/** Corps tel que le parseur HTTP l'a rendu (`{}` pour un JSON illisible). */
|
|
72
|
+
body: unknown;
|
|
73
|
+
/**
|
|
74
|
+
* Le corps BRUT, lu à la demande : seul moyen de distinguer un `{}` envoyé
|
|
75
|
+
* d'un texte qui ne se parse pas (JSON-RPC `-32700` contre `-32600`). Appelé
|
|
76
|
+
* uniquement quand le corps n'a aucune clé.
|
|
77
|
+
*/
|
|
78
|
+
rawBody?: (() => string | undefined) | undefined;
|
|
79
|
+
/** En-tête `Origin`. */
|
|
80
|
+
origin?: string | undefined;
|
|
81
|
+
/** En-tête `MCP-Protocol-Version`. */
|
|
82
|
+
protocolVersion?: string | undefined;
|
|
83
|
+
/** En-tête `Authorization`. */
|
|
84
|
+
authorization?: string | undefined;
|
|
85
|
+
/** Adresse de l'appelant (après `trustProxy`). */
|
|
86
|
+
remoteAddress?: string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* La requête a traversé un relais (en-tête `Forwarded`, `X-Forwarded-For`,
|
|
89
|
+
* `X-Real-IP` ou `Via`) — refusée par une porte locale.
|
|
90
|
+
*/
|
|
91
|
+
relayed?: boolean | undefined;
|
|
92
|
+
/** Abattu quand le client abandonne la requête. */
|
|
93
|
+
signal?: AbortSignal | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Ouvre un flux SSE — absent quand le client n'en accepte pas : la
|
|
96
|
+
* progression devient alors muette, jamais refusée.
|
|
97
|
+
*/
|
|
98
|
+
openStream?: (() => S | Promise<S>) | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Prévenu de l'identifiant du compte au nom duquel un outil agit — pour le
|
|
101
|
+
* journal de la requête, qui le lit au démontage, hors de la bulle.
|
|
102
|
+
*/
|
|
103
|
+
onAccount?: ((identifier: string) => void) | undefined;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Ce qu'un transport de process (stdio) a reçu : une ligne, sans en-tête ni
|
|
107
|
+
* adresse.
|
|
108
|
+
*/
|
|
109
|
+
export interface IMcpStdioRequest {
|
|
110
|
+
/** La ligne reçue, telle quelle — un message JSON-RPC, en principe. */
|
|
111
|
+
line: string;
|
|
112
|
+
/** Abattu quand le client s'en va (entrée standard fermée). */
|
|
113
|
+
signal?: AbortSignal | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* Ouvre le puits où écrire les notifications de l'appel, puis sa réponse
|
|
116
|
+
* finale — absent, la progression devient muette.
|
|
117
|
+
*/
|
|
118
|
+
openStream?: (() => IMcpEventSink | Promise<IMcpEventSink>) | undefined;
|
|
119
|
+
}
|
|
120
|
+
/** Ce que l'environnement fournit à une réponse — `serve()` le lit seul. */
|
|
121
|
+
export interface IMcpGateRuntime {
|
|
122
|
+
/** Les modules chargés, pour une porte `tools: "modules"`. */
|
|
123
|
+
modules?: Readonly<Record<string, unknown>> | undefined;
|
|
124
|
+
/** Qui sait valider un jeton — requis dès qu'un serveur d'autorisation est déclaré. */
|
|
125
|
+
verifier?: IAccessTokenVerifier | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* Qui retrouve le compte qu'un jeton désigne — requis par un outil
|
|
128
|
+
* `actsForUser` ; absent, un tel outil refuse de s'exécuter.
|
|
129
|
+
*/
|
|
130
|
+
accountResolver?: IAccessAccountResolver | undefined;
|
|
131
|
+
/** Identité annoncée ; défaut : `name`/`version` de la porte, sinon le projet. */
|
|
132
|
+
serverInfo?: IServerInfo | undefined;
|
|
133
|
+
/** Journal de la porte ; muet si absent. */
|
|
134
|
+
log?: ((message: string, severity: McpGateSeverity) => void) | undefined;
|
|
135
|
+
}
|
|
136
|
+
/** Réponse DÉCRITE par la mécanique — la porte l'écrit. */
|
|
137
|
+
export type IMcpGateResponse<S extends IMcpEventSink = IMcpEventSink> = {
|
|
138
|
+
kind: "json";
|
|
139
|
+
status: number;
|
|
140
|
+
body: unknown;
|
|
141
|
+
headers?: Readonly<Record<string, string>>;
|
|
142
|
+
} | {
|
|
143
|
+
kind: "empty";
|
|
144
|
+
status: number;
|
|
145
|
+
headers?: Readonly<Record<string, string>>;
|
|
146
|
+
} | {
|
|
147
|
+
kind: "stream";
|
|
148
|
+
stream: S;
|
|
149
|
+
};
|
|
150
|
+
/** Une porte MCP définie — à servir depuis une action de controller. */
|
|
151
|
+
export interface IMcpGate {
|
|
152
|
+
/** Les réglages effectifs, validés et figés. */
|
|
153
|
+
readonly settings: IMcpGateSettings;
|
|
154
|
+
/**
|
|
155
|
+
* Sert la requête courante d'un controller : `POST` sert le message, `GET` et
|
|
156
|
+
* `DELETE` rendent `405` (verbes retirés par la révision `2026-07-28`).
|
|
157
|
+
*
|
|
158
|
+
* @param controller - le controller de la porte.
|
|
159
|
+
* @param body - le corps reçu (`@Body()`).
|
|
160
|
+
* @returns ce que l'action doit rendre.
|
|
161
|
+
*/
|
|
162
|
+
serve(controller: Controller, body: unknown): Promise<unknown>;
|
|
163
|
+
/**
|
|
164
|
+
* Répond à UN `POST` décrit — sans controller, éprouvable sans serveur (et
|
|
165
|
+
* réutilisable par un autre transport).
|
|
166
|
+
*
|
|
167
|
+
* @param request - ce que la porte a reçu.
|
|
168
|
+
* @param runtime - modules, vérificateur, identité, journal.
|
|
169
|
+
* @returns la réponse à écrire ; `stream` quand un flux SSE a été ouvert.
|
|
170
|
+
*/
|
|
171
|
+
respond<S extends IMcpEventSink>(request: IMcpGateRequest<S>, runtime?: IMcpGateRuntime): Promise<IMcpGateResponse<S>>;
|
|
172
|
+
/**
|
|
173
|
+
* Répond à UNE ligne reçue sur l'entrée standard — le pendant de
|
|
174
|
+
* {@link IMcpGate.respond} pour un client qui lance lui-même son serveur.
|
|
175
|
+
*
|
|
176
|
+
* Ni origine, ni adresse, ni jeton : la frontière est celle du process.
|
|
177
|
+
* L'appelant est anonyme et porte les rôles d'une porte non protégée
|
|
178
|
+
* (`unprotectedRoles`, aucun par défaut) ; les outils réservés restent
|
|
179
|
+
* retenus. Les annulations (`notifications/cancelled`) visent l'unique
|
|
180
|
+
* appelant du process ; un appel annulé ne produit PLUS RIEN — ni
|
|
181
|
+
* progression, ni réponse (`empty`), comme la spec l'exige en stdio. Le
|
|
182
|
+
* statut de la réponse décrite n'a pas de sens hors HTTP : seul son corps
|
|
183
|
+
* s'écrit.
|
|
184
|
+
*
|
|
185
|
+
* @param request - la ligne reçue, et le puits où écrire.
|
|
186
|
+
* @param runtime - modules, identité, journal.
|
|
187
|
+
* @returns la réponse à écrire ; `stream` quand le puits a déjà tout reçu.
|
|
188
|
+
*/
|
|
189
|
+
respondStdio(request: IMcpStdioRequest, runtime?: IMcpGateRuntime): Promise<IMcpGateResponse>;
|
|
190
|
+
/**
|
|
191
|
+
* Ce que la porte protège, à publier en RFC 9728 — une entrée, ou rien.
|
|
192
|
+
*
|
|
193
|
+
* Un service qui expose `publishedProtectedResources()` voit
|
|
194
|
+
* `@nodefony/framework` monter le document ; les scopes publiés sont ceux
|
|
195
|
+
* que le défi exige. Rôle éteint (aucun serveur d'autorisation) ⇒ rien : un
|
|
196
|
+
* document sans `authorization_servers` apprendrait au client qu'un jeton est
|
|
197
|
+
* nécessaire sans lui dire où l'obtenir.
|
|
198
|
+
*
|
|
199
|
+
* @param modules - les modules chargés, pour une porte `tools: "modules"`.
|
|
200
|
+
* @returns zéro ou une entrée.
|
|
201
|
+
*/
|
|
202
|
+
protectedResources(modules?: Readonly<Record<string, unknown>>): readonly IProtectedResourceInput[];
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Définit une porte MCP — UNE fois, à côté de son controller.
|
|
206
|
+
*
|
|
207
|
+
* La définition est validée et figée ici : une clé inconnue, une révision du
|
|
208
|
+
* protocole que le paquet installé ne sait pas servir, un outil mal formé, un
|
|
209
|
+
* nom en double ou réservé (`nodefony_`) lèvent une `BootConfigurationError`
|
|
210
|
+
* qui nomme la faute — au démarrage, jamais à la première requête. Chaque
|
|
211
|
+
* requête ne paie ensuite aucune validation.
|
|
212
|
+
*
|
|
213
|
+
* ```ts
|
|
214
|
+
* const shopGate = defineMcpGate({
|
|
215
|
+
* name: "shop",
|
|
216
|
+
* instructions: "Stock et commandes du magasin.",
|
|
217
|
+
* tools: [stockTool, orderTool],
|
|
218
|
+
* });
|
|
219
|
+
*
|
|
220
|
+
* @route("shop-mcp", { path: "/mcp", requirements: { methods: ["POST", "GET", "DELETE"] } })
|
|
221
|
+
* mcp(@Body() body: unknown) {
|
|
222
|
+
* return shopGate.serve(this, body);
|
|
223
|
+
* }
|
|
224
|
+
* ```
|
|
225
|
+
*
|
|
226
|
+
* Défauts : appels locaux seulement (`allowRemote: false`), aucune origine
|
|
227
|
+
* navigateur, aucun jeton exigé, AUCUN rôle accordé — les outils réservés
|
|
228
|
+
* (`scopes`, `requiresAuth`) restent retenus tant qu'une `authorization` ne
|
|
229
|
+
* les ouvre pas.
|
|
230
|
+
*
|
|
231
|
+
* @param definition - ce que la porte publie, à qui, et comment.
|
|
232
|
+
* @returns la porte, prête à servir.
|
|
233
|
+
* @throws BootConfigurationError si la définition est invalide.
|
|
234
|
+
*/
|
|
235
|
+
export declare function defineMcpGate(definition: IMcpGateDefinition): IMcpGate;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gardes du transport MCP — ce qui protège une porte locale SANS authentification.
|
|
3
|
+
*
|
|
4
|
+
* Ces deux contrôles ne sont pas un pis-aller : ce sont exactement les
|
|
5
|
+
* exigences que la spec pose au transport lui-même, indépendamment de toute
|
|
6
|
+
* autorisation (`transports/streamable-http` §Security & Endpoint) —
|
|
7
|
+
* « Servers **MUST** validate the `Origin` header on all incoming connections
|
|
8
|
+
* to prevent DNS rebinding attacks » et « When running locally, servers
|
|
9
|
+
* **SHOULD** bind only to localhost ».
|
|
10
|
+
*
|
|
11
|
+
* Fonctions **pures** : elles reçoivent ce qu'elles jugent, elles ne le lisent
|
|
12
|
+
* nulle part. C'est ce qui les rend éprouvables sans serveur ni requête réelle
|
|
13
|
+
* — et ce qui permet de vérifier le refus, qui est le comportement qui compte.
|
|
14
|
+
*/
|
|
15
|
+
/** Verdict d'une garde : passer, ou refuser en disant pourquoi. */
|
|
16
|
+
export type GuardVerdict = {
|
|
17
|
+
allowed: true;
|
|
18
|
+
} | {
|
|
19
|
+
allowed: false;
|
|
20
|
+
why: string;
|
|
21
|
+
};
|
|
22
|
+
/** Ce que la garde a besoin de savoir d'une requête. */
|
|
23
|
+
export interface IGuardInput {
|
|
24
|
+
/** En-tête `Origin`, `undefined` s'il est absent. */
|
|
25
|
+
origin?: string | undefined;
|
|
26
|
+
/** Adresse distante de la connexion (`socket.remoteAddress`). */
|
|
27
|
+
remoteAddress?: string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* La requête porte un en-tête de relais (`Forwarded`, `X-Forwarded-For`,
|
|
30
|
+
* `X-Real-IP`, `Via`) : elle a traversé un proxy, quelle que soit
|
|
31
|
+
* l'adresse de la connexion.
|
|
32
|
+
*/
|
|
33
|
+
relayed?: boolean | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Réglages qui gouvernent les gardes (viennent de la config du module). */
|
|
36
|
+
export interface IGuardPolicy {
|
|
37
|
+
/** Origines de navigateur admises ; vide = aucune. */
|
|
38
|
+
allowedOrigins: readonly string[];
|
|
39
|
+
/** Accepter une adresse distante non locale. */
|
|
40
|
+
allowRemote: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Une adresse est-elle celle de la machine locale ?
|
|
44
|
+
*
|
|
45
|
+
* Couvre les trois formes que Node rend selon la pile réseau : IPv4
|
|
46
|
+
* (`127.x.x.x`), IPv6 (`::1`), et l'IPv4 encapsulée en IPv6
|
|
47
|
+
* (`::ffff:127.0.0.1`) — la plus fréquente sur un serveur à double pile, et
|
|
48
|
+
* celle qu'une comparaison naïve à `"127.0.0.1"` rate.
|
|
49
|
+
*
|
|
50
|
+
* @param address - `socket.remoteAddress`, éventuellement absent
|
|
51
|
+
* @returns `true` si l'appel vient de cette machine
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* Les en-têtes qu'un relais pose — leur présence suffit à dire que la requête
|
|
55
|
+
* n'est pas arrivée directement, quelle que soit l'adresse de la connexion.
|
|
56
|
+
*/
|
|
57
|
+
export declare const RELAY_HEADERS: readonly ["forwarded", "x-forwarded-for", "x-real-ip", "via"];
|
|
58
|
+
/**
|
|
59
|
+
* La requête a-t-elle traversé un relais (proxy inverse, répartiteur) ?
|
|
60
|
+
*
|
|
61
|
+
* Un relais posé sur la MÊME machine fait arriver un client distant depuis la
|
|
62
|
+
* boucle locale : l'adresse de la connexion ne prouve alors plus rien. Toute
|
|
63
|
+
* porte qui exige la localité refuse donc aussi l'appel relayé — la porte MCP
|
|
64
|
+
* comme la porte de session par ticket, une seule règle.
|
|
65
|
+
*
|
|
66
|
+
* @param headers - en-têtes reçus (`request.headers` de Node)
|
|
67
|
+
* @returns `true` si un en-tête de relais non vide est présent
|
|
68
|
+
*/
|
|
69
|
+
export declare function isRelayedRequest(headers: Readonly<Record<string, string | string[] | undefined>>): boolean;
|
|
70
|
+
export declare function isLocalAddress(address?: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Décide si un appel MCP peut être servi.
|
|
73
|
+
*
|
|
74
|
+
* ⭐ **La règle sur `Origin` est contre-intuitive, et c'est elle qui protège.**
|
|
75
|
+
* Un client MCP légitime est un *process* (Cursor, Claude Code, un agent tiers)
|
|
76
|
+
* : il n'envoie **aucun** `Origin`, cet en-tête étant posé par les navigateurs.
|
|
77
|
+
* Une page web malveillante, elle, en pose **toujours** un lorsqu'elle vise
|
|
78
|
+
* `https://localhost:5152`. Donc : *absent* → on passe ; *présent et hors
|
|
79
|
+
* allowlist* → `403`. C'est ce qui referme le DNS rebinding, le seul vecteur
|
|
80
|
+
* réel contre un serveur MCP local.
|
|
81
|
+
*
|
|
82
|
+
* L'ordre des contrôles est délibéré : la localité d'abord, parce qu'un appel
|
|
83
|
+
* distant ne doit même pas apprendre quelles origines sont admises.
|
|
84
|
+
*
|
|
85
|
+
* @param input - ce que la requête présente
|
|
86
|
+
* @param policy - les réglages du module
|
|
87
|
+
* @returns le verdict, avec le motif quand il refuse
|
|
88
|
+
*/
|
|
89
|
+
export declare function checkMcpAccess(input: IGuardInput, policy: IGuardPolicy): GuardVerdict;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type IJsonRpcNotification } from "nodefony";
|
|
2
|
+
/**
|
|
3
|
+
* La progression d'un appel d'outil (`notifications/progress`) — règle PURE,
|
|
4
|
+
* horloge injectée.
|
|
5
|
+
*
|
|
6
|
+
* La norme (`basic/patterns/progress`) pose quatre exigences, toutes tenues
|
|
7
|
+
* ICI plutôt que dans chaque outil :
|
|
8
|
+
* - une notification ne cite qu'un jeton fourni par une requête EN COURS — le
|
|
9
|
+
* rapporteur se tait dès que l'outil a rendu sa réponse ({@link IMcpProgressReporter.end}) ;
|
|
10
|
+
* - `progress` CROÎT à chaque notification — une valeur qui stagne ou recule
|
|
11
|
+
* est ignorée, pas transmise ;
|
|
12
|
+
* - la cadence est bornée (« SHOULD implement rate limiting ») ;
|
|
13
|
+
* - sans jeton, rien n'est émis : l'agent n'a rien demandé.
|
|
14
|
+
*/
|
|
15
|
+
/** Intervalle minimal entre deux notifications de progression, en millisecondes. */
|
|
16
|
+
export declare const MCP_PROGRESS_MIN_INTERVAL_MS = 100;
|
|
17
|
+
/** Longueur maximale du `message` d'une progression, en caractères. */
|
|
18
|
+
export declare const MCP_PROGRESS_MESSAGE_MAX = 1000;
|
|
19
|
+
/** Un jeton de progression : chaîne ou entier (`progress` §Progress Flow). */
|
|
20
|
+
export type McpProgressToken = string | number;
|
|
21
|
+
/** Le rapporteur d'UN appel : ce que l'outil appelle, ce que la porte clôt. */
|
|
22
|
+
export interface IMcpProgressReporter {
|
|
23
|
+
/**
|
|
24
|
+
* Signale un avancement ; ignoré s'il ne croît pas, s'il arrive trop tôt
|
|
25
|
+
* après le précédent, ou après {@link end}.
|
|
26
|
+
*/
|
|
27
|
+
progress(progress: number, total?: number, message?: string): void;
|
|
28
|
+
/** L'outil a rendu sa réponse : plus aucune notification ne part. */
|
|
29
|
+
end(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Lit le jeton de progression d'une requête (`params._meta.progressToken`).
|
|
33
|
+
*
|
|
34
|
+
* @param params - les `params` de la requête.
|
|
35
|
+
* @returns le jeton, ou `null` s'il manque ou n'est ni une chaîne ni un entier.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readProgressToken(params: Record<string, unknown>): McpProgressToken | null;
|
|
38
|
+
/**
|
|
39
|
+
* Fabrique le rapporteur de progression d'un appel.
|
|
40
|
+
*
|
|
41
|
+
* @param token - le jeton de la requête ({@link readProgressToken}), ou `null`.
|
|
42
|
+
* @param notify - émet une notification sur le flux de la réponse ; absent
|
|
43
|
+
* quand la porte ne sait pas tenir de flux.
|
|
44
|
+
* @param now - horloge (millisecondes), injectée pour l'épreuve.
|
|
45
|
+
* @param minIntervalMs - intervalle minimal entre deux notifications.
|
|
46
|
+
* @returns un rapporteur, muet quand il n'y a ni jeton ni flux.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createProgressReporter(token: McpProgressToken | null, notify: ((notification: IJsonRpcNotification) => void) | undefined, now?: () => number, minIntervalMs?: number): IMcpProgressReporter;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Context Protocol — types et constantes du transport « Streamable HTTP ».
|
|
3
|
+
*
|
|
4
|
+
* Révision visée : **2026-07-28**, celle qui a supprimé les sessions de niveau
|
|
5
|
+
* protocole et le flux `GET`. C'est ce qui rend cette porte possible sans
|
|
6
|
+
* process dédié : chaque message est un `POST` autonome, donc un redémarrage du
|
|
7
|
+
* serveur de développement ne casse rien — le client rejoue simplement sa
|
|
8
|
+
* requête, et la réponse vient du code qui vient d'être rechargé.
|
|
9
|
+
*
|
|
10
|
+
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
|
|
11
|
+
*/
|
|
12
|
+
import type { IJsonRpcSuccess, IJsonRpcFailure } from "nodefony";
|
|
13
|
+
/** Révision PRÉFÉRÉE de ce serveur — celle qu'il met en tête de ce qu'il sait faire. */
|
|
14
|
+
export declare const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
15
|
+
/**
|
|
16
|
+
* Révision supposée quand un client n'en déclare AUCUNE.
|
|
17
|
+
*
|
|
18
|
+
* Ce n'est pas un choix : la spec impose de traiter une requête sans
|
|
19
|
+
* déclaration comme `2025-03-26`, et le SDK de référence en fait sa
|
|
20
|
+
* `DEFAULT_NEGOTIATED_PROTOCOL_VERSION`. Répondre notre préférée à un client
|
|
21
|
+
* muet reviendrait à lui imposer une révision qu'il n'a pas demandée.
|
|
22
|
+
*/
|
|
23
|
+
export declare const MCP_DEFAULT_NEGOTIATED_VERSION = "2025-03-26";
|
|
24
|
+
/**
|
|
25
|
+
* Versions que ce serveur sait servir — publiées par `server/discover`, listées
|
|
26
|
+
* dans l'erreur `UnsupportedProtocolVersion`, et candidates à l'écho
|
|
27
|
+
* d'`initialize`. De la plus récente à la plus ancienne.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 **N'en servir qu'une était un défaut, et il a coûté la connexion.** Le
|
|
30
|
+
* serveur annonçait `2026-07-28` à TOUT client, y compris à ceux qui
|
|
31
|
+
* demandaient autre chose — or aucun client déployé ne connaît cette révision :
|
|
32
|
+
* le SDK de référence (`@modelcontextprotocol/sdk@1.30.0`) porte
|
|
33
|
+
* `LATEST = 2025-11-25` et refuse net toute réponse hors de sa liste
|
|
34
|
+
* (« Server's protocol version is not supported »). Une porte parfaitement
|
|
35
|
+
* conforme à la dernière spec, et injoignable par tout le monde.
|
|
36
|
+
*
|
|
37
|
+
* Ces cinq révisions sont celles que la spec a publiées. Les servir toutes est
|
|
38
|
+
* honnête ici, et vérifiable : les quatre méthodes de ce serveur — `initialize`,
|
|
39
|
+
* `ping`, `tools/list`, `tools/call` — ont la même forme de REQUÊTE dans
|
|
40
|
+
* chacune. La forme du RÉSULTAT, elle, dépend de l'ÈRE du client : depuis
|
|
41
|
+
* `2026-07-28`, tout résultat porte `resultType` (MUST du schéma) et `_meta`
|
|
42
|
+
* serverInfo — servis par `eraResult` (server.ts) à un client moderne, jamais
|
|
43
|
+
* à un client legacy qui ne définit pas ces champs. Un test EXERCE chacune,
|
|
44
|
+
* plutôt que de se fier à cette phrase.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MCP_SUPPORTED_VERSIONS: readonly ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
47
|
+
/** Une révision du protocole que ce paquet sait servir. */
|
|
48
|
+
export type McpProtocolVersion = (typeof MCP_SUPPORTED_VERSIONS)[number];
|
|
49
|
+
/**
|
|
50
|
+
* Clé de métadonnée par laquelle un client MODERNE déclare sa révision.
|
|
51
|
+
*
|
|
52
|
+
* ⭐ **C'est la différence d'ÈRE, et elle commande tout le reste.** Jusqu'à
|
|
53
|
+
* `2025-11-25` (ère « legacy »), un client ouvrait une session par un handshake
|
|
54
|
+
* `initialize`. Depuis `2026-07-28` (ère « modern »), il n'y a plus de session :
|
|
55
|
+
* chaque requête porte elle-même sa version et les capacités du client, dans
|
|
56
|
+
* `params._meta`. Un serveur qui n'écouterait que `initialize` serait un
|
|
57
|
+
* serveur *legacy* — quelle que soit la version qu'il prétend annoncer.
|
|
58
|
+
*/
|
|
59
|
+
export declare const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
60
|
+
/** Clé de métadonnée portant l'identité du serveur dans `server/discover`. */
|
|
61
|
+
export declare const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
62
|
+
export { JsonRpcError, jsonRpcSuccess, jsonRpcFailure, isNotification, } from "nodefony";
|
|
63
|
+
export type { JsonRpcId, IJsonRpcMessage, IJsonRpcSuccess, IJsonRpcFailure, } from "nodefony";
|
|
64
|
+
/**
|
|
65
|
+
* Codes réservés par la spec MCP, hors plage JSON-RPC standard.
|
|
66
|
+
*
|
|
67
|
+
* Ils ne sont pas décoratifs : un client s'en sert pour se rattraper seul —
|
|
68
|
+
* renégocier une version sur `-32022`, relire `tools/list` puis réessayer sur
|
|
69
|
+
* `-32020`. Rendre un `-32600` générique à leur place le priverait de cette
|
|
70
|
+
* reprise et transformerait un désaccord réparable en échec définitif.
|
|
71
|
+
*/
|
|
72
|
+
export declare const McpProtocolError: {
|
|
73
|
+
/**
|
|
74
|
+
* Les en-têtes HTTP contredisent le corps, ou un en-tête requis manque.
|
|
75
|
+
* La spec impose `400` **et** ce code (`streamable-http` §Server Validation).
|
|
76
|
+
*/
|
|
77
|
+
readonly HEADER_MISMATCH: -32020;
|
|
78
|
+
/**
|
|
79
|
+
* La révision demandée n'est pas servie. La réponse **doit** lister celles
|
|
80
|
+
* qu'on sert, sans quoi le client n'a rien pour choisir.
|
|
81
|
+
*/
|
|
82
|
+
readonly UNSUPPORTED_PROTOCOL_VERSION: -32022;
|
|
83
|
+
};
|
|
84
|
+
/** Ce que le serveur MCP rend, avant traduction en réponse HTTP. */
|
|
85
|
+
export interface IMcpHttpReply {
|
|
86
|
+
/** Statut HTTP à poser. */
|
|
87
|
+
status: number;
|
|
88
|
+
/**
|
|
89
|
+
* Corps JSON, ou `null` pour un `202 Accepted` sans corps — la spec l'exige
|
|
90
|
+
* pour une notification acceptée.
|
|
91
|
+
*/
|
|
92
|
+
body: IJsonRpcSuccess | IJsonRpcFailure | null;
|
|
93
|
+
/**
|
|
94
|
+
* L'exception levée par un outil, rendue À CÔTÉ de la réponse — jamais
|
|
95
|
+
* sérialisée.
|
|
96
|
+
*
|
|
97
|
+
* Le client ne reçoit qu'un message générique : le message d'une exception
|
|
98
|
+
* peut porter un chemin de disque, une chaîne de connexion ou un secret, et
|
|
99
|
+
* l'outil qui la lève est du code d'application arbitraire. Le détail reste
|
|
100
|
+
* donc au serveur, et c'est au TRANSPORT de le journaliser : le protocole est
|
|
101
|
+
* une fonction pure, qui n'a pas de journal.
|
|
102
|
+
*/
|
|
103
|
+
failure?: {
|
|
104
|
+
tool: string;
|
|
105
|
+
error: unknown;
|
|
106
|
+
} | undefined;
|
|
107
|
+
}
|