@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,143 @@
|
|
|
1
|
+
import { Service, Module } from "nodefony";
|
|
2
|
+
import type { IMcpToolDeps, IProtectedResourceInput } from "nodefony";
|
|
3
|
+
import type { IDevkitCard, IDevkitService } from "../interfaces/IDevkitService.js";
|
|
4
|
+
import { type DevkitConfig } from "../config/config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Service principal du module — la logique vit ici, pas dans les controllers
|
|
7
|
+
* (un controller traduit du HTTP/WS ; un service, lui, est réutilisable par la
|
|
8
|
+
* CLI, un job, un autre module).
|
|
9
|
+
*
|
|
10
|
+
* Cycle : `constructor` (fusion défauts + config de l'app) → `init`
|
|
11
|
+
* (branchements kernel) → méthodes métier.
|
|
12
|
+
*
|
|
13
|
+
* Un service porte DEUX noms, et c'est normal :
|
|
14
|
+
* `@injectable()` → nomme la CLASSE (`DevkitService`),
|
|
15
|
+
* c'est ce qu'on écrit dans `@inject("…")`
|
|
16
|
+
* `super("devkit", …)` → nomme l'INSTANCE, sa clé dans le conteneur,
|
|
17
|
+
* c'est ce qu'on écrit dans `kernel.get("…")`
|
|
18
|
+
* Les deux mènent à la MÊME instance : le conteneur les réconcilie via la classe.
|
|
19
|
+
* (Le décorateur ne peut pas deviner la clé — il s'exécute au chargement de la
|
|
20
|
+
* classe, le `super()` seulement à la construction.)
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ Ne JAMAIS redéclarer `options` comme propriété : la classe `Service` parente
|
|
23
|
+
* l'assigne déjà via le 4ᵉ argument du `super()`. On garde une référence typée
|
|
24
|
+
* `cfg` pour lire la config sans se battre avec TypeScript.
|
|
25
|
+
*
|
|
26
|
+
* POUR L'UTILISER AILLEURS, deux voies, toutes deux légales :
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* // 1. INJECTION par le constructeur — la dépendance est DÉCLARÉE, donc le
|
|
30
|
+
* // conteneur l'ordonnance et elle se voit dans la signature (nom de CLASSE).
|
|
31
|
+
* import { inject, injectable, Service, Module } from "nodefony";
|
|
32
|
+
*
|
|
33
|
+
* @injectable()
|
|
34
|
+
* class ReportService extends Service {
|
|
35
|
+
* constructor(
|
|
36
|
+
* module: Module,
|
|
37
|
+
* @inject("DevkitService") private devkit: DevkitService,
|
|
38
|
+
* ) {
|
|
39
|
+
* super("report", module.container, module.notificationsCenter);
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* // 2. RÉSOLUTION par le conteneur, pour une dépendance tardive ou optionnelle
|
|
44
|
+
* // (nom d'INSTANCE).
|
|
45
|
+
* const devkit = this.container.get("devkit");
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
declare class DevkitService extends Service implements IDevkitService {
|
|
49
|
+
module: Module;
|
|
50
|
+
private readonly cfg;
|
|
51
|
+
constructor(module: Module);
|
|
52
|
+
/**
|
|
53
|
+
* Hook de démarrage d'un service : appelé UNE fois par le kernel, après la
|
|
54
|
+
* construction. C'est ici qu'on s'abonne aux événements du kernel — jamais
|
|
55
|
+
* dans le constructeur, où le kernel n'est pas encore prêt.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ Il s'appelle `init`, pas `initialize`. Le kernel ne cherche que `init`
|
|
58
|
+
* (`guardServiceInitialize`) : une méthode nommée `initialize` sur un service
|
|
59
|
+
* n'est JAMAIS appelée, et rien ne le signale — le code y dort en silence.
|
|
60
|
+
* (`initialize` existe bien, mais sur un CONTROLLER, où il tourne à CHAQUE
|
|
61
|
+
* requête : deux cycles de vie distincts, d'où deux noms.)
|
|
62
|
+
*/
|
|
63
|
+
init(): Promise<this>;
|
|
64
|
+
/**
|
|
65
|
+
* Carte de visite de l'application — recalculée à CHAQUE lecture.
|
|
66
|
+
*
|
|
67
|
+
* Tout est DÉRIVÉ de l'état du Kernel : le module ne stocke rien en propre, et
|
|
68
|
+
* ne peut donc pas décrire une application qui n'est plus celle-là. Un cache
|
|
69
|
+
* mentirait au premier module ajouté ; le coût ne le justifie pas (quelques
|
|
70
|
+
* lectures de champs, sur une route de développement appelée à la main).
|
|
71
|
+
*
|
|
72
|
+
* `buildCard` reste PURE et reçoit cet état : c'est la frontière qui rend la
|
|
73
|
+
* composition de la carte éprouvable sans Kernel ni serveur.
|
|
74
|
+
*/
|
|
75
|
+
getCard(): IDevkitCard;
|
|
76
|
+
/**
|
|
77
|
+
* Réglages du serveur MCP, tels que l'application les a effectivement.
|
|
78
|
+
*
|
|
79
|
+
* La porte HTTP les LIT ici plutôt que de relire la configuration de son
|
|
80
|
+
* côté : les défauts du schéma sont déjà fusionnés avec ce que l'app a passé
|
|
81
|
+
* dans `use()`, une seconde lecture finirait par diverger de celle-ci.
|
|
82
|
+
*/
|
|
83
|
+
mcpSettings(): DevkitConfig["mcp"];
|
|
84
|
+
/**
|
|
85
|
+
* Ce dont les outils MCP intégrés ont besoin pour répondre.
|
|
86
|
+
*
|
|
87
|
+
* Composé ICI, et non dans la porte HTTP, parce que DEUX questions les
|
|
88
|
+
* réclament — « que sert-on à cet appelant ? » (la porte) et « qu'exige cette
|
|
89
|
+
* porte ? » ({@link DevkitService.declaredMcpScopes}, lue sans requête, au
|
|
90
|
+
* moment de publier le document RFC 9728). Deux compositions auraient fini
|
|
91
|
+
* par diverger, et c'est le document publié qui aurait eu tort.
|
|
92
|
+
*
|
|
93
|
+
* @returns broker d'administration (absent si `@nodefony/framework` n'est pas
|
|
94
|
+
* monté — les outils le DISENT alors, ils ne plantent pas), carte de
|
|
95
|
+
* visite et racine de l'APPLICATION (jamais `process.cwd()` : le
|
|
96
|
+
* serveur répond dans le process de l'app, dont le dossier courant
|
|
97
|
+
* n'est pas garanti être celui du projet).
|
|
98
|
+
*/
|
|
99
|
+
mcpToolDeps(): IMcpToolDeps;
|
|
100
|
+
/**
|
|
101
|
+
* Les scopes que la porte MCP EXIGE — dérivés des outils qu'elle déclare.
|
|
102
|
+
*
|
|
103
|
+
* 🔴 **Aucune liste de configuration ne double celle-ci**, et c'est la
|
|
104
|
+
* correction d'un mensonge normatif : la liste écrite publiait `admin:write`
|
|
105
|
+
* qu'aucun outil n'exige, et taisait le scope de tout outil déclaré par un
|
|
106
|
+
* module — deux écarts qu'aucun contrôle ne pouvait voir, puisque rien ne
|
|
107
|
+
* reliait les deux. Une application qui veut voir un scope publié le pose sur
|
|
108
|
+
* son outil (`IMcpTool.scopes`), seul endroit où un scope a un EFFET.
|
|
109
|
+
*
|
|
110
|
+
* Vide quand la porte n'exige rien : `scopes_supported` est alors OMIS du
|
|
111
|
+
* document (RFC 9728 §2, champ optionnel) plutôt que publié vide.
|
|
112
|
+
*
|
|
113
|
+
* @returns les scopes dédupliqués et triés
|
|
114
|
+
*/
|
|
115
|
+
declaredMcpScopes(): readonly string[];
|
|
116
|
+
/**
|
|
117
|
+
* Ce que ce module protège, à publier en RFC 9728 — la porte MCP, ou rien.
|
|
118
|
+
*
|
|
119
|
+
* ⭐ **Le document n'est plus monté ici.** Il l'était, par un controller
|
|
120
|
+
* dédié, et cela faisait deux implémentations d'une même règle : celle du
|
|
121
|
+
* pare-feu (les zones qui déclarent leur ressource) et celle-ci. Deux copies
|
|
122
|
+
* divergent — chacune passe ses propres tests — et elles pouvaient en plus se
|
|
123
|
+
* disputer un chemin, que `Router.createRoute` attribue au premier arrivé sans
|
|
124
|
+
* un mot. Le module DÉCLARE désormais, `@nodefony/framework` monte.
|
|
125
|
+
*
|
|
126
|
+
* 🔴 **Effet voulu du changement** : le document n'est plus servi que sur
|
|
127
|
+
* l'autorité de `resource`. L'ancien controller répondait sur n'importe
|
|
128
|
+
* laquelle — exactement le défaut corrigé sur le document d'émetteur, qu'un
|
|
129
|
+
* vrai client MCP avait trouvé : recevoir le document d'une autre autorité le
|
|
130
|
+
* fait ARRÊTER, là où un `404` l'aurait laissé continuer.
|
|
131
|
+
*
|
|
132
|
+
* Rôle éteint (aucun serveur d'autorisation déclaré) ⇒ rien : un document sans
|
|
133
|
+
* `authorization_servers` apprendrait au client qu'un jeton est nécessaire
|
|
134
|
+
* sans lui dire où l'obtenir, ce que la spécification MCP interdit.
|
|
135
|
+
*
|
|
136
|
+
* @returns une entrée pour la porte MCP, ou aucune
|
|
137
|
+
*/
|
|
138
|
+
publishedProtectedResources(): readonly IProtectedResourceInput[];
|
|
139
|
+
status(): {
|
|
140
|
+
ready: boolean;
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export default DevkitService;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La composition de la carte vit désormais dans le CŒUR
|
|
3
|
+
* (`nodefony` → `src/cli/cardReport.ts`) ; ce fichier n'en est plus que le point
|
|
4
|
+
* d'entrée historique du module.
|
|
5
|
+
*
|
|
6
|
+
* Pourquoi elle a déménagé : la carte doit répondre sur une application **non
|
|
7
|
+
* construite** et dans un terminal **sans `NODE_ENV`** — deux situations où
|
|
8
|
+
* aucun module n'est chargé, celui-ci compris. Une capacité qui doit tenir sans
|
|
9
|
+
* installation ne peut pas dépendre d'un module : c'est la règle que `check`,
|
|
10
|
+
* `env` et `inspect` suivent déjà, et elle est inscrite au `CLAUDE.md` de ce
|
|
11
|
+
* paquet (« y déplacer une capacité qui doit marcher sans installation ou
|
|
12
|
+
* application cassée » est justement ce qu'il interdit).
|
|
13
|
+
*
|
|
14
|
+
* Ce qui reste ici : la porte **HTTP**, servie par `DevkitService` quand le
|
|
15
|
+
* Kernel tourne — elle seule connaît les modules réellement CHARGÉS. Une
|
|
16
|
+
* composition, deux portes, aucune divergence possible.
|
|
17
|
+
*/
|
|
18
|
+
export { buildCard, renderCard } from "nodefony";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Erreur du module devkit.
|
|
3
|
+
*
|
|
4
|
+
* Deux champs qui font la différence en production :
|
|
5
|
+
* - `code` : identifiant MACHINE (stable, grep-able, consommé par Studio et
|
|
6
|
+
* l'audit) — le message, lui, peut changer sans rien casser ;
|
|
7
|
+
* - `context` : payload structuré joint au log (jamais de secret ici).
|
|
8
|
+
*/
|
|
9
|
+
export declare class DevkitError extends Error {
|
|
10
|
+
readonly code: string;
|
|
11
|
+
readonly context?: Record<string, unknown> | undefined;
|
|
12
|
+
constructor(message: string, code?: string, context?: Record<string, unknown> | undefined);
|
|
13
|
+
}
|
|
14
|
+
export default DevkitError;
|
|
@@ -0,0 +1,66 @@
|
|
|
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;
|
|
26
|
+
/** Adresse distante de la connexion (`socket.remoteAddress`). */
|
|
27
|
+
remoteAddress?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Réglages qui gouvernent les gardes (viennent de la config du module). */
|
|
30
|
+
export interface IGuardPolicy {
|
|
31
|
+
/** Origines de navigateur admises ; vide = aucune. */
|
|
32
|
+
allowedOrigins: readonly string[];
|
|
33
|
+
/** Accepter une adresse distante non locale. */
|
|
34
|
+
allowRemote: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Une adresse est-elle celle de la machine locale ?
|
|
38
|
+
*
|
|
39
|
+
* Couvre les trois formes que Node rend selon la pile réseau : IPv4
|
|
40
|
+
* (`127.x.x.x`), IPv6 (`::1`), et l'IPv4 encapsulée en IPv6
|
|
41
|
+
* (`::ffff:127.0.0.1`) — la plus fréquente sur un serveur à double pile, et
|
|
42
|
+
* celle qu'une comparaison naïve à `"127.0.0.1"` rate.
|
|
43
|
+
*
|
|
44
|
+
* @param address - `socket.remoteAddress`, éventuellement absent
|
|
45
|
+
* @returns `true` si l'appel vient de cette machine
|
|
46
|
+
*/
|
|
47
|
+
export declare function isLocalAddress(address?: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Décide si un appel MCP peut être servi.
|
|
50
|
+
*
|
|
51
|
+
* ⭐ **La règle sur `Origin` est contre-intuitive, et c'est elle qui protège.**
|
|
52
|
+
* Un client MCP légitime est un *process* (Cursor, Claude Code, un agent tiers)
|
|
53
|
+
* : il n'envoie **aucun** `Origin`, cet en-tête étant posé par les navigateurs.
|
|
54
|
+
* Une page web malveillante, elle, en pose **toujours** un lorsqu'elle vise
|
|
55
|
+
* `https://localhost:5152`. Donc : *absent* → on passe ; *présent et hors
|
|
56
|
+
* allowlist* → `403`. C'est ce qui referme le DNS rebinding, le seul vecteur
|
|
57
|
+
* réel contre un serveur MCP local.
|
|
58
|
+
*
|
|
59
|
+
* L'ordre des contrôles est délibéré : la localité d'abord, parce qu'un appel
|
|
60
|
+
* distant ne doit même pas apprendre quelles origines sont admises.
|
|
61
|
+
*
|
|
62
|
+
* @param input - ce que la requête présente
|
|
63
|
+
* @param policy - les réglages du module
|
|
64
|
+
* @returns le verdict, avec le motif quand il refuse
|
|
65
|
+
*/
|
|
66
|
+
export declare function checkMcpAccess(input: IGuardInput, policy: IGuardPolicy): GuardVerdict;
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
/** Révision du protocole que ce serveur annonce. */
|
|
13
|
+
export declare const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
14
|
+
/**
|
|
15
|
+
* Versions que ce serveur sait servir — publiées par `server/discover` et
|
|
16
|
+
* listées dans l'erreur `UnsupportedProtocolVersion`.
|
|
17
|
+
*
|
|
18
|
+
* Une seule pour l'instant, et c'est délibéré : annoncer une révision qu'on
|
|
19
|
+
* n'a pas éprouvée reviendrait à promettre une sémantique qu'on ne tient pas.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MCP_SUPPORTED_VERSIONS: readonly ["2026-07-28"];
|
|
22
|
+
/**
|
|
23
|
+
* Clé de métadonnée par laquelle un client MODERNE déclare sa révision.
|
|
24
|
+
*
|
|
25
|
+
* ⭐ **C'est la différence d'ÈRE, et elle commande tout le reste.** Jusqu'à
|
|
26
|
+
* `2025-11-25` (ère « legacy »), un client ouvrait une session par un handshake
|
|
27
|
+
* `initialize`. Depuis `2026-07-28` (ère « modern »), il n'y a plus de session :
|
|
28
|
+
* chaque requête porte elle-même sa version et les capacités du client, dans
|
|
29
|
+
* `params._meta`. Un serveur qui n'écouterait que `initialize` serait un
|
|
30
|
+
* serveur *legacy* — quelle que soit la version qu'il prétend annoncer.
|
|
31
|
+
*/
|
|
32
|
+
export declare const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
33
|
+
/** Clé de métadonnée portant l'identité du serveur dans `server/discover`. */
|
|
34
|
+
export declare const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
35
|
+
/**
|
|
36
|
+
* Chemin de l'endpoint MCP.
|
|
37
|
+
*
|
|
38
|
+
* ## Pourquoi `/nodefony/mcp`, et pas `/nodefony/devkit/api/mcp`
|
|
39
|
+
*
|
|
40
|
+
* Cette URL est un **contrat public** : elle est écrite dans le `.mcp.json` de
|
|
41
|
+
* chaque utilisateur. Y faire figurer le module qui l'implémente la rendrait
|
|
42
|
+
* caduque au premier déménagement — or ce serveur a vocation à bouger le jour
|
|
43
|
+
* où une application voudra s'exposer en production (le devkit, lui, est
|
|
44
|
+
* `policy: "dev"`). Le nom d'un module est un détail d'implémentation ; une URL
|
|
45
|
+
* ne l'est pas.
|
|
46
|
+
*
|
|
47
|
+
* Le segment `api` est écarté pour une autre raison : il désigne le plan
|
|
48
|
+
* d'administration JSON de Studio, avec son contrôle d'accès par rôle. Le MCP
|
|
49
|
+
* n'est ni REST ni destiné à Studio — ranger deux protocoles sous le même
|
|
50
|
+
* segment promettrait une parenté qui n'existe pas.
|
|
51
|
+
*
|
|
52
|
+
* Et `/mcp` à la racine, qui est la convention de fait ailleurs, prendrait un
|
|
53
|
+
* chemin qui **appartient à l'application** : `/nodefony` est le préfixe
|
|
54
|
+
* réservé du framework, donc sans collision possible.
|
|
55
|
+
*
|
|
56
|
+
* Constante et **non configurable** : une route décorée est statique, et un
|
|
57
|
+
* réglage qui n'agirait pas serait pire qu'aucun réglage.
|
|
58
|
+
*/
|
|
59
|
+
export declare const MCP_ENDPOINT_PATH = "/nodefony/mcp";
|
|
60
|
+
/** Codes d'erreur JSON-RPC 2.0 employés par ce serveur. */
|
|
61
|
+
export declare const JsonRpcError: {
|
|
62
|
+
/** Corps illisible. */
|
|
63
|
+
readonly PARSE_ERROR: -32700;
|
|
64
|
+
/** Message qui n'est pas une requête JSON-RPC valide. */
|
|
65
|
+
readonly INVALID_REQUEST: -32600;
|
|
66
|
+
/** Méthode inconnue — la spec exige alors un `404` HTTP. */
|
|
67
|
+
readonly METHOD_NOT_FOUND: -32601;
|
|
68
|
+
/** Paramètres absents ou mal typés. */
|
|
69
|
+
readonly INVALID_PARAMS: -32602;
|
|
70
|
+
/** Échec côté serveur. */
|
|
71
|
+
readonly INTERNAL_ERROR: -32603;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Codes réservés par la spec MCP, hors plage JSON-RPC standard.
|
|
75
|
+
*
|
|
76
|
+
* Ils ne sont pas décoratifs : un client s'en sert pour se rattraper seul —
|
|
77
|
+
* renégocier une version sur `-32022`, relire `tools/list` puis réessayer sur
|
|
78
|
+
* `-32020`. Rendre un `-32600` générique à leur place le priverait de cette
|
|
79
|
+
* reprise et transformerait un désaccord réparable en échec définitif.
|
|
80
|
+
*/
|
|
81
|
+
export declare const McpProtocolError: {
|
|
82
|
+
/**
|
|
83
|
+
* Les en-têtes HTTP contredisent le corps, ou un en-tête requis manque.
|
|
84
|
+
* La spec impose `400` **et** ce code (`streamable-http` §Server Validation).
|
|
85
|
+
*/
|
|
86
|
+
readonly HEADER_MISMATCH: -32020;
|
|
87
|
+
/**
|
|
88
|
+
* La révision demandée n'est pas servie. La réponse **doit** lister celles
|
|
89
|
+
* qu'on sert, sans quoi le client n'a rien pour choisir.
|
|
90
|
+
*/
|
|
91
|
+
readonly UNSUPPORTED_PROTOCOL_VERSION: -32022;
|
|
92
|
+
};
|
|
93
|
+
/** Identifiant d'une requête JSON-RPC (jamais `null` pour une requête). */
|
|
94
|
+
export type JsonRpcId = string | number;
|
|
95
|
+
/** Message entrant : requête (avec `id`) ou notification (sans `id`). */
|
|
96
|
+
export interface IJsonRpcMessage {
|
|
97
|
+
jsonrpc?: unknown;
|
|
98
|
+
id?: unknown;
|
|
99
|
+
method?: unknown;
|
|
100
|
+
params?: unknown;
|
|
101
|
+
}
|
|
102
|
+
/** Réponse JSON-RPC de succès. */
|
|
103
|
+
export interface IJsonRpcSuccess {
|
|
104
|
+
jsonrpc: "2.0";
|
|
105
|
+
id: JsonRpcId;
|
|
106
|
+
result: unknown;
|
|
107
|
+
}
|
|
108
|
+
/** Réponse JSON-RPC d'erreur. */
|
|
109
|
+
export interface IJsonRpcFailure {
|
|
110
|
+
jsonrpc: "2.0";
|
|
111
|
+
/** `null` quand l'erreur survient avant d'avoir pu lire un `id`. */
|
|
112
|
+
id: JsonRpcId | null;
|
|
113
|
+
error: {
|
|
114
|
+
code: number;
|
|
115
|
+
message: string;
|
|
116
|
+
data?: unknown;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Ce que le serveur MCP rend, avant traduction en réponse HTTP. */
|
|
120
|
+
export interface IMcpHttpReply {
|
|
121
|
+
/** Statut HTTP à poser. */
|
|
122
|
+
status: number;
|
|
123
|
+
/**
|
|
124
|
+
* Corps JSON, ou `null` pour un `202 Accepted` sans corps — la spec l'exige
|
|
125
|
+
* pour une notification acceptée.
|
|
126
|
+
*/
|
|
127
|
+
body: IJsonRpcSuccess | IJsonRpcFailure | null;
|
|
128
|
+
}
|
|
129
|
+
/** Fabrique une réponse de succès. */
|
|
130
|
+
export declare function jsonRpcSuccess(id: JsonRpcId, result: unknown): IJsonRpcSuccess;
|
|
131
|
+
/** Fabrique une réponse d'erreur. */
|
|
132
|
+
export declare function jsonRpcFailure(id: JsonRpcId | null, code: number, message: string, data?: unknown): IJsonRpcFailure;
|
|
133
|
+
/**
|
|
134
|
+
* Un message est-il une NOTIFICATION (pas d'`id`) plutôt qu'une requête ?
|
|
135
|
+
*
|
|
136
|
+
* La distinction commande le statut HTTP : une notification acceptée rend
|
|
137
|
+
* `202` **sans corps**, une requête rend son objet JSON.
|
|
138
|
+
*/
|
|
139
|
+
export declare function isNotification(message: IJsonRpcMessage): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type IJsonRpcMessage, type IMcpHttpReply } from "./protocol.js";
|
|
2
|
+
import { type IMcpToolDeps } from "./tools.js";
|
|
3
|
+
/**
|
|
4
|
+
* Cœur du serveur MCP : un message JSON-RPC entre, une réponse HTTP sort.
|
|
5
|
+
*
|
|
6
|
+
* **Fonction pure** — elle ne touche ni au socket, ni au conteneur, ni à
|
|
7
|
+
* l'horloge. C'est ce qui permet d'éprouver le protocole entier (statuts
|
|
8
|
+
* compris) sans démarrer de serveur, et c'est aussi ce qui rend le transport
|
|
9
|
+
* interchangeable : le jour où un transport `stdio` serait nécessaire pour
|
|
10
|
+
* répondre application éteinte, il appellerait cette même fonction.
|
|
11
|
+
*
|
|
12
|
+
* ## Ce que la révision 2026-07-28 change, et pourquoi c'est ce qui rend cette
|
|
13
|
+
* porte viable
|
|
14
|
+
*
|
|
15
|
+
* Les sessions de niveau protocole ont disparu. Rien n'est retenu entre deux
|
|
16
|
+
* appels : chaque `POST` porte tout ce qu'il faut pour être servi. Un
|
|
17
|
+
* redémarrage du serveur de développement — celui que le superviseur déclenche
|
|
18
|
+
* à chaque fichier sauvegardé — ne casse donc aucun état, et la réponse
|
|
19
|
+
* suivante vient du code qui vient d'être rechargé. Aucun cache à invalider,
|
|
20
|
+
* jamais : la fraîcheur est une propriété du protocole, pas une discipline.
|
|
21
|
+
*/
|
|
22
|
+
/** Ce que ce serveur sait faire, annoncé à l'initialisation. */
|
|
23
|
+
interface IServerInfo {
|
|
24
|
+
name: string;
|
|
25
|
+
version: string;
|
|
26
|
+
}
|
|
27
|
+
/** Tout ce dont le traitement d'un message a besoin. */
|
|
28
|
+
export interface IMcpServerContext extends IMcpToolDeps {
|
|
29
|
+
/** Allowlist des outils (`devkit.mcp.tools`). */
|
|
30
|
+
tools: readonly string[];
|
|
31
|
+
/** Identité annoncée au client. */
|
|
32
|
+
serverInfo: IServerInfo;
|
|
33
|
+
}
|
|
34
|
+
/** En-têtes HTTP dont le protocole se sert. */
|
|
35
|
+
export interface IMcpHeaders {
|
|
36
|
+
/** `MCP-Protocol-Version`, absent chez un client de l'ère legacy. */
|
|
37
|
+
protocolVersion?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Traite UN message JSON-RPC.
|
|
41
|
+
*
|
|
42
|
+
* @param message - corps du `POST`, déjà parsé
|
|
43
|
+
* @param context - outils autorisés et briques qui répondent
|
|
44
|
+
* @param headers - en-têtes du transport (`MCP-Protocol-Version`)
|
|
45
|
+
* @returns statut HTTP et corps à écrire (corps `null` = `202` sans contenu)
|
|
46
|
+
*/
|
|
47
|
+
export declare function handleMcpMessage(message: IJsonRpcMessage, context: IMcpServerContext, headers?: IMcpHeaders): Promise<IMcpHttpReply>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type IAdminBrokerLike } from "nodefony";
|
|
2
|
+
/**
|
|
3
|
+
* Catalogue des outils MCP, et leur exécution.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **Rien n'est calculé ici.** Chaque outil traduit un appel JSON-RPC vers une
|
|
6
|
+
* brique qui répond DÉJÀ à une autre porte : `inspect` lit le plan
|
|
7
|
+
* d'administration par `readAdminSubject` (la même fonction que la commande
|
|
8
|
+
* `nodefony inspect`), `card` appelle le service du module (la même que la route
|
|
9
|
+
* HTTP). Une source, plusieurs portes — un outil qui recalculerait sa réponse
|
|
10
|
+
* finirait par contredire la commande, et c'est lui qu'on croirait sur parole.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ **La description d'un outil est le premier critère de son déclenchement.**
|
|
13
|
+
* Un modèle n'appelle pas ce qu'il ne comprend pas : le POC de 2026-05 l'a payé
|
|
14
|
+
* cash — un outil à description neutre n'a jamais été appelé, un skill
|
|
15
|
+
* auto-déclenché prenait la main à chaque fois. Ces descriptions disent donc ce
|
|
16
|
+
* que l'outil rend ET quand s'en servir, pas seulement son nom.
|
|
17
|
+
*/
|
|
18
|
+
/** Ce dont les outils ont besoin pour répondre — injecté, jamais lu ici. */
|
|
19
|
+
export interface IMcpToolDeps {
|
|
20
|
+
/** Le service `adminBroker` du conteneur, ou `undefined` s'il manque. */
|
|
21
|
+
broker: IAdminBrokerLike | undefined;
|
|
22
|
+
/** Compose la carte de visite de l'application. */
|
|
23
|
+
getCard: () => unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Racine depuis laquelle lire le disque (graphe symbolique, diagnostic).
|
|
26
|
+
*
|
|
27
|
+
* Injectée plutôt que lue par `process.cwd()` : le serveur répond dans le
|
|
28
|
+
* process de l'application, dont le dossier courant n'est pas garanti être
|
|
29
|
+
* celui du projet — et un outil qui diagnostiquerait le mauvais dossier
|
|
30
|
+
* conclurait « rien à signaler » avec aplomb.
|
|
31
|
+
*/
|
|
32
|
+
projectRoot: string;
|
|
33
|
+
}
|
|
34
|
+
/** Un outil tel que `tools/list` le publie. */
|
|
35
|
+
export interface IMcpToolDefinition {
|
|
36
|
+
name: string;
|
|
37
|
+
description: string;
|
|
38
|
+
inputSchema: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
/** Ce que rend `tools/call` — du contenu, et l'aveu d'un échec métier. */
|
|
41
|
+
export interface IMcpToolResult {
|
|
42
|
+
content: {
|
|
43
|
+
type: "text";
|
|
44
|
+
text: string;
|
|
45
|
+
}[];
|
|
46
|
+
isError?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Outils publiés, filtrés par l'allowlist de configuration.
|
|
50
|
+
*
|
|
51
|
+
* Une clé inconnue dans la configuration est simplement ignorée : elle ne peut
|
|
52
|
+
* rien ouvrir. C'est le sens d'une allowlist — ce qui n'est pas nommé ICI
|
|
53
|
+
* n'existe pas, et la faute de frappe d'un utilisateur ne peut pas activer
|
|
54
|
+
* autre chose que ce qu'il voulait.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **`Object.hasOwn` n'est pas une précaution de style.** Sans lui,
|
|
57
|
+
* `tools: ["toString"]` résolvait une méthode héritée d'`Object.prototype` :
|
|
58
|
+
* la valeur n'étant pas `undefined`, elle franchissait le filtre et un outil
|
|
59
|
+
* fantôme entrait dans le catalogue publié. Trouvé par le test, pas à la
|
|
60
|
+
* relecture.
|
|
61
|
+
*
|
|
62
|
+
* @param enabled - clés d'outils autorisées (`devkit.mcp.tools`)
|
|
63
|
+
*/
|
|
64
|
+
export declare function listMcpTools(enabled: readonly string[]): IMcpToolDefinition[];
|
|
65
|
+
/**
|
|
66
|
+
* Exécute un outil par son nom public.
|
|
67
|
+
*
|
|
68
|
+
* Un échec métier (sujet inconnu, module absent) rend un résultat `isError`,
|
|
69
|
+
* **pas** une erreur JSON-RPC : le protocole réserve celle-ci aux fautes de
|
|
70
|
+
* protocole. La distinction compte pour l'agent — une erreur de protocole
|
|
71
|
+
* signifie « tu t'y prends mal », un `isError` signifie « ta demande est
|
|
72
|
+
* recevable, voici pourquoi elle n'aboutit pas » ; c'est la seconde qu'il peut
|
|
73
|
+
* corriger seul.
|
|
74
|
+
*
|
|
75
|
+
* @param name - nom public de l'outil (`nodefony_inspect`…)
|
|
76
|
+
* @param args - arguments fournis par l'agent
|
|
77
|
+
* @param enabled - allowlist de configuration
|
|
78
|
+
* @param deps - briques qui répondent réellement
|
|
79
|
+
* @returns le résultat de l'outil, ou `null` si le nom n'est pas exposé
|
|
80
|
+
*/
|
|
81
|
+
export declare function callMcpTool(name: string, args: Record<string, unknown>, enabled: readonly string[], deps: IMcpToolDeps): Promise<IMcpToolResult | null>;
|