@nodefony/devkit 10.0.0-alpha.1
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 +544 -0
- package/README.md +318 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +47 -0
- package/dist/nodefony/command/CardCommand.js +70 -0
- package/dist/nodefony/config/config.js +200 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controllers/DevkitController.js +60 -0
- package/dist/nodefony/controllers/McpController.js +223 -0
- package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
- package/dist/nodefony/interfaces/IDevkitService.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/DevkitService.js +198 -0
- package/dist/nodefony/src/card.js +2 -0
- package/dist/nodefony/src/errors/DevkitError.js +21 -0
- package/dist/nodefony/src/mcp/guard.js +51 -0
- package/dist/nodefony/src/mcp/protocol.js +127 -0
- package/dist/nodefony/src/mcp/server.js +133 -0
- package/dist/nodefony/src/mcp/tools.js +163 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
- package/dist/types/nodefony/config/config.d.ts +25 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
- package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
- package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
- package/dist/types/nodefony/src/card.d.ts +18 -0
- package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
- package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
- package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
- package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
- package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
- package/docs/index.md +358 -0
- package/package.json +77 -0
- package/skills/nodefony-add-crud/SKILL.md +199 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
- package/skills/nodefony-add-service/SKILL.md +90 -0
- package/skills/nodefony-browser/SKILL.md +416 -0
- package/skills/nodefony-browser/references/socket.md +115 -0
- package/skills/nodefony-browser/references/sondes.md +175 -0
- package/skills/nodefony-browser/scripts/audit.mjs +169 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
- package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
- package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
- package/skills/nodefony-browser/scripts/socket.mjs +354 -0
- package/skills/nodefony-browser/scripts/watch.mjs +125 -0
- package/skills/nodefony-migrate-schema/SKILL.md +359 -0
- package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
- package/skills/nodefony-protect-route/SKILL.md +195 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import defaults from "../config/config.js";
|
|
2
|
+
import { buildCard } from "../src/card.js";
|
|
3
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
|
|
4
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
|
|
5
|
+
import { Module, Nodefony, Service, extend, injectable, mcpDeclaredScopes } from "nodefony";
|
|
6
|
+
//#region nodefony/service/DevkitService.ts
|
|
7
|
+
/**
|
|
8
|
+
* Réponse partagée quand la porte MCP n'est pas protégée — le cas par défaut.
|
|
9
|
+
* Allouer un tableau vide pour dire « rien » est la dépense que la règle de
|
|
10
|
+
* lazy-allocation proscrit.
|
|
11
|
+
*/
|
|
12
|
+
const EMPTY_PROTECTED_RESOURCES = Object.freeze([]);
|
|
13
|
+
/**
|
|
14
|
+
* Service principal du module — la logique vit ici, pas dans les controllers
|
|
15
|
+
* (un controller traduit du HTTP/WS ; un service, lui, est réutilisable par la
|
|
16
|
+
* CLI, un job, un autre module).
|
|
17
|
+
*
|
|
18
|
+
* Cycle : `constructor` (fusion défauts + config de l'app) → `init`
|
|
19
|
+
* (branchements kernel) → méthodes métier.
|
|
20
|
+
*
|
|
21
|
+
* Un service porte DEUX noms, et c'est normal :
|
|
22
|
+
* `@injectable()` → nomme la CLASSE (`DevkitService`),
|
|
23
|
+
* c'est ce qu'on écrit dans `@inject("…")`
|
|
24
|
+
* `super("devkit", …)` → nomme l'INSTANCE, sa clé dans le conteneur,
|
|
25
|
+
* c'est ce qu'on écrit dans `kernel.get("…")`
|
|
26
|
+
* Les deux mènent à la MÊME instance : le conteneur les réconcilie via la classe.
|
|
27
|
+
* (Le décorateur ne peut pas deviner la clé — il s'exécute au chargement de la
|
|
28
|
+
* classe, le `super()` seulement à la construction.)
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ Ne JAMAIS redéclarer `options` comme propriété : la classe `Service` parente
|
|
31
|
+
* l'assigne déjà via le 4ᵉ argument du `super()`. On garde une référence typée
|
|
32
|
+
* `cfg` pour lire la config sans se battre avec TypeScript.
|
|
33
|
+
*
|
|
34
|
+
* POUR L'UTILISER AILLEURS, deux voies, toutes deux légales :
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* // 1. INJECTION par le constructeur — la dépendance est DÉCLARÉE, donc le
|
|
38
|
+
* // conteneur l'ordonnance et elle se voit dans la signature (nom de CLASSE).
|
|
39
|
+
* import { inject, injectable, Service, Module } from "nodefony";
|
|
40
|
+
*
|
|
41
|
+
* @injectable()
|
|
42
|
+
* class ReportService extends Service {
|
|
43
|
+
* constructor(
|
|
44
|
+
* module: Module,
|
|
45
|
+
* @inject("DevkitService") private devkit: DevkitService,
|
|
46
|
+
* ) {
|
|
47
|
+
* super("report", module.container, module.notificationsCenter);
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* // 2. RÉSOLUTION par le conteneur, pour une dépendance tardive ou optionnelle
|
|
52
|
+
* // (nom d'INSTANCE).
|
|
53
|
+
* const devkit = this.container.get("devkit");
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
let DevkitService = class DevkitService extends Service {
|
|
57
|
+
module;
|
|
58
|
+
cfg;
|
|
59
|
+
constructor(module) {
|
|
60
|
+
const merged = extend(true, {}, defaults, module.options ?? {});
|
|
61
|
+
super("devkit", module.container, module.notificationsCenter, merged);
|
|
62
|
+
this.module = module;
|
|
63
|
+
this.cfg = merged;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Hook de démarrage d'un service : appelé UNE fois par le kernel, après la
|
|
67
|
+
* construction. C'est ici qu'on s'abonne aux événements du kernel — jamais
|
|
68
|
+
* dans le constructeur, où le kernel n'est pas encore prêt.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ Il s'appelle `init`, pas `initialize`. Le kernel ne cherche que `init`
|
|
71
|
+
* (`guardServiceInitialize`) : une méthode nommée `initialize` sur un service
|
|
72
|
+
* n'est JAMAIS appelée, et rien ne le signale — le code y dort en silence.
|
|
73
|
+
* (`initialize` existe bien, mais sur un CONTROLLER, où il tourne à CHAQUE
|
|
74
|
+
* requête : deux cycles de vie distincts, d'où deux noms.)
|
|
75
|
+
*/
|
|
76
|
+
async init() {
|
|
77
|
+
this.log("service devkit initialisé", "DEBUG");
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Carte de visite de l'application — recalculée à CHAQUE lecture.
|
|
82
|
+
*
|
|
83
|
+
* Tout est DÉRIVÉ de l'état du Kernel : le module ne stocke rien en propre, et
|
|
84
|
+
* ne peut donc pas décrire une application qui n'est plus celle-là. Un cache
|
|
85
|
+
* mentirait au premier module ajouté ; le coût ne le justifie pas (quelques
|
|
86
|
+
* lectures de champs, sur une route de développement appelée à la main).
|
|
87
|
+
*
|
|
88
|
+
* `buildCard` reste PURE et reçoit cet état : c'est la frontière qui rend la
|
|
89
|
+
* composition de la carte éprouvable sans Kernel ni serveur.
|
|
90
|
+
*/
|
|
91
|
+
getCard() {
|
|
92
|
+
const kernel = this.module.kernel;
|
|
93
|
+
return buildCard({
|
|
94
|
+
appName: kernel?.projectName ?? "application",
|
|
95
|
+
appVersion: kernel?.version ?? "0.0.0",
|
|
96
|
+
nodefonyVersion: Nodefony.version,
|
|
97
|
+
environment: kernel?.environment ?? "unknown",
|
|
98
|
+
modules: Object.keys(kernel?.modules ?? {}),
|
|
99
|
+
source: "runtime"
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Réglages du serveur MCP, tels que l'application les a effectivement.
|
|
104
|
+
*
|
|
105
|
+
* La porte HTTP les LIT ici plutôt que de relire la configuration de son
|
|
106
|
+
* côté : les défauts du schéma sont déjà fusionnés avec ce que l'app a passé
|
|
107
|
+
* dans `use()`, une seconde lecture finirait par diverger de celle-ci.
|
|
108
|
+
*/
|
|
109
|
+
mcpSettings() {
|
|
110
|
+
return this.cfg.mcp;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Ce dont les outils MCP intégrés ont besoin pour répondre.
|
|
114
|
+
*
|
|
115
|
+
* Composé ICI, et non dans la porte HTTP, parce que DEUX questions les
|
|
116
|
+
* réclament — « que sert-on à cet appelant ? » (la porte) et « qu'exige cette
|
|
117
|
+
* porte ? » ({@link DevkitService.declaredMcpScopes}, lue sans requête, au
|
|
118
|
+
* moment de publier le document RFC 9728). Deux compositions auraient fini
|
|
119
|
+
* par diverger, et c'est le document publié qui aurait eu tort.
|
|
120
|
+
*
|
|
121
|
+
* @returns broker d'administration (absent si `@nodefony/framework` n'est pas
|
|
122
|
+
* monté — les outils le DISENT alors, ils ne plantent pas), carte de
|
|
123
|
+
* visite et racine de l'APPLICATION (jamais `process.cwd()` : le
|
|
124
|
+
* serveur répond dans le process de l'app, dont le dossier courant
|
|
125
|
+
* n'est pas garanti être celui du projet).
|
|
126
|
+
*/
|
|
127
|
+
mcpToolDeps() {
|
|
128
|
+
const kernel = this.module.kernel;
|
|
129
|
+
return {
|
|
130
|
+
broker: this.get("adminBroker") ?? void 0,
|
|
131
|
+
getCard: () => this.getCard(),
|
|
132
|
+
projectRoot: kernel?.path ?? process.cwd()
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Les scopes que la porte MCP EXIGE — dérivés des outils qu'elle déclare.
|
|
137
|
+
*
|
|
138
|
+
* 🔴 **Aucune liste de configuration ne double celle-ci**, et c'est la
|
|
139
|
+
* correction d'un mensonge normatif : la liste écrite publiait `admin:write`
|
|
140
|
+
* qu'aucun outil n'exige, et taisait le scope de tout outil déclaré par un
|
|
141
|
+
* module — deux écarts qu'aucun contrôle ne pouvait voir, puisque rien ne
|
|
142
|
+
* reliait les deux. Une application qui veut voir un scope publié le pose sur
|
|
143
|
+
* son outil (`IMcpTool.scopes`), seul endroit où un scope a un EFFET.
|
|
144
|
+
*
|
|
145
|
+
* Vide quand la porte n'exige rien : `scopes_supported` est alors OMIS du
|
|
146
|
+
* document (RFC 9728 §2, champ optionnel) plutôt que publié vide.
|
|
147
|
+
*
|
|
148
|
+
* @returns les scopes dédupliqués et triés
|
|
149
|
+
*/
|
|
150
|
+
declaredMcpScopes() {
|
|
151
|
+
return mcpDeclaredScopes({
|
|
152
|
+
builtins: this.cfg.mcp.tools,
|
|
153
|
+
deps: this.mcpToolDeps(),
|
|
154
|
+
modules: this.module.kernel?.modules
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Ce que ce module protège, à publier en RFC 9728 — la porte MCP, ou rien.
|
|
159
|
+
*
|
|
160
|
+
* ⭐ **Le document n'est plus monté ici.** Il l'était, par un controller
|
|
161
|
+
* dédié, et cela faisait deux implémentations d'une même règle : celle du
|
|
162
|
+
* pare-feu (les zones qui déclarent leur ressource) et celle-ci. Deux copies
|
|
163
|
+
* divergent — chacune passe ses propres tests — et elles pouvaient en plus se
|
|
164
|
+
* disputer un chemin, que `Router.createRoute` attribue au premier arrivé sans
|
|
165
|
+
* un mot. Le module DÉCLARE désormais, `@nodefony/framework` monte.
|
|
166
|
+
*
|
|
167
|
+
* 🔴 **Effet voulu du changement** : le document n'est plus servi que sur
|
|
168
|
+
* l'autorité de `resource`. L'ancien controller répondait sur n'importe
|
|
169
|
+
* laquelle — exactement le défaut corrigé sur le document d'émetteur, qu'un
|
|
170
|
+
* vrai client MCP avait trouvé : recevoir le document d'une autre autorité le
|
|
171
|
+
* fait ARRÊTER, là où un `404` l'aurait laissé continuer.
|
|
172
|
+
*
|
|
173
|
+
* Rôle éteint (aucun serveur d'autorisation déclaré) ⇒ rien : un document sans
|
|
174
|
+
* `authorization_servers` apprendrait au client qu'un jeton est nécessaire
|
|
175
|
+
* sans lui dire où l'obtenir, ce que la spécification MCP interdit.
|
|
176
|
+
*
|
|
177
|
+
* @returns une entrée pour la porte MCP, ou aucune
|
|
178
|
+
*/
|
|
179
|
+
publishedProtectedResources() {
|
|
180
|
+
const mcp = this.cfg.mcp;
|
|
181
|
+
const authz = mcp.authorization;
|
|
182
|
+
if (!mcp.enabled || authz.authorizationServers.length === 0) return EMPTY_PROTECTED_RESOURCES;
|
|
183
|
+
return [{
|
|
184
|
+
resource: authz.resource,
|
|
185
|
+
authorizationServers: authz.authorizationServers,
|
|
186
|
+
scopesSupported: this.declaredMcpScopes(),
|
|
187
|
+
resourceName: authz.resourceName,
|
|
188
|
+
resourceDocumentation: authz.resourceDocumentation
|
|
189
|
+
}];
|
|
190
|
+
}
|
|
191
|
+
status() {
|
|
192
|
+
return { ready: this.cfg.enabled };
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
DevkitService = __decorate([injectable(), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module])], DevkitService);
|
|
196
|
+
var DevkitService_default = DevkitService;
|
|
197
|
+
//#endregion
|
|
198
|
+
export { DevkitService_default as default };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region nodefony/src/errors/DevkitError.ts
|
|
2
|
+
/**
|
|
3
|
+
* Erreur du module devkit.
|
|
4
|
+
*
|
|
5
|
+
* Deux champs qui font la différence en production :
|
|
6
|
+
* - `code` : identifiant MACHINE (stable, grep-able, consommé par Studio et
|
|
7
|
+
* l'audit) — le message, lui, peut changer sans rien casser ;
|
|
8
|
+
* - `context` : payload structuré joint au log (jamais de secret ici).
|
|
9
|
+
*/
|
|
10
|
+
var DevkitError = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
context;
|
|
13
|
+
constructor(message, code = "DEVKIT_ERROR", context) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.context = context;
|
|
17
|
+
this.name = "DevkitError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { DevkitError, DevkitError as default };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region nodefony/src/mcp/guard.ts
|
|
2
|
+
/**
|
|
3
|
+
* Une adresse est-elle celle de la machine locale ?
|
|
4
|
+
*
|
|
5
|
+
* Couvre les trois formes que Node rend selon la pile réseau : IPv4
|
|
6
|
+
* (`127.x.x.x`), IPv6 (`::1`), et l'IPv4 encapsulée en IPv6
|
|
7
|
+
* (`::ffff:127.0.0.1`) — la plus fréquente sur un serveur à double pile, et
|
|
8
|
+
* celle qu'une comparaison naïve à `"127.0.0.1"` rate.
|
|
9
|
+
*
|
|
10
|
+
* @param address - `socket.remoteAddress`, éventuellement absent
|
|
11
|
+
* @returns `true` si l'appel vient de cette machine
|
|
12
|
+
*/
|
|
13
|
+
function isLocalAddress(address) {
|
|
14
|
+
if (!address) return false;
|
|
15
|
+
const bare = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
16
|
+
if (bare === "::1" || bare === "localhost") return true;
|
|
17
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Décide si un appel MCP peut être servi.
|
|
21
|
+
*
|
|
22
|
+
* ⭐ **La règle sur `Origin` est contre-intuitive, et c'est elle qui protège.**
|
|
23
|
+
* Un client MCP légitime est un *process* (Cursor, Claude Code, un agent tiers)
|
|
24
|
+
* : il n'envoie **aucun** `Origin`, cet en-tête étant posé par les navigateurs.
|
|
25
|
+
* Une page web malveillante, elle, en pose **toujours** un lorsqu'elle vise
|
|
26
|
+
* `https://localhost:5152`. Donc : *absent* → on passe ; *présent et hors
|
|
27
|
+
* allowlist* → `403`. C'est ce qui referme le DNS rebinding, le seul vecteur
|
|
28
|
+
* réel contre un serveur MCP local.
|
|
29
|
+
*
|
|
30
|
+
* L'ordre des contrôles est délibéré : la localité d'abord, parce qu'un appel
|
|
31
|
+
* distant ne doit même pas apprendre quelles origines sont admises.
|
|
32
|
+
*
|
|
33
|
+
* @param input - ce que la requête présente
|
|
34
|
+
* @param policy - les réglages du module
|
|
35
|
+
* @returns le verdict, avec le motif quand il refuse
|
|
36
|
+
*/
|
|
37
|
+
function checkMcpAccess(input, policy) {
|
|
38
|
+
if (!policy.allowRemote && !isLocalAddress(input.remoteAddress)) return {
|
|
39
|
+
allowed: false,
|
|
40
|
+
why: `adresse non locale (${input.remoteAddress ?? "inconnue"}) — voir devkit.mcp.allowRemote`
|
|
41
|
+
};
|
|
42
|
+
if (input.origin !== void 0 && input.origin !== "") {
|
|
43
|
+
if (!policy.allowedOrigins.includes(input.origin)) return {
|
|
44
|
+
allowed: false,
|
|
45
|
+
why: `origine « ${input.origin} » non admise — voir devkit.mcp.allowedOrigins`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { allowed: true };
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { checkMcpAccess, isLocalAddress };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
//#region nodefony/src/mcp/protocol.ts
|
|
2
|
+
/**
|
|
3
|
+
* Model Context Protocol — types et constantes du transport « Streamable HTTP ».
|
|
4
|
+
*
|
|
5
|
+
* Révision visée : **2026-07-28**, celle qui a supprimé les sessions de niveau
|
|
6
|
+
* protocole et le flux `GET`. C'est ce qui rend cette porte possible sans
|
|
7
|
+
* process dédié : chaque message est un `POST` autonome, donc un redémarrage du
|
|
8
|
+
* serveur de développement ne casse rien — le client rejoue simplement sa
|
|
9
|
+
* requête, et la réponse vient du code qui vient d'être rechargé.
|
|
10
|
+
*
|
|
11
|
+
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
|
|
12
|
+
*/
|
|
13
|
+
/** Révision du protocole que ce serveur annonce. */
|
|
14
|
+
const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
15
|
+
/**
|
|
16
|
+
* Versions que ce serveur sait servir — publiées par `server/discover` et
|
|
17
|
+
* listées dans l'erreur `UnsupportedProtocolVersion`.
|
|
18
|
+
*
|
|
19
|
+
* Une seule pour l'instant, et c'est délibéré : annoncer une révision qu'on
|
|
20
|
+
* n'a pas éprouvée reviendrait à promettre une sémantique qu'on ne tient pas.
|
|
21
|
+
*/
|
|
22
|
+
const MCP_SUPPORTED_VERSIONS = [MCP_PROTOCOL_VERSION];
|
|
23
|
+
/**
|
|
24
|
+
* Clé de métadonnée par laquelle un client MODERNE déclare sa révision.
|
|
25
|
+
*
|
|
26
|
+
* ⭐ **C'est la différence d'ÈRE, et elle commande tout le reste.** Jusqu'à
|
|
27
|
+
* `2025-11-25` (ère « legacy »), un client ouvrait une session par un handshake
|
|
28
|
+
* `initialize`. Depuis `2026-07-28` (ère « modern »), il n'y a plus de session :
|
|
29
|
+
* chaque requête porte elle-même sa version et les capacités du client, dans
|
|
30
|
+
* `params._meta`. Un serveur qui n'écouterait que `initialize` serait un
|
|
31
|
+
* serveur *legacy* — quelle que soit la version qu'il prétend annoncer.
|
|
32
|
+
*/
|
|
33
|
+
const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
34
|
+
/** Clé de métadonnée portant l'identité du serveur dans `server/discover`. */
|
|
35
|
+
const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
36
|
+
/**
|
|
37
|
+
* Chemin de l'endpoint MCP.
|
|
38
|
+
*
|
|
39
|
+
* ## Pourquoi `/nodefony/mcp`, et pas `/nodefony/devkit/api/mcp`
|
|
40
|
+
*
|
|
41
|
+
* Cette URL est un **contrat public** : elle est écrite dans le `.mcp.json` de
|
|
42
|
+
* chaque utilisateur. Y faire figurer le module qui l'implémente la rendrait
|
|
43
|
+
* caduque au premier déménagement — or ce serveur a vocation à bouger le jour
|
|
44
|
+
* où une application voudra s'exposer en production (le devkit, lui, est
|
|
45
|
+
* `policy: "dev"`). Le nom d'un module est un détail d'implémentation ; une URL
|
|
46
|
+
* ne l'est pas.
|
|
47
|
+
*
|
|
48
|
+
* Le segment `api` est écarté pour une autre raison : il désigne le plan
|
|
49
|
+
* d'administration JSON de Studio, avec son contrôle d'accès par rôle. Le MCP
|
|
50
|
+
* n'est ni REST ni destiné à Studio — ranger deux protocoles sous le même
|
|
51
|
+
* segment promettrait une parenté qui n'existe pas.
|
|
52
|
+
*
|
|
53
|
+
* Et `/mcp` à la racine, qui est la convention de fait ailleurs, prendrait un
|
|
54
|
+
* chemin qui **appartient à l'application** : `/nodefony` est le préfixe
|
|
55
|
+
* réservé du framework, donc sans collision possible.
|
|
56
|
+
*
|
|
57
|
+
* Constante et **non configurable** : une route décorée est statique, et un
|
|
58
|
+
* réglage qui n'agirait pas serait pire qu'aucun réglage.
|
|
59
|
+
*/
|
|
60
|
+
const MCP_ENDPOINT_PATH = "/nodefony/mcp";
|
|
61
|
+
/** Codes d'erreur JSON-RPC 2.0 employés par ce serveur. */
|
|
62
|
+
const JsonRpcError = {
|
|
63
|
+
/** Corps illisible. */
|
|
64
|
+
PARSE_ERROR: -32700,
|
|
65
|
+
/** Message qui n'est pas une requête JSON-RPC valide. */
|
|
66
|
+
INVALID_REQUEST: -32600,
|
|
67
|
+
/** Méthode inconnue — la spec exige alors un `404` HTTP. */
|
|
68
|
+
METHOD_NOT_FOUND: -32601,
|
|
69
|
+
/** Paramètres absents ou mal typés. */
|
|
70
|
+
INVALID_PARAMS: -32602,
|
|
71
|
+
/** Échec côté serveur. */
|
|
72
|
+
INTERNAL_ERROR: -32603
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Codes réservés par la spec MCP, hors plage JSON-RPC standard.
|
|
76
|
+
*
|
|
77
|
+
* Ils ne sont pas décoratifs : un client s'en sert pour se rattraper seul —
|
|
78
|
+
* renégocier une version sur `-32022`, relire `tools/list` puis réessayer sur
|
|
79
|
+
* `-32020`. Rendre un `-32600` générique à leur place le priverait de cette
|
|
80
|
+
* reprise et transformerait un désaccord réparable en échec définitif.
|
|
81
|
+
*/
|
|
82
|
+
const McpProtocolError = {
|
|
83
|
+
/**
|
|
84
|
+
* Les en-têtes HTTP contredisent le corps, ou un en-tête requis manque.
|
|
85
|
+
* La spec impose `400` **et** ce code (`streamable-http` §Server Validation).
|
|
86
|
+
*/
|
|
87
|
+
HEADER_MISMATCH: -32020,
|
|
88
|
+
/**
|
|
89
|
+
* La révision demandée n'est pas servie. La réponse **doit** lister celles
|
|
90
|
+
* qu'on sert, sans quoi le client n'a rien pour choisir.
|
|
91
|
+
*/
|
|
92
|
+
UNSUPPORTED_PROTOCOL_VERSION: -32022
|
|
93
|
+
};
|
|
94
|
+
/** Fabrique une réponse de succès. */
|
|
95
|
+
function jsonRpcSuccess(id, result) {
|
|
96
|
+
return {
|
|
97
|
+
jsonrpc: "2.0",
|
|
98
|
+
id,
|
|
99
|
+
result
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Fabrique une réponse d'erreur. */
|
|
103
|
+
function jsonRpcFailure(id, code, message, data) {
|
|
104
|
+
return {
|
|
105
|
+
jsonrpc: "2.0",
|
|
106
|
+
id,
|
|
107
|
+
error: data === void 0 ? {
|
|
108
|
+
code,
|
|
109
|
+
message
|
|
110
|
+
} : {
|
|
111
|
+
code,
|
|
112
|
+
message,
|
|
113
|
+
data
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Un message est-il une NOTIFICATION (pas d'`id`) plutôt qu'une requête ?
|
|
119
|
+
*
|
|
120
|
+
* La distinction commande le statut HTTP : une notification acceptée rend
|
|
121
|
+
* `202` **sans corps**, une requête rend son objet JSON.
|
|
122
|
+
*/
|
|
123
|
+
function isNotification(message) {
|
|
124
|
+
return message.id === void 0 || message.id === null;
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
export { JsonRpcError, MCP_ENDPOINT_PATH, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, McpProtocolError, isNotification, jsonRpcFailure, jsonRpcSuccess };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { JsonRpcError, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, McpProtocolError, isNotification, jsonRpcFailure, jsonRpcSuccess } from "./protocol.js";
|
|
2
|
+
import { callMcpTool, listMcpTools } from "./tools.js";
|
|
3
|
+
//#region nodefony/src/mcp/server.ts
|
|
4
|
+
/**
|
|
5
|
+
* Extrait la révision déclarée dans `params._meta`, s'il y en a une.
|
|
6
|
+
*
|
|
7
|
+
* Un client MODERNE la pose à chaque requête ; un client LEGACY ne pose rien
|
|
8
|
+
* et négocie par `initialize`. L'absence n'est donc pas une faute : c'est un
|
|
9
|
+
* indice d'ère.
|
|
10
|
+
*/
|
|
11
|
+
function metaVersion(params) {
|
|
12
|
+
const meta = params._meta;
|
|
13
|
+
if (typeof meta !== "object" || meta === null) return void 0;
|
|
14
|
+
const value = meta[META_PROTOCOL_VERSION];
|
|
15
|
+
return typeof value === "string" ? value : void 0;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Contrôle la cohérence et le support de la révision annoncée.
|
|
19
|
+
*
|
|
20
|
+
* Deux refus distincts, et la spec impose les deux :
|
|
21
|
+
* - **en-tête ≠ `_meta`** → `400` + `HeaderMismatch` (`-32020`). Le motif est
|
|
22
|
+
* une vraie faille : un répartiteur de charge peut router sur l'en-tête
|
|
23
|
+
* pendant que le serveur exécute d'après le corps — deux sources de vérité
|
|
24
|
+
* pour une même requête.
|
|
25
|
+
* - **révision inconnue** → `400` + `UnsupportedProtocolVersion` (`-32022`),
|
|
26
|
+
* **avec la liste de celles qu'on sert** : c'est elle qui permet au client
|
|
27
|
+
* de se rattraper au lieu d'abandonner.
|
|
28
|
+
*
|
|
29
|
+
* @returns `null` si tout va bien, sinon la réponse de refus
|
|
30
|
+
*/
|
|
31
|
+
function checkProtocolVersion(id, params, headers) {
|
|
32
|
+
const fromMeta = metaVersion(params);
|
|
33
|
+
const fromHeader = headers.protocolVersion;
|
|
34
|
+
if (fromMeta && fromHeader && fromMeta !== fromHeader) return {
|
|
35
|
+
status: 400,
|
|
36
|
+
body: jsonRpcFailure(id, McpProtocolError.HEADER_MISMATCH, "MCP-Protocol-Version ne correspond pas à _meta", {
|
|
37
|
+
header: fromHeader,
|
|
38
|
+
meta: fromMeta
|
|
39
|
+
})
|
|
40
|
+
};
|
|
41
|
+
const declared = fromMeta ?? fromHeader;
|
|
42
|
+
if (!declared) return null;
|
|
43
|
+
if (!MCP_SUPPORTED_VERSIONS.includes(declared)) return {
|
|
44
|
+
status: 400,
|
|
45
|
+
body: jsonRpcFailure(id, McpProtocolError.UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {
|
|
46
|
+
supported: [...MCP_SUPPORTED_VERSIONS],
|
|
47
|
+
requested: declared
|
|
48
|
+
})
|
|
49
|
+
};
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Traite UN message JSON-RPC.
|
|
54
|
+
*
|
|
55
|
+
* @param message - corps du `POST`, déjà parsé
|
|
56
|
+
* @param context - outils autorisés et briques qui répondent
|
|
57
|
+
* @param headers - en-têtes du transport (`MCP-Protocol-Version`)
|
|
58
|
+
* @returns statut HTTP et corps à écrire (corps `null` = `202` sans contenu)
|
|
59
|
+
*/
|
|
60
|
+
async function handleMcpMessage(message, context, headers = {}) {
|
|
61
|
+
if (message === null || typeof message !== "object" || typeof message.method !== "string") return {
|
|
62
|
+
status: 400,
|
|
63
|
+
body: jsonRpcFailure(null, JsonRpcError.INVALID_REQUEST, "message JSON-RPC invalide : `method` manquante")
|
|
64
|
+
};
|
|
65
|
+
const method = message.method;
|
|
66
|
+
if (isNotification(message)) return {
|
|
67
|
+
status: 202,
|
|
68
|
+
body: null
|
|
69
|
+
};
|
|
70
|
+
const id = message.id;
|
|
71
|
+
const params = typeof message.params === "object" && message.params !== null ? message.params : {};
|
|
72
|
+
const refus = checkProtocolVersion(id, params, headers);
|
|
73
|
+
if (refus) return refus;
|
|
74
|
+
switch (method) {
|
|
75
|
+
case "server/discover": return {
|
|
76
|
+
status: 200,
|
|
77
|
+
body: jsonRpcSuccess(id, {
|
|
78
|
+
resultType: "complete",
|
|
79
|
+
supportedVersions: [...MCP_SUPPORTED_VERSIONS],
|
|
80
|
+
capabilities: { tools: {} },
|
|
81
|
+
instructions: "Outils d'introspection d'une application Nodefony : ce qui est monté (inspect), ce qui manque (check), ce qu'une API du framework signifie (symbols), et par où commencer (card).",
|
|
82
|
+
_meta: { [META_SERVER_INFO]: context.serverInfo }
|
|
83
|
+
})
|
|
84
|
+
};
|
|
85
|
+
case "initialize": return {
|
|
86
|
+
status: 200,
|
|
87
|
+
body: jsonRpcSuccess(id, {
|
|
88
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
89
|
+
capabilities: { tools: {} },
|
|
90
|
+
serverInfo: context.serverInfo
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
case "ping": return {
|
|
94
|
+
status: 200,
|
|
95
|
+
body: jsonRpcSuccess(id, {})
|
|
96
|
+
};
|
|
97
|
+
case "tools/list": return {
|
|
98
|
+
status: 200,
|
|
99
|
+
body: jsonRpcSuccess(id, { tools: listMcpTools(context.tools) })
|
|
100
|
+
};
|
|
101
|
+
case "tools/call": {
|
|
102
|
+
const name = typeof params.name === "string" ? params.name : "";
|
|
103
|
+
const args = typeof params.arguments === "object" && params.arguments !== null ? params.arguments : {};
|
|
104
|
+
if (!name) return {
|
|
105
|
+
status: 400,
|
|
106
|
+
body: jsonRpcFailure(id, JsonRpcError.INVALID_PARAMS, "`name` est requis pour `tools/call`")
|
|
107
|
+
};
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await callMcpTool(name, args, context.tools, context);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
status: 200,
|
|
114
|
+
body: jsonRpcFailure(id, JsonRpcError.INTERNAL_ERROR, `l'outil « ${name} » a échoué : ${error.message}`)
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (result === null) return {
|
|
118
|
+
status: 200,
|
|
119
|
+
body: jsonRpcFailure(id, JsonRpcError.INVALID_PARAMS, `outil inconnu « ${name} » — voir tools/list`)
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
status: 200,
|
|
123
|
+
body: jsonRpcSuccess(id, result)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
default: return {
|
|
127
|
+
status: 404,
|
|
128
|
+
body: jsonRpcFailure(id, JsonRpcError.METHOD_NOT_FOUND, `méthode inconnue « ${method} »`)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
export { handleMcpMessage };
|