@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.
Files changed (67) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +318 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  7. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
  8. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
  9. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
  10. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
  11. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
  12. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
  13. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
  14. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
  15. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  16. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  17. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  18. package/dist/index.js +47 -0
  19. package/dist/nodefony/command/CardCommand.js +70 -0
  20. package/dist/nodefony/config/config.js +200 -0
  21. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  22. package/dist/nodefony/controllers/DevkitController.js +60 -0
  23. package/dist/nodefony/controllers/McpController.js +223 -0
  24. package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
  25. package/dist/nodefony/interfaces/IDevkitService.js +1 -0
  26. package/dist/nodefony/interfaces/index.js +1 -0
  27. package/dist/nodefony/service/DevkitService.js +198 -0
  28. package/dist/nodefony/src/card.js +2 -0
  29. package/dist/nodefony/src/errors/DevkitError.js +21 -0
  30. package/dist/nodefony/src/mcp/guard.js +51 -0
  31. package/dist/nodefony/src/mcp/protocol.js +127 -0
  32. package/dist/nodefony/src/mcp/server.js +133 -0
  33. package/dist/nodefony/src/mcp/tools.js +163 -0
  34. package/dist/types/index.d.ts +52 -0
  35. package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
  36. package/dist/types/nodefony/config/config.d.ts +25 -0
  37. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  38. package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
  39. package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
  40. package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
  41. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  43. package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
  44. package/dist/types/nodefony/src/card.d.ts +18 -0
  45. package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
  46. package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
  47. package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
  48. package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
  49. package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
  50. package/docs/index.md +358 -0
  51. package/package.json +77 -0
  52. package/skills/nodefony-add-crud/SKILL.md +199 -0
  53. package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
  54. package/skills/nodefony-add-service/SKILL.md +90 -0
  55. package/skills/nodefony-browser/SKILL.md +416 -0
  56. package/skills/nodefony-browser/references/socket.md +115 -0
  57. package/skills/nodefony-browser/references/sondes.md +175 -0
  58. package/skills/nodefony-browser/scripts/audit.mjs +169 -0
  59. package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
  60. package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
  61. package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
  62. package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
  63. package/skills/nodefony-browser/scripts/socket.mjs +354 -0
  64. package/skills/nodefony-browser/scripts/watch.mjs +125 -0
  65. package/skills/nodefony-migrate-schema/SKILL.md +359 -0
  66. package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
  67. 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,2 @@
1
+ import { buildCard, renderCard } from "nodefony";
2
+ export { buildCard, renderCard };
@@ -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 };