@nodefony/devkit 10.0.0-alpha.1 → 10.0.0-alpha.2
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/package.json +7 -7
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +0 -9
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +0 -6
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +0 -8
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +0 -9
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +0 -6
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +0 -8
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +0 -9
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +0 -6
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +0 -8
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +0 -9
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +0 -6
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +0 -8
- package/dist/nodefony/command/CardCommand.js +0 -70
- package/dist/nodefony/controllers/OAuthMetadataController.js +0 -89
- package/dist/nodefony/src/mcp/guard.js +0 -51
- package/dist/nodefony/src/mcp/protocol.js +0 -127
- package/dist/nodefony/src/mcp/server.js +0 -133
- package/dist/nodefony/src/mcp/tools.js +0 -163
- package/dist/types/nodefony/command/CardCommand.d.ts +0 -33
- package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +0 -39
- package/dist/types/nodefony/src/mcp/guard.d.ts +0 -66
- package/dist/types/nodefony/src/mcp/protocol.d.ts +0 -139
- package/dist/types/nodefony/src/mcp/server.d.ts +0 -48
- package/dist/types/nodefony/src/mcp/tools.d.ts +0 -81
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/devkit",
|
|
3
|
-
"version": "10.0.0-alpha.
|
|
3
|
+
"version": "10.0.0-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Outillage de développement d'une application Nodefony : carte de visite du projet et portes de découverte pour un agent de développement",
|
|
6
6
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
@@ -25,17 +25,17 @@
|
|
|
25
25
|
"coverage": "vitest run --coverage"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@nodefony/framework": "
|
|
29
|
-
"@nodefony/http": "
|
|
30
|
-
"nodefony": "
|
|
28
|
+
"@nodefony/framework": "^10.0.0-alpha.2",
|
|
29
|
+
"@nodefony/http": "^10.0.0-alpha.2",
|
|
30
|
+
"nodefony": "^10.0.0-alpha.2",
|
|
31
31
|
"zod": "^4.4.3",
|
|
32
32
|
"playwright": "^1.50.0",
|
|
33
33
|
"lighthouse": "^13.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@nodefony/framework": "
|
|
37
|
-
"@nodefony/http": "
|
|
38
|
-
"nodefony": "
|
|
36
|
+
"@nodefony/framework": "^10.0.0-alpha.2",
|
|
37
|
+
"@nodefony/http": "^10.0.0-alpha.2",
|
|
38
|
+
"nodefony": "^10.0.0-alpha.2"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"axe-core": "4.13.0",
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.142.0/helpers/esm/decorate.js
|
|
2
|
-
function __decorate(decorators, target, key, desc) {
|
|
3
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
-
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
-
}
|
|
8
|
-
//#endregion
|
|
9
|
-
export { __decorate as default };
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.142.0/helpers/esm/decorateMetadata.js
|
|
2
|
-
function __decorateMetadata(k, v) {
|
|
3
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
|
-
}
|
|
5
|
-
//#endregion
|
|
6
|
-
export { __decorateMetadata as default };
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/decorate.js
|
|
2
|
-
function __decorate(decorators, target, key, desc) {
|
|
3
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
-
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
-
}
|
|
8
|
-
//#endregion
|
|
9
|
-
export { __decorate as default };
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/decorateMetadata.js
|
|
2
|
-
function __decorateMetadata(k, v) {
|
|
3
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
|
-
}
|
|
5
|
-
//#endregion
|
|
6
|
-
export { __decorateMetadata as default };
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.146.0/helpers/esm/decorate.js
|
|
2
|
-
function __decorate(decorators, target, key, desc) {
|
|
3
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
-
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
-
}
|
|
8
|
-
//#endregion
|
|
9
|
-
export { __decorate as default };
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.146.0/helpers/esm/decorateMetadata.js
|
|
2
|
-
function __decorateMetadata(k, v) {
|
|
3
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
|
-
}
|
|
5
|
-
//#endregion
|
|
6
|
-
export { __decorateMetadata as default };
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.147.0/helpers/esm/decorate.js
|
|
2
|
-
function __decorate(decorators, target, key, desc) {
|
|
3
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
-
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
-
}
|
|
8
|
-
//#endregion
|
|
9
|
-
export { __decorate as default };
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.147.0/helpers/esm/decorateMetadata.js
|
|
2
|
-
function __decorateMetadata(k, v) {
|
|
3
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
|
-
}
|
|
5
|
-
//#endregion
|
|
6
|
-
export { __decorateMetadata as default };
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { Command } from "nodefony";
|
|
2
|
-
//#region nodefony/command/CardCommand.ts
|
|
3
|
-
/**
|
|
4
|
-
* `onReady` : les services sont construits, AUCUN serveur n'écoute. La carte se
|
|
5
|
-
* lit dans l'état du kernel — ouvrir un port pour la rendre serait payer un
|
|
6
|
-
* démarrage complet pour une réponse qui n'en dépend pas.
|
|
7
|
-
*/
|
|
8
|
-
const options = {
|
|
9
|
-
showBanner: false,
|
|
10
|
-
kernelEvent: "onReady"
|
|
11
|
-
};
|
|
12
|
-
/**
|
|
13
|
-
* `nodefony devkit:card [-j]` — qui répond, et où aller ensuite.
|
|
14
|
-
*
|
|
15
|
-
* ## Pourquoi une commande, alors que la route existe
|
|
16
|
-
*
|
|
17
|
-
* La route HTTP vit sous `/nodefony`, que le pare-feu d'une application réelle
|
|
18
|
-
* couvre : un agent qui code ne s'authentifie pas, et n'a pas de navigateur. La
|
|
19
|
-
* porte qu'il a déjà, c'est le terminal. Même source (le service), deux rendus —
|
|
20
|
-
* ajouter une porte n'ajoute jamais une vérité.
|
|
21
|
-
*
|
|
22
|
-
* ⚠️ Le module est `policy: "dev"` : hors développement il n'est pas chargé, donc
|
|
23
|
-
* cette commande **n'existe pas**. C'est voulu — et c'est pour ça qu'elle
|
|
24
|
-
* s'invoque `NODE_ENV=development npx nodefony devkit:card` depuis un terminal
|
|
25
|
-
* qui n'aurait pas posé la variable.
|
|
26
|
-
*/
|
|
27
|
-
var CardCommand = class CardCommand extends Command {
|
|
28
|
-
constructor(cli) {
|
|
29
|
-
super("devkit:card", "Imprime la carte de visite de l application", cli, options);
|
|
30
|
-
this.addOption("-j, --json", "sortie JSON brute (scriptable, `| jq`)");
|
|
31
|
-
}
|
|
32
|
-
async generate(opts) {
|
|
33
|
-
const svc = this.kernel?.container?.get("devkit");
|
|
34
|
-
if (!svc) {
|
|
35
|
-
this.log("service « devkit » non enregistré — module non chargé (policy dev) ?", "ERROR");
|
|
36
|
-
return this;
|
|
37
|
-
}
|
|
38
|
-
const card = svc.getCard();
|
|
39
|
-
if (opts.json) {
|
|
40
|
-
process.stdout.write(`${JSON.stringify(card, null, 2)}\n`);
|
|
41
|
-
return this;
|
|
42
|
-
}
|
|
43
|
-
process.stdout.write(CardCommand.format(card));
|
|
44
|
-
return this;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Rend la carte pour un HUMAIN (ou un agent qui lit un terminal).
|
|
48
|
-
*
|
|
49
|
-
* Statique et PURE : elle ne touche ni au kernel ni au service, donc elle
|
|
50
|
-
* s'éprouve seule. Sortie sur `stdout` plutôt que par le journal — une carte
|
|
51
|
-
* de visite n'est pas un événement de log, et le préfixe horodaté rendrait le
|
|
52
|
-
* copier-coller inutilisable.
|
|
53
|
-
*/
|
|
54
|
-
static format(card) {
|
|
55
|
-
return [
|
|
56
|
-
`${card.app.name} ${card.app.version} — ${card.app.environment} (nodefony ${card.nodefony.version})`,
|
|
57
|
-
"",
|
|
58
|
-
`Modules chargés (${card.modules.length}) : ${card.modules.join(", ")}`,
|
|
59
|
-
"",
|
|
60
|
-
"Où aller :",
|
|
61
|
-
...card.portes.map((p) => ` ${p.ou}\n ${p.titre} — ${p.pourquoi}`),
|
|
62
|
-
"",
|
|
63
|
-
"Quoi lancer :",
|
|
64
|
-
...card.verbes.map((v) => ` ${v.commande}\n ${v.pourquoi}`),
|
|
65
|
-
""
|
|
66
|
-
].join("\n");
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
//#endregion
|
|
70
|
-
export { CardCommand as default };
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js";
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js";
|
|
3
|
-
import { MCP_ENDPOINT_PATH, buildProtectedResourceMetadata, protectedResourceMetadataPath } from "nodefony";
|
|
4
|
-
import { Controller, controller, route } from "@nodefony/framework";
|
|
5
|
-
//#region nodefony/controllers/OAuthMetadataController.ts
|
|
6
|
-
/**
|
|
7
|
-
* Chemin bien connu où se publient les métadonnées de la porte MCP.
|
|
8
|
-
*
|
|
9
|
-
* **Dérivé, jamais recopié** : il se compose du chemin de l'endpoint par la
|
|
10
|
-
* règle d'insertion de la RFC 9728 §3.1. Le littéral qu'on serait tenté
|
|
11
|
-
* d'écrire ici deviendrait faux le jour où la porte déménage — et l'erreur
|
|
12
|
-
* serait parfaitement silencieuse : un client sonde ce chemin, reçoit `404`,
|
|
13
|
-
* et conclut que l'application n'a pas d'autorisation.
|
|
14
|
-
*/
|
|
15
|
-
const METADATA_PATH = protectedResourceMetadataPath(MCP_ENDPOINT_PATH);
|
|
16
|
-
/**
|
|
17
|
-
* Métadonnées OAuth 2.1 de la porte MCP — **le seul moyen normalisé** pour
|
|
18
|
-
* qu'un agent apprenne où obtenir un jeton.
|
|
19
|
-
*
|
|
20
|
-
* ## Pourquoi un controller séparé
|
|
21
|
-
*
|
|
22
|
-
* Trois raisons, et aucune n'est esthétique. Le chemin vit **hors** du préfixe
|
|
23
|
-
* `/nodefony` (la RFC impose `/.well-known/…` juste après l'hôte). Le document
|
|
24
|
-
* est **public par conception** — un client doit pouvoir le lire avant d'avoir
|
|
25
|
-
* le moindre jeton, donc il ne porte ni garde d'origine ni exigence de
|
|
26
|
-
* localité, contrairement à la porte elle-même. Et il répond en `GET`, quand la
|
|
27
|
-
* porte n'accepte que `POST`.
|
|
28
|
-
*
|
|
29
|
-
* ## Ce que publier coûte, et ce que ne rien publier coûte
|
|
30
|
-
*
|
|
31
|
-
* Le document ne révèle rien de sensible : l'émetteur des jetons, les scopes
|
|
32
|
-
* compris, un nom. En revanche, ne PAS le publier a un coût mesuré — un client
|
|
33
|
-
* qui ne le trouve pas suppose que le serveur d'autorisation est le serveur
|
|
34
|
-
* lui-même, et part sonder des chemins qui n'existent pas. C'est exactement ce
|
|
35
|
-
* qui a produit le bruit OAuth observé sur cette porte.
|
|
36
|
-
*
|
|
37
|
-
* 🔴 **Rôle éteint = `404`, jamais un document vide.** Un document sans serveur
|
|
38
|
-
* d'autorisation apprendrait au client qu'un jeton est nécessaire sans jamais
|
|
39
|
-
* lui dire où le demander : la spécification MCP l'interdit d'ailleurs
|
|
40
|
-
* explicitement (« MUST include […] at least one authorization server »).
|
|
41
|
-
*/
|
|
42
|
-
let OAuthMetadataController = class OAuthMetadataController extends Controller {
|
|
43
|
-
constructor(context) {
|
|
44
|
-
super("devkit-oauth-metadata", context);
|
|
45
|
-
}
|
|
46
|
-
/** Résout le service du module depuis le conteneur partagé. */
|
|
47
|
-
#service() {
|
|
48
|
-
const svc = this.get("devkit");
|
|
49
|
-
if (!svc) throw new Error("DevkitService non enregistré");
|
|
50
|
-
return svc;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* `GET /.well-known/oauth-protected-resource/nodefony/mcp` — RFC 9728 §3.
|
|
54
|
-
*
|
|
55
|
-
* @returns le document JSON, ou `404` si cette porte n'est pas protégée
|
|
56
|
-
*/
|
|
57
|
-
async metadata() {
|
|
58
|
-
const settings = this.#service().mcpSettings();
|
|
59
|
-
const authz = settings.authorization;
|
|
60
|
-
if (!settings.enabled || authz.authorizationServers.length === 0) return this.renderJson({ error: "not_found" }, 404);
|
|
61
|
-
let document;
|
|
62
|
-
try {
|
|
63
|
-
document = buildProtectedResourceMetadata({
|
|
64
|
-
resource: authz.resource,
|
|
65
|
-
authorizationServers: authz.authorizationServers,
|
|
66
|
-
scopesSupported: authz.scopesSupported,
|
|
67
|
-
resourceName: authz.resourceName,
|
|
68
|
-
resourceDocumentation: authz.resourceDocumentation
|
|
69
|
-
});
|
|
70
|
-
} catch (error) {
|
|
71
|
-
this.log(`MCP — métadonnées impubliables : ${error.message}`, "CRITIC");
|
|
72
|
-
return this.renderJson({ error: "server_error" }, 500);
|
|
73
|
-
}
|
|
74
|
-
return this.renderJson(document, 200, { "Cache-Control": "public, max-age=3600" });
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
__decorate([
|
|
78
|
-
route("devkit-mcp-protected-resource", {
|
|
79
|
-
path: METADATA_PATH,
|
|
80
|
-
method: "GET"
|
|
81
|
-
}),
|
|
82
|
-
__decorateMetadata("design:type", Function),
|
|
83
|
-
__decorateMetadata("design:paramtypes", []),
|
|
84
|
-
__decorateMetadata("design:returntype", Promise)
|
|
85
|
-
], OAuthMetadataController.prototype, "metadata", null);
|
|
86
|
-
OAuthMetadataController = __decorate([controller(""), __decorateMetadata("design:paramtypes", [Object])], OAuthMetadataController);
|
|
87
|
-
var OAuthMetadataController_default = OAuthMetadataController;
|
|
88
|
-
//#endregion
|
|
89
|
-
export { OAuthMetadataController_default as default };
|
|
@@ -1,51 +0,0 @@
|
|
|
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 };
|
|
@@ -1,127 +0,0 @@
|
|
|
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 };
|
|
@@ -1,133 +0,0 @@
|
|
|
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 };
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { INSPECT_SUBJECTS, collectCheckReport, countCheckFindings, lookupSymbol, readAdminSubject, readSymbolsGraph } from "nodefony";
|
|
2
|
-
//#region nodefony/src/mcp/tools.ts
|
|
3
|
-
/** Préfixe des noms d'outils — un agent voit à qui il parle. */
|
|
4
|
-
const PREFIX = "nodefony_";
|
|
5
|
-
/**
|
|
6
|
-
* Liste des sujets, rendue lisible pour la description de l'outil.
|
|
7
|
-
*
|
|
8
|
-
* Dérivée de {@link INSPECT_SUBJECTS} : ajouter un sujet au cœur le publie ici
|
|
9
|
-
* sans rien réécrire, et il ne peut pas exister de sujet annoncé qu'on ne
|
|
10
|
-
* saurait pas lire.
|
|
11
|
-
*/
|
|
12
|
-
function subjectLines() {
|
|
13
|
-
return Object.entries(INSPECT_SUBJECTS).map(([key, spec]) => `- \`${key}\` : ${spec.summary}`).join("\n");
|
|
14
|
-
}
|
|
15
|
-
/** Catalogue complet, avant filtrage par l'allowlist de configuration. */
|
|
16
|
-
function catalogue() {
|
|
17
|
-
return {
|
|
18
|
-
inspect: {
|
|
19
|
-
name: `${PREFIX}inspect`,
|
|
20
|
-
description: `Lit l'état RÉEL de cette application Nodefony : les routes réellement montées, les modules chargés, les services enregistrés, la configuration effective et la provenance de chaque valeur, les stores de données, les entités de l'ORM. À utiliser avant d'écrire du code qui suppose une route, un service ou une clé de configuration — la réponse vient de l'application qui tourne, pas d'une lecture des sources, donc elle ne peut pas se tromper sur ce qui est chargé.
|
|
21
|
-
|
|
22
|
-
Sujets disponibles :\n${subjectLines()}`,
|
|
23
|
-
inputSchema: {
|
|
24
|
-
type: "object",
|
|
25
|
-
properties: {
|
|
26
|
-
subject: {
|
|
27
|
-
type: "string",
|
|
28
|
-
enum: Object.keys(INSPECT_SUBJECTS),
|
|
29
|
-
description: "Ce qu'on veut voir"
|
|
30
|
-
},
|
|
31
|
-
target: {
|
|
32
|
-
type: "string",
|
|
33
|
-
description: "Paramètre du sujet, quand il en attend un (ex. le nom d'un module pour `module`)"
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
required: ["subject"]
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
card: {
|
|
40
|
-
name: `${PREFIX}card`,
|
|
41
|
-
description: "Carte de visite de l'application : son identité, les modules chargés, où trouver la documentation, et les commandes à lancer. À utiliser en ARRIVANT sur une application inconnue, avant toute autre exploration — elle dit en un appel ce qu'il y a et où aller ensuite.",
|
|
42
|
-
inputSchema: {
|
|
43
|
-
type: "object",
|
|
44
|
-
properties: {}
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
check: {
|
|
48
|
-
name: `${PREFIX}check`,
|
|
49
|
-
description: "Diagnostic STATIQUE de l'application : classes écrites que rien n'enregistre (entité, controller ou service jamais câblé), paquets importés sans être déclarés, variables d'environnement requises absentes, modules du manifeste non installés, ports occupés, et le bilan du dernier démarrage. À utiliser APRÈS avoir écrit ou généré du code, avant de conclure que c'est fini : ni la compilation ni les tests ne voient qu'une classe n'est branchée à rien.",
|
|
50
|
-
inputSchema: {
|
|
51
|
-
type: "object",
|
|
52
|
-
properties: {}
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
symbols: {
|
|
56
|
-
name: `${PREFIX}symbols`,
|
|
57
|
-
description: "Interroge le graphe symbolique du framework : ce qu'est un symbole (classe, interface, fonction), où il est défini, ce qu'il étend ou implémente, et la première phrase de sa documentation. À utiliser AVANT d'ouvrir un fichier pour comprendre une API du framework — la réponse est immédiate et vaut pour la version réellement installée. Sans argument, rend le résumé du graphe ; avec `module`, la surface exportée d'un paquet.",
|
|
58
|
-
inputSchema: {
|
|
59
|
-
type: "object",
|
|
60
|
-
properties: {
|
|
61
|
-
name: {
|
|
62
|
-
type: "string",
|
|
63
|
-
description: "Nom exact du symbole (ex. `AbstractCrudService`, `IKernel`)"
|
|
64
|
-
},
|
|
65
|
-
module: {
|
|
66
|
-
type: "string",
|
|
67
|
-
description: "Paquet dont on veut la surface exportée (ex. `@nodefony/http`)"
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Outils publiés, filtrés par l'allowlist de configuration.
|
|
76
|
-
*
|
|
77
|
-
* Une clé inconnue dans la configuration est simplement ignorée : elle ne peut
|
|
78
|
-
* rien ouvrir. C'est le sens d'une allowlist — ce qui n'est pas nommé ICI
|
|
79
|
-
* n'existe pas, et la faute de frappe d'un utilisateur ne peut pas activer
|
|
80
|
-
* autre chose que ce qu'il voulait.
|
|
81
|
-
*
|
|
82
|
-
* ⚠️ **`Object.hasOwn` n'est pas une précaution de style.** Sans lui,
|
|
83
|
-
* `tools: ["toString"]` résolvait une méthode héritée d'`Object.prototype` :
|
|
84
|
-
* la valeur n'étant pas `undefined`, elle franchissait le filtre et un outil
|
|
85
|
-
* fantôme entrait dans le catalogue publié. Trouvé par le test, pas à la
|
|
86
|
-
* relecture.
|
|
87
|
-
*
|
|
88
|
-
* @param enabled - clés d'outils autorisées (`devkit.mcp.tools`)
|
|
89
|
-
*/
|
|
90
|
-
function listMcpTools(enabled) {
|
|
91
|
-
const all = catalogue();
|
|
92
|
-
return enabled.filter((key) => Object.hasOwn(all, key)).map((key) => all[key]).filter((tool) => tool !== void 0);
|
|
93
|
-
}
|
|
94
|
-
/** Rend un contenu textuel — les données partent en JSON indenté, lisible. */
|
|
95
|
-
function text(value, isError = false) {
|
|
96
|
-
const rendered = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
97
|
-
return isError ? {
|
|
98
|
-
content: [{
|
|
99
|
-
type: "text",
|
|
100
|
-
text: rendered
|
|
101
|
-
}],
|
|
102
|
-
isError: true
|
|
103
|
-
} : { content: [{
|
|
104
|
-
type: "text",
|
|
105
|
-
text: rendered
|
|
106
|
-
}] };
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Exécute un outil par son nom public.
|
|
110
|
-
*
|
|
111
|
-
* Un échec métier (sujet inconnu, module absent) rend un résultat `isError`,
|
|
112
|
-
* **pas** une erreur JSON-RPC : le protocole réserve celle-ci aux fautes de
|
|
113
|
-
* protocole. La distinction compte pour l'agent — une erreur de protocole
|
|
114
|
-
* signifie « tu t'y prends mal », un `isError` signifie « ta demande est
|
|
115
|
-
* recevable, voici pourquoi elle n'aboutit pas » ; c'est la seconde qu'il peut
|
|
116
|
-
* corriger seul.
|
|
117
|
-
*
|
|
118
|
-
* @param name - nom public de l'outil (`nodefony_inspect`…)
|
|
119
|
-
* @param args - arguments fournis par l'agent
|
|
120
|
-
* @param enabled - allowlist de configuration
|
|
121
|
-
* @param deps - briques qui répondent réellement
|
|
122
|
-
* @returns le résultat de l'outil, ou `null` si le nom n'est pas exposé
|
|
123
|
-
*/
|
|
124
|
-
async function callMcpTool(name, args, enabled, deps) {
|
|
125
|
-
if (!listMcpTools(enabled).find((tool) => tool.name === name)) return null;
|
|
126
|
-
if (name === `${PREFIX}card`) return text(deps.getCard());
|
|
127
|
-
if (name === `${PREFIX}inspect`) {
|
|
128
|
-
const subject = typeof args.subject === "string" ? args.subject : "";
|
|
129
|
-
const target = typeof args.target === "string" ? args.target : void 0;
|
|
130
|
-
const read = await readAdminSubject(deps.broker, subject, target);
|
|
131
|
-
if (!read.ok) return text(read.message, true);
|
|
132
|
-
return text(read.data);
|
|
133
|
-
}
|
|
134
|
-
if (name === `${PREFIX}check`) {
|
|
135
|
-
const report = await collectCheckReport(deps.projectRoot);
|
|
136
|
-
return text({
|
|
137
|
-
verdict: countCheckFindings(report) === 0 ? "ok" : "manquements",
|
|
138
|
-
total: countCheckFindings(report),
|
|
139
|
-
...report
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
if (name === `${PREFIX}symbols`) {
|
|
143
|
-
const graph = readSymbolsGraph(deps.projectRoot);
|
|
144
|
-
if (graph === null) return text("aucun graphe symbolique atteignable — il est publié par le paquet `nodefony` (node_modules/nodefony/.ai/symbols.json) et régénérable dans ce dépôt par `npm run generate-symbols`", true);
|
|
145
|
-
const wanted = typeof args.name === "string" ? args.name : null;
|
|
146
|
-
const module = typeof args.module === "string" ? args.module : null;
|
|
147
|
-
if (wanted !== null) {
|
|
148
|
-
const sym = lookupSymbol(graph, wanted);
|
|
149
|
-
return sym ? text(sym) : text(`« ${wanted} » est introuvable dans le graphe (${Object.keys(graph.symbols).length} symboles)`, true);
|
|
150
|
-
}
|
|
151
|
-
const entries = Object.values(graph.symbols).filter((s) => module === null || s.module === module);
|
|
152
|
-
if (module !== null) return text(entries.sort((a, b) => a.name.localeCompare(b.name)));
|
|
153
|
-
const parPaquet = Object.create(null);
|
|
154
|
-
for (const sym of entries) parPaquet[sym.module] = (parPaquet[sym.module] ?? 0) + 1;
|
|
155
|
-
return text({
|
|
156
|
-
total: entries.length,
|
|
157
|
-
parPaquet
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
return text(`outil « ${name} » publié mais non implémenté`, true);
|
|
161
|
-
}
|
|
162
|
-
//#endregion
|
|
163
|
-
export { callMcpTool, listMcpTools };
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { Command, CliKernel } from "nodefony";
|
|
2
|
-
import type { IDevkitCard } from "../interfaces/IDevkitService.js";
|
|
3
|
-
/**
|
|
4
|
-
* `nodefony devkit:card [-j]` — qui répond, et où aller ensuite.
|
|
5
|
-
*
|
|
6
|
-
* ## Pourquoi une commande, alors que la route existe
|
|
7
|
-
*
|
|
8
|
-
* La route HTTP vit sous `/nodefony`, que le pare-feu d'une application réelle
|
|
9
|
-
* couvre : un agent qui code ne s'authentifie pas, et n'a pas de navigateur. La
|
|
10
|
-
* porte qu'il a déjà, c'est le terminal. Même source (le service), deux rendus —
|
|
11
|
-
* ajouter une porte n'ajoute jamais une vérité.
|
|
12
|
-
*
|
|
13
|
-
* ⚠️ Le module est `policy: "dev"` : hors développement il n'est pas chargé, donc
|
|
14
|
-
* cette commande **n'existe pas**. C'est voulu — et c'est pour ça qu'elle
|
|
15
|
-
* s'invoque `NODE_ENV=development npx nodefony devkit:card` depuis un terminal
|
|
16
|
-
* qui n'aurait pas posé la variable.
|
|
17
|
-
*/
|
|
18
|
-
declare class CardCommand extends Command {
|
|
19
|
-
constructor(cli: CliKernel);
|
|
20
|
-
generate(opts: {
|
|
21
|
-
json?: boolean;
|
|
22
|
-
}): Promise<this>;
|
|
23
|
-
/**
|
|
24
|
-
* Rend la carte pour un HUMAIN (ou un agent qui lit un terminal).
|
|
25
|
-
*
|
|
26
|
-
* Statique et PURE : elle ne touche ni au kernel ni au service, donc elle
|
|
27
|
-
* s'éprouve seule. Sortie sur `stdout` plutôt que par le journal — une carte
|
|
28
|
-
* de visite n'est pas un événement de log, et le préfixe horodaté rendrait le
|
|
29
|
-
* copier-coller inutilisable.
|
|
30
|
-
*/
|
|
31
|
-
static format(card: IDevkitCard): string;
|
|
32
|
-
}
|
|
33
|
-
export default CardCommand;
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { Controller } from "@nodefony/framework";
|
|
2
|
-
import type { ContextType } from "@nodefony/http";
|
|
3
|
-
/**
|
|
4
|
-
* Métadonnées OAuth 2.1 de la porte MCP — **le seul moyen normalisé** pour
|
|
5
|
-
* qu'un agent apprenne où obtenir un jeton.
|
|
6
|
-
*
|
|
7
|
-
* ## Pourquoi un controller séparé
|
|
8
|
-
*
|
|
9
|
-
* Trois raisons, et aucune n'est esthétique. Le chemin vit **hors** du préfixe
|
|
10
|
-
* `/nodefony` (la RFC impose `/.well-known/…` juste après l'hôte). Le document
|
|
11
|
-
* est **public par conception** — un client doit pouvoir le lire avant d'avoir
|
|
12
|
-
* le moindre jeton, donc il ne porte ni garde d'origine ni exigence de
|
|
13
|
-
* localité, contrairement à la porte elle-même. Et il répond en `GET`, quand la
|
|
14
|
-
* porte n'accepte que `POST`.
|
|
15
|
-
*
|
|
16
|
-
* ## Ce que publier coûte, et ce que ne rien publier coûte
|
|
17
|
-
*
|
|
18
|
-
* Le document ne révèle rien de sensible : l'émetteur des jetons, les scopes
|
|
19
|
-
* compris, un nom. En revanche, ne PAS le publier a un coût mesuré — un client
|
|
20
|
-
* qui ne le trouve pas suppose que le serveur d'autorisation est le serveur
|
|
21
|
-
* lui-même, et part sonder des chemins qui n'existent pas. C'est exactement ce
|
|
22
|
-
* qui a produit le bruit OAuth observé sur cette porte.
|
|
23
|
-
*
|
|
24
|
-
* 🔴 **Rôle éteint = `404`, jamais un document vide.** Un document sans serveur
|
|
25
|
-
* d'autorisation apprendrait au client qu'un jeton est nécessaire sans jamais
|
|
26
|
-
* lui dire où le demander : la spécification MCP l'interdit d'ailleurs
|
|
27
|
-
* explicitement (« MUST include […] at least one authorization server »).
|
|
28
|
-
*/
|
|
29
|
-
declare class OAuthMetadataController extends Controller {
|
|
30
|
-
#private;
|
|
31
|
-
constructor(context: ContextType);
|
|
32
|
-
/**
|
|
33
|
-
* `GET /.well-known/oauth-protected-resource/nodefony/mcp` — RFC 9728 §3.
|
|
34
|
-
*
|
|
35
|
-
* @returns le document JSON, ou `404` si cette porte n'est pas protégée
|
|
36
|
-
*/
|
|
37
|
-
metadata(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
38
|
-
}
|
|
39
|
-
export default OAuthMetadataController;
|
|
@@ -1,66 +0,0 @@
|
|
|
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;
|
|
@@ -1,139 +0,0 @@
|
|
|
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;
|
|
@@ -1,48 +0,0 @@
|
|
|
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 {};
|
|
@@ -1,81 +0,0 @@
|
|
|
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>;
|