@nodefony/devkit 10.0.0-beta.2 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +24 -11
- package/dist/nodefony/config/config.js +16 -137
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controllers/DevkitController.js +7 -14
- package/dist/nodefony/controllers/McpController.js +27 -163
- package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
- package/dist/nodefony/mcp/observe.js +235 -0
- package/dist/nodefony/mcp/profileReport.js +242 -0
- package/dist/nodefony/mcp/profileRoute.js +521 -0
- package/dist/nodefony/mcp/shotClient.js +84 -0
- package/dist/nodefony/mcp/shutdown.js +44 -0
- package/dist/nodefony/mcp/tools.js +696 -0
- package/dist/nodefony/service/DevkitService.js +93 -29
- package/dist/nodefony/src/card.js +1 -2
- package/dist/nodefony/src/errors/DevkitError.js +1 -2
- package/dist/nodefony/src/profileSession.js +14 -0
- package/dist/types/index.d.ts +15 -1
- package/dist/types/nodefony/config/config.d.ts +1 -1
- package/dist/types/nodefony/controllers/McpController.d.ts +13 -17
- package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
- package/dist/types/nodefony/mcp/observe.d.ts +83 -0
- package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
- package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
- package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
- package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
- package/dist/types/nodefony/mcp/tools.d.ts +109 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
- package/dist/types/nodefony/src/profileSession.d.ts +12 -0
- package/docs/index.md +217 -20
- package/package.json +17 -12
- package/skills/nodefony-add-crud/SKILL.md +5 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
- package/skills/nodefony-add-service/SKILL.md +14 -3
- package/skills/nodefony-authentication/SKILL.md +234 -0
- package/skills/nodefony-browser/SKILL.md +65 -21
- package/skills/nodefony-browser/references/socket.md +13 -4
- package/skills/nodefony-browser/references/sondes.md +13 -0
- package/skills/nodefony-browser/scripts/audit.mjs +59 -10
- package/skills/nodefony-browser/scripts/inspect.mjs +123 -5
- package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
- package/skills/nodefony-browser/scripts/lib/probes.mjs +219 -7
- package/skills/nodefony-browser/scripts/socket.mjs +70 -4
- package/skills/nodefony-browser/scripts/watch.mjs +101 -0
- package/skills/nodefony-dev/SKILL.md +78 -82
- package/skills/nodefony-dev/scripts/docs.mjs +24 -8
- package/skills/nodefony-devops/SKILL.md +98 -74
- package/skills/nodefony-devops/references/compose.md +8 -0
- package/skills/nodefony-devops/references/dimensionnement.md +122 -0
- package/skills/nodefony-devops/references/frontal.md +282 -48
- package/skills/nodefony-devops/references/kubernetes.md +41 -14
- package/skills/nodefony-devops/references/secrets.md +30 -5
- package/skills/nodefony-devops/references/variables.md +66 -31
- package/skills/nodefony-migrate-schema/SKILL.md +5 -2
- package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
- package/skills/nodefony-protect-route/SKILL.md +124 -21
- package/skills/nodefony-security-review/SKILL.md +127 -0
- package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
- package/skills/nodefony-troubleshoot/SKILL.md +225 -0
- package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
- package/skills/nodefony-upgrade/SKILL.md +130 -0
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { observeTool } from "./observe.js";
|
|
2
|
+
import { profileRouteTool } from "./profileRoute.js";
|
|
3
|
+
import { ADMIN_SCOPE_READ, INSPECT_SUBJECTS, adminReadCatalog, callAdminEndpoint, collectDoctorReport, countCheckFindings, findAdminReadEntry, lookupSymbol, outlineMarkdown, readAdminSubject, readSymbolsGraph, skippedChecks } from "nodefony";
|
|
4
|
+
import { MCP_RESERVED_PREFIX, MCP_TEXT_MAX_CHARS, mcpText } from "@nodefony/ai";
|
|
5
|
+
// nodefony/mcp/tools.ts
|
|
6
|
+
/**
|
|
7
|
+
* Outils MCP du framework : le catalogue `nodefony_*`, que `@nodefony/devkit`
|
|
8
|
+
* déclare par `getMcpTools()` comme n'importe quel module d'application.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ **Rien n'est calculé ici.** Chaque outil intégré traduit un appel JSON-RPC
|
|
11
|
+
* vers une brique qui répond DÉJÀ à une autre porte : `inspect` lit le plan
|
|
12
|
+
* d'administration par {@link readAdminSubject} (la même fonction que la
|
|
13
|
+
* commande `nodefony inspect`), `check` appelle {@link collectDoctorReport} (la
|
|
14
|
+
* même que `nodefony doctor`). Une source, plusieurs portes — un outil qui
|
|
15
|
+
* recalculerait sa réponse finirait par contredire la commande, et c'est lui
|
|
16
|
+
* qu'on croirait sur parole.
|
|
17
|
+
*
|
|
18
|
+
* ⭐ **La description d'un outil est le premier critère de son déclenchement.**
|
|
19
|
+
* Un modèle n'appelle pas ce qu'il ne comprend pas : le POC de 2026-05 l'a payé
|
|
20
|
+
* cash — un outil à description neutre n'a jamais été appelé, un skill
|
|
21
|
+
* auto-déclenché prenait la main à chaque fois. Ces descriptions disent donc ce
|
|
22
|
+
* que l'outil rend ET quand s'en servir, pas seulement son nom.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Marge laissée au corps d'un refus. Un producteur qui explique pourquoi il
|
|
26
|
+
* refuse tient en quelques centaines d'octets (un plan de page, une liste de
|
|
27
|
+
* valeurs) ; au-delà, c'est une donnée déguisée en message d'erreur, et une
|
|
28
|
+
* réponse démesurée sature la fenêtre de celui qui la lit.
|
|
29
|
+
*/
|
|
30
|
+
const MCP_ERROR_BODY_MAX_CHARS = 4e3;
|
|
31
|
+
/**
|
|
32
|
+
* Rend un refus du plan d'administration — la phrase ET ce que le producteur a
|
|
33
|
+
* joint pour la corriger.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ Rendre la seule phrase faisait conclure l'inverse de la vérité : demander
|
|
36
|
+
* une section absente d'une page PRÉSENTE produit « … introuvable (404) », et
|
|
37
|
+
* l'agent en déduisait que la page n'existait pas — alors que le producteur
|
|
38
|
+
* joignait le plan de cette page, c'est-à-dire la réponse. Une porte qui résume
|
|
39
|
+
* un refus lui fait dire autre chose.
|
|
40
|
+
*
|
|
41
|
+
* @param read - l'échec rendu par la lecture, corps du producteur inclus.
|
|
42
|
+
* @returns le résultat d'outil, en erreur, borné en volume.
|
|
43
|
+
*/
|
|
44
|
+
function mcpAdminFailure(read) {
|
|
45
|
+
if (read.body === void 0 || read.body === null) return mcpText(read.message, true);
|
|
46
|
+
const rendered = JSON.stringify(read.body, null, 2);
|
|
47
|
+
const body = rendered.length <= MCP_ERROR_BODY_MAX_CHARS ? rendered : `${rendered.slice(0, MCP_ERROR_BODY_MAX_CHARS)}\n… body du refus tronqué (${rendered.length} caractères)`;
|
|
48
|
+
return mcpText(`${read.message}\n\n${body}`, true);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Marge laissée à l'enveloppe JSON d'une page : `frontmatter`, `slug`, guillemets
|
|
52
|
+
* échappés. Sous-estimer ferait retomber la page dans le résumé générique — celui
|
|
53
|
+
* qui rend des CLÉS, c'est-à-dire rien d'utile sur un markdown.
|
|
54
|
+
*/
|
|
55
|
+
const DOC_ENVELOPE_CHARS = 4e3;
|
|
56
|
+
/**
|
|
57
|
+
* Une page de documentation, ou son PLAN quand elle est trop lourde.
|
|
58
|
+
*
|
|
59
|
+
* Le résumé générique ({@link summarize}) sait ramener une liste à son compte et un
|
|
60
|
+
* objet à ses clés ; sur une page de 78 ko il rendrait
|
|
61
|
+
* `keys: [slug, frontmatter, markdown]` et conseillerait « demande une branche
|
|
62
|
+
* précise » — une phrase qui ne désigne aucun geste possible. Le plan, lui, se lit
|
|
63
|
+
* en quelques centaines d'octets et nomme exactement ce qu'il faut redemander.
|
|
64
|
+
*
|
|
65
|
+
* @param data - ce que le producteur a rendu pour la page
|
|
66
|
+
* @param module - module interrogé, pour rédiger le geste suivant
|
|
67
|
+
* @param slug - page interrogée, idem
|
|
68
|
+
*/
|
|
69
|
+
function pageOrOutline(data, module, slug) {
|
|
70
|
+
const page = data;
|
|
71
|
+
const markdown = typeof page?.markdown === "string" ? page.markdown : null;
|
|
72
|
+
if (markdown === null || markdown.length <= MCP_TEXT_MAX_CHARS - DOC_ENVELOPE_CHARS) return data;
|
|
73
|
+
return {
|
|
74
|
+
...data,
|
|
75
|
+
markdown: void 0,
|
|
76
|
+
chars: markdown.length,
|
|
77
|
+
note: `cette page pèse ${markdown.length} caractères — voici son PLAN. Rappelle l'outil avec section: "<titre>" (module: "${module}", slug: "${slug}") pour n'obtenir que le passage utile.`,
|
|
78
|
+
outline: outlineMarkdown(markdown)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Clé de module telle que le kernel la connaît, depuis un nom de package npm.
|
|
83
|
+
*
|
|
84
|
+
* Le graphe symbolique indexe par nom npm (`@nodefony/http`) ; le plan
|
|
85
|
+
* d'administration, lui, désigne les modules par leur clé courte (`http`), et
|
|
86
|
+
* le socle sous le nom logique `core` — son package réel s'appelant `nodefony`
|
|
87
|
+
* pour des raisons d'histoire. Deux vocabulaires pour une même chose : la
|
|
88
|
+
* traduction vit ICI, une fois, plutôt que dans chaque appelant.
|
|
89
|
+
*/
|
|
90
|
+
function moduleKey(packageName) {
|
|
91
|
+
if (packageName === "nodefony" || packageName === "@nodefony/core") return "core";
|
|
92
|
+
return packageName.replace(/^@nodefony\//u, "");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Largeur maximale d'un résumé dans le catalogue d'administration.
|
|
96
|
+
*
|
|
97
|
+
* Le catalogue vaut par sa densité : soixante lignes qu'on parcourt d'un coup
|
|
98
|
+
* d'œil. Un résumé de trois phrases y ferait perdre la colonne de gauche — le
|
|
99
|
+
* chemin, c'est-à-dire la seule chose qu'on recopie ensuite.
|
|
100
|
+
*/
|
|
101
|
+
const CATALOG_SUMMARY_MAX = 96;
|
|
102
|
+
/** Largeur de la colonne des chemins, au-delà de laquelle on cesse d'aligner. */
|
|
103
|
+
const CATALOG_PATH_COLUMN = 34;
|
|
104
|
+
/**
|
|
105
|
+
* Budget du catalogue rendu, sous la borne générique de {@link mcpText}.
|
|
106
|
+
*
|
|
107
|
+
* 🔴 Il ne s'agit pas de politesse : au-delà de sa borne, `mcpText` remplace une
|
|
108
|
+
* CHAÎNE par une note générique — le résumé sait ramener une liste à son compte
|
|
109
|
+
* et un objet à ses clés, mais d'un texte il ne garde rien. Le catalogue
|
|
110
|
+
* disparaîtrait donc en entier, en conseillant « demande une branche précise »,
|
|
111
|
+
* un geste qui ne désigne rien ici. On borne nous-mêmes, en gardant des entrées
|
|
112
|
+
* ET en nommant les deux restrictions qui EXISTENT.
|
|
113
|
+
*/
|
|
114
|
+
const CATALOG_MAX_CHARS = 24e3;
|
|
115
|
+
/**
|
|
116
|
+
* Met le catalogue en forme pour un lecteur qui va s'en servir tout de suite.
|
|
117
|
+
*
|
|
118
|
+
* Texte plutôt que JSON, et ce n'est pas une question de goût : la même
|
|
119
|
+
* information sérialisée en objets indentés pèse environ trois fois plus, pour
|
|
120
|
+
* une porte dont l'unique raison d'être est de ne pas coûter de contexte. Le
|
|
121
|
+
* groupement par producteur porte la seule structure nécessaire — le producteur
|
|
122
|
+
* est le premier argument de l'appel qui suit.
|
|
123
|
+
*
|
|
124
|
+
* @param view - ce que le cœur a retenu, et ce qu'il a écarté.
|
|
125
|
+
* @returns le catalogue rendu, écarts annoncés.
|
|
126
|
+
*/
|
|
127
|
+
function renderAdminCatalog(view) {
|
|
128
|
+
const gaps = [];
|
|
129
|
+
if (view.mutations > 0) gaps.push(`${view.mutations} mutations (POST/PATCH/DELETE — cette porte ne fait que LIRE)`);
|
|
130
|
+
if (view.selfService > 0) gaps.push(`${view.selfService} self-service liés à une session (« me », « sessions/mine ») — cette porte n'a pas d'utilisateur connecté`);
|
|
131
|
+
if (view.denied > 0) gaps.push(`${view.denied} hors des droits de ce jeton`);
|
|
132
|
+
if (view.filtered > 0) gaps.push(`${view.filtered} hors du filtre demandé`);
|
|
133
|
+
const lines = [`${view.entries.length} lectures appelables — sur ${view.total} endpoints déclarés par cette application.`, `Appel : nodefony_admin_call { namespace, path, params?, query? } — recopier « path » TEL QUEL, variables comprises.`];
|
|
134
|
+
if (gaps.length > 0) lines.push(`Non listés : ${gaps.join(" · ")}.`);
|
|
135
|
+
let namespace = null;
|
|
136
|
+
for (const entry of view.entries) {
|
|
137
|
+
if (entry.namespace !== namespace) {
|
|
138
|
+
namespace = entry.namespace;
|
|
139
|
+
const count = view.entries.filter((other) => other.namespace === namespace).length;
|
|
140
|
+
lines.push("", `## ${namespace} (${count})`);
|
|
141
|
+
}
|
|
142
|
+
const summary = entry.summary.length > CATALOG_SUMMARY_MAX ? `${entry.summary.slice(0, 95)}…` : entry.summary;
|
|
143
|
+
const suffix = summary === "" ? "" : ` ${summary}`;
|
|
144
|
+
lines.push(`${entry.path.padEnd(CATALOG_PATH_COLUMN)}${suffix}`.trimEnd());
|
|
145
|
+
const caps = [];
|
|
146
|
+
if (entry.page?.sortable.length) caps.push(`tri: ${entry.page.sortable.join(",")}`);
|
|
147
|
+
if (entry.page?.filters.length) caps.push(`filtres: ${entry.page.filters.join(",")}`);
|
|
148
|
+
if (entry.page?.search) caps.push("recherche q");
|
|
149
|
+
if (caps.length > 0) lines.push(`${" ".repeat(4)}${caps.join(" | ")}`);
|
|
150
|
+
}
|
|
151
|
+
if (view.entries.length === 0) lines.push("", "(aucune entrée ne répond à cette demande)");
|
|
152
|
+
const rendered = lines.join("\n");
|
|
153
|
+
if (rendered.length <= CATALOG_MAX_CHARS) return rendered;
|
|
154
|
+
const cut = rendered.slice(0, CATALOG_MAX_CHARS);
|
|
155
|
+
return `${cut.slice(0, cut.lastIndexOf("\n"))}\n\n… catalogue tronqué (${rendered.length} caractères pour ${view.entries.length} entrées). Rappelle l'outil avec namespace: "<producteur>" ou q: "<termes>" pour voir le reste.`;
|
|
156
|
+
}
|
|
157
|
+
function stringTable(value, field, arrays) {
|
|
158
|
+
if (value === void 0 || value === null) return {
|
|
159
|
+
ok: true,
|
|
160
|
+
value: {}
|
|
161
|
+
};
|
|
162
|
+
if (typeof value !== "object" || Array.isArray(value)) return {
|
|
163
|
+
ok: false,
|
|
164
|
+
why: `« ${field} » doit être un objet { clé: valeur }`
|
|
165
|
+
};
|
|
166
|
+
const table = {};
|
|
167
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
168
|
+
if (typeof raw === "string") {
|
|
169
|
+
table[key] = raw;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (typeof raw === "number" || typeof raw === "boolean") {
|
|
173
|
+
table[key] = String(raw);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (arrays && Array.isArray(raw) && raw.every((item) => typeof item === "string")) {
|
|
177
|
+
table[key] = raw;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
why: `« ${field}.${key} » n'est pas une valeur transportable — attendu du texte${arrays ? " ou une liste de textes" : ""}`
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
value: table
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** Lignes de journal rapportées par requête en erreur. */
|
|
191
|
+
const LOG_LINES_PER_REQUEST = 8;
|
|
192
|
+
/** Lignes lues par requête avant filtrage par sévérité. */
|
|
193
|
+
const LOG_SCAN_PER_REQUEST = 50;
|
|
194
|
+
/** Longueur maximale d'une ligne de journal rapportée. */
|
|
195
|
+
const LOG_LINE_MAX = 300;
|
|
196
|
+
/**
|
|
197
|
+
* Sévérité la plus faible rapportée (RFC 5424 : 4 = WARNING). Le DEBUG d'une
|
|
198
|
+
* requête n'aide pas l'agent à trouver une erreur — et il peut porter ce qui
|
|
199
|
+
* ne doit pas entrer dans son contexte : la valeur d'un cookie de session
|
|
200
|
+
* posé par la réponse, par exemple.
|
|
201
|
+
*/
|
|
202
|
+
const LOG_MAX_SEVERITY = 4;
|
|
203
|
+
/**
|
|
204
|
+
* Une ligne de journal lisible pour l'agent, ou `null` si elle est sous le
|
|
205
|
+
* seuil de sévérité ou illisible. Le texte vit dans `payload` (chaîne, ou
|
|
206
|
+
* erreur) ; `msg` est souvent vide, `msgid` dit le contexte.
|
|
207
|
+
*/
|
|
208
|
+
function logLine(row) {
|
|
209
|
+
if (typeof row !== "object" || row === null) return null;
|
|
210
|
+
const severity = "severity" in row && typeof row.severity === "number" ? row.severity : 7;
|
|
211
|
+
if (severity > LOG_MAX_SEVERITY) return null;
|
|
212
|
+
const name = "severityName" in row && typeof row.severityName === "string" ? row.severityName : String(severity);
|
|
213
|
+
const where = "msgid" in row && typeof row.msgid === "string" && row.msgid.trim() !== "" ? row.msgid.trim() : "moduleName" in row && typeof row.moduleName === "string" ? row.moduleName : "?";
|
|
214
|
+
const payload = "payload" in row ? row.payload : void 0;
|
|
215
|
+
let text = "msg" in row && typeof row.msg === "string" ? row.msg : "";
|
|
216
|
+
if (typeof payload === "string" && payload !== "") text = payload;
|
|
217
|
+
else if (typeof payload === "object" && payload !== null && "message" in payload && typeof payload.message === "string") text = payload.message;
|
|
218
|
+
if (text === "") return null;
|
|
219
|
+
const line = `${name} [${where}] ${text.replace(/\s+/g, " ")}`;
|
|
220
|
+
return line.length > LOG_LINE_MAX ? `${line.slice(0, LOG_LINE_MAX)}…` : line;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Lit le journal d'UNE requête dans le syslog de l'application, par le plan
|
|
224
|
+
* d'administration — la même voie, et la même autorisation, que
|
|
225
|
+
* `nodefony_admin_call { namespace: "syslog", path: "logs" }`. Le driver de
|
|
226
|
+
* relecture actif répond (mémoire par défaut, fichier ou Redis si configuré).
|
|
227
|
+
*
|
|
228
|
+
* @returns les lignes, de la plus ancienne à la plus récente, ou la raison
|
|
229
|
+
* pour laquelle elles ne sont pas lisibles ici.
|
|
230
|
+
*/
|
|
231
|
+
async function readRequestLogs(broker, requestId, caller) {
|
|
232
|
+
const admin = adminCallerFromMcp(caller);
|
|
233
|
+
if (findAdminReadEntry(broker, admin, "syslog", "logs") === null) return {
|
|
234
|
+
kind: "unavailable",
|
|
235
|
+
reason: broker?.list().some((api) => api.adminNamespace === "syslog") === true ? "journal réservé aux appelants authentifiés (scope admin:read) — présenter un jeton MCP : `npx nodefony security:mcp:token --write`" : "aucun journal lisible : le producteur `syslog` du plan d'administration n'est pas chargé (`@nodefony/framework` absent ?)"
|
|
236
|
+
};
|
|
237
|
+
const read = await callAdminEndpoint(broker, {
|
|
238
|
+
namespace: "syslog",
|
|
239
|
+
path: "logs",
|
|
240
|
+
method: "GET",
|
|
241
|
+
params: {},
|
|
242
|
+
query: {
|
|
243
|
+
requestId,
|
|
244
|
+
limit: String(LOG_SCAN_PER_REQUEST)
|
|
245
|
+
},
|
|
246
|
+
label: "syslog/logs"
|
|
247
|
+
}, admin);
|
|
248
|
+
if (!read.ok) return {
|
|
249
|
+
kind: "unavailable",
|
|
250
|
+
reason: read.message
|
|
251
|
+
};
|
|
252
|
+
const lines = [];
|
|
253
|
+
const rows = Array.isArray(read.data) ? read.data : [];
|
|
254
|
+
for (const row of rows) {
|
|
255
|
+
const line = logLine(row);
|
|
256
|
+
if (line !== null && lines.length < LOG_LINES_PER_REQUEST) lines.push(line);
|
|
257
|
+
}
|
|
258
|
+
const hidden = rows.length - lines.length;
|
|
259
|
+
lines.reverse();
|
|
260
|
+
return {
|
|
261
|
+
kind: "lines",
|
|
262
|
+
lines,
|
|
263
|
+
hidden
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Préfixe des noms d'outils du framework — un agent voit à qui il parle. Sa
|
|
268
|
+
* réserve aux modules `@nodefony/*` vit dans `@nodefony/ai`, à la collecte.
|
|
269
|
+
*/
|
|
270
|
+
const PREFIX = MCP_RESERVED_PREFIX;
|
|
271
|
+
/**
|
|
272
|
+
* Ce que promettent les outils intégrés : ils ne font que LIRE l'état de
|
|
273
|
+
* l'application (même `admin_call`, qui n'atteint que le catalogue de lecture),
|
|
274
|
+
* et ne sortent pas de l'application. Partagé : un objet figé ne retient rien.
|
|
275
|
+
*/
|
|
276
|
+
const READ_ONLY_CLOSED = Object.freeze({
|
|
277
|
+
readOnlyHint: true,
|
|
278
|
+
openWorldHint: false
|
|
279
|
+
});
|
|
280
|
+
/**
|
|
281
|
+
* Clés des outils intégrés — ce que l'allowlist de configuration nomme.
|
|
282
|
+
*
|
|
283
|
+
* Publiée pour qu'un schéma de configuration puisse la valider au lieu de la
|
|
284
|
+
* retaper : une clé inconnue en config est silencieusement sans effet, et
|
|
285
|
+
* l'utilisateur croit avoir activé quelque chose.
|
|
286
|
+
*/
|
|
287
|
+
const BUILTIN_MCP_TOOL_KEYS = [
|
|
288
|
+
"inspect",
|
|
289
|
+
"card",
|
|
290
|
+
"check",
|
|
291
|
+
"symbols",
|
|
292
|
+
"docs",
|
|
293
|
+
"admin_list",
|
|
294
|
+
"admin_call",
|
|
295
|
+
"observe",
|
|
296
|
+
"profile_route"
|
|
297
|
+
];
|
|
298
|
+
/**
|
|
299
|
+
* Liste des sujets, rendue lisible pour la description de l'outil.
|
|
300
|
+
*
|
|
301
|
+
* Dérivée de {@link INSPECT_SUBJECTS} : ajouter un sujet au cœur le publie ici
|
|
302
|
+
* sans rien réécrire, et il ne peut pas exister de sujet annoncé qu'on ne
|
|
303
|
+
* saurait pas lire.
|
|
304
|
+
*/
|
|
305
|
+
function subjectLines() {
|
|
306
|
+
return Object.entries(INSPECT_SUBJECTS).map(([key, spec]) => `- \`${key}\` : ${spec.summary}`).join("\n");
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Regroupe les contrôles sautés qui disent exactement la même chose.
|
|
310
|
+
*
|
|
311
|
+
* Sur l'application de développement, sept familles sautées partagent trois
|
|
312
|
+
* raisons : les répéter famille par famille coûtait ~1 800 caractères à chaque
|
|
313
|
+
* appel, relus ensuite à chaque tour de l'agent.
|
|
314
|
+
*
|
|
315
|
+
* @param skipped - les contrôles sautés, dans l'ordre du rapport
|
|
316
|
+
* @returns un groupe par raison distincte, dans l'ordre de première apparition
|
|
317
|
+
*/
|
|
318
|
+
function groupSkippedChecks(skipped) {
|
|
319
|
+
const groups = /* @__PURE__ */ new Map();
|
|
320
|
+
for (const { family, title, ...said } of skipped) {
|
|
321
|
+
const key = JSON.stringify(said);
|
|
322
|
+
const group = groups.get(key);
|
|
323
|
+
if (group) group.families[family] = title;
|
|
324
|
+
else groups.set(key, {
|
|
325
|
+
families: { [family]: title },
|
|
326
|
+
...said
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return [...groups.values()];
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Taille le rapport de diagnostic pour un AGENT : les mêmes faits, sans redite.
|
|
333
|
+
*
|
|
334
|
+
* Le `--json` de `nodefony doctor` reste complet ; seule la porte MCP taille,
|
|
335
|
+
* parce que ce qu'elle rend se relit à chaque tour suivant de l'agent. Trois
|
|
336
|
+
* coupes : `execution` (que `skipped` résume déjà), les raisons de `skipped`
|
|
337
|
+
* regroupées ({@link groupSkippedChecks}), les liens `open` des derniers
|
|
338
|
+
* démarrages (faits pour un humain qui clique).
|
|
339
|
+
*
|
|
340
|
+
* @param report - le rapport de `collectDoctorReport`
|
|
341
|
+
* @returns le verdict, son compte, puis le rapport sans redite
|
|
342
|
+
*/
|
|
343
|
+
function compactDoctorReport(report) {
|
|
344
|
+
const total = countCheckFindings(report);
|
|
345
|
+
const skipped = skippedChecks(report.execution);
|
|
346
|
+
const { execution: _execution, lastBoots, ...rest } = report;
|
|
347
|
+
return {
|
|
348
|
+
verdict: total > 0 ? "manquements" : skipped.length > 0 ? "ok-mais-incomplet" : "ok",
|
|
349
|
+
total,
|
|
350
|
+
skipped: groupSkippedChecks(skipped),
|
|
351
|
+
...rest,
|
|
352
|
+
lastBoots: lastBoots.map(({ open: _open, ...boot }) => boot)
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Catalogue intégré du framework, indexé par clé d'allowlist.
|
|
357
|
+
*
|
|
358
|
+
* Chaque handler capture ses dépendances par fermeture : c'est ce qui permet à
|
|
359
|
+
* ces outils d'être des {@link IMcpTool} ordinaires, du même type que
|
|
360
|
+
* ceux d'une application — le serveur n'a plus aucun cas particulier à traiter.
|
|
361
|
+
*
|
|
362
|
+
* @param deps - briques qui répondent réellement
|
|
363
|
+
*/
|
|
364
|
+
function builtinMcpTools(deps) {
|
|
365
|
+
return {
|
|
366
|
+
inspect: {
|
|
367
|
+
name: `${PREFIX}inspect`,
|
|
368
|
+
annotations: READ_ONLY_CLOSED,
|
|
369
|
+
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é.
|
|
370
|
+
|
|
371
|
+
Sujets disponibles :\n${subjectLines()}`,
|
|
372
|
+
inputSchema: {
|
|
373
|
+
type: "object",
|
|
374
|
+
properties: {
|
|
375
|
+
subject: {
|
|
376
|
+
type: "string",
|
|
377
|
+
enum: Object.keys(INSPECT_SUBJECTS),
|
|
378
|
+
description: "Ce qu'on veut voir"
|
|
379
|
+
},
|
|
380
|
+
target: {
|
|
381
|
+
type: "string",
|
|
382
|
+
description: "Paramètre du sujet, quand il en attend un (ex. le nom d'un module pour `module`)"
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
required: ["subject"]
|
|
386
|
+
},
|
|
387
|
+
handler: async (args, caller) => {
|
|
388
|
+
const subject = typeof args.subject === "string" ? args.subject : "";
|
|
389
|
+
const target = typeof args.target === "string" ? args.target : void 0;
|
|
390
|
+
const read = await readAdminSubject(deps.broker, subject, adminCallerFromMcp(caller), target);
|
|
391
|
+
return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
card: {
|
|
395
|
+
name: `${PREFIX}card`,
|
|
396
|
+
annotations: READ_ONLY_CLOSED,
|
|
397
|
+
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.",
|
|
398
|
+
inputSchema: {
|
|
399
|
+
type: "object",
|
|
400
|
+
properties: {}
|
|
401
|
+
},
|
|
402
|
+
handler: () => mcpText(deps.getCard())
|
|
403
|
+
},
|
|
404
|
+
check: {
|
|
405
|
+
name: `${PREFIX}check`,
|
|
406
|
+
annotations: READ_ONLY_CLOSED,
|
|
407
|
+
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.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: "object",
|
|
410
|
+
properties: {}
|
|
411
|
+
},
|
|
412
|
+
handler: async () => mcpText(compactDoctorReport(await collectDoctorReport(deps.projectRoot)))
|
|
413
|
+
},
|
|
414
|
+
docs: {
|
|
415
|
+
name: `${PREFIX}docs`,
|
|
416
|
+
annotations: READ_ONLY_CLOSED,
|
|
417
|
+
description: "Cherche et lit la DOCUMENTATION des modules installés — la prose écrite par leurs auteurs : concepts, recettes, pièges, exemples. À utiliser AVANT d'écrire du code qui utilise une brique du framework, et avant de conclure qu'une capacité n'existe pas. ⭐ Cette documentation vit sous `node_modules`, que les outils de recherche de fichiers EXCLUENT par défaut (dossier ignoré par git) : elle est livrée avec les paquets mais introuvable autrement qu'ici.\n\nSans argument : ce que l'application documente, module par module. Avec `query` : les pages qui portent tous les termes, avec leurs extraits. Avec `module` et `slug` : la page. Une page pèse souvent plus que ce qu'une réponse peut porter — demander alors `outline` pour son plan, puis `section` pour le seul passage utile.",
|
|
418
|
+
inputSchema: {
|
|
419
|
+
type: "object",
|
|
420
|
+
properties: {
|
|
421
|
+
query: {
|
|
422
|
+
type: "string",
|
|
423
|
+
description: "Texte cherché dans toute la documentation ; les espaces séparent des termes CUMULATIFS (`session redis` = les deux)"
|
|
424
|
+
},
|
|
425
|
+
module: {
|
|
426
|
+
type: "string",
|
|
427
|
+
description: "Module dont on veut la documentation (`http`, `security`, `core`…)"
|
|
428
|
+
},
|
|
429
|
+
slug: {
|
|
430
|
+
type: "string",
|
|
431
|
+
description: "Page à lire, telle que la recherche ou l'index la nomme"
|
|
432
|
+
},
|
|
433
|
+
section: {
|
|
434
|
+
type: "string",
|
|
435
|
+
description: "Titre de la seule section voulue (approchant suffit : sans accent, sans casse, sans emoji)"
|
|
436
|
+
},
|
|
437
|
+
outline: {
|
|
438
|
+
type: "boolean",
|
|
439
|
+
description: "Rendre le PLAN de la page au lieu de son body — le premier geste sur une page volumineuse"
|
|
440
|
+
},
|
|
441
|
+
limit: {
|
|
442
|
+
type: "integer",
|
|
443
|
+
description: "Nombre maximum de documents rendus par `query` (défaut 20)"
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
handler: async (args, caller) => {
|
|
448
|
+
const admin = adminCallerFromMcp(caller);
|
|
449
|
+
const module = typeof args.module === "string" ? args.module : "";
|
|
450
|
+
const slug = typeof args.slug === "string" ? args.slug : "";
|
|
451
|
+
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
452
|
+
const section = typeof args.section === "string" ? args.section : "";
|
|
453
|
+
if (module !== "" && slug !== "") {
|
|
454
|
+
const read = await callAdminEndpoint(deps.broker, {
|
|
455
|
+
namespace: "kernel",
|
|
456
|
+
path: "module/{name}/docs/{slug}",
|
|
457
|
+
params: {
|
|
458
|
+
name: module,
|
|
459
|
+
slug
|
|
460
|
+
},
|
|
461
|
+
query: section !== "" ? { section } : args.outline === true ? { outline: "1" } : {},
|
|
462
|
+
label: `${module}/${slug}`
|
|
463
|
+
}, admin);
|
|
464
|
+
if (!read.ok) return mcpAdminFailure(read);
|
|
465
|
+
return mcpText(pageOrOutline(read.data, module, slug));
|
|
466
|
+
}
|
|
467
|
+
if (query !== "") {
|
|
468
|
+
const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.trunc(args.limit) : null;
|
|
469
|
+
const read = await callAdminEndpoint(deps.broker, {
|
|
470
|
+
namespace: "kernel",
|
|
471
|
+
path: "docs/search",
|
|
472
|
+
query: limit !== null && limit > 0 ? {
|
|
473
|
+
q: query,
|
|
474
|
+
limit: String(limit)
|
|
475
|
+
} : { q: query },
|
|
476
|
+
label: `recherche « ${query} »`
|
|
477
|
+
}, admin);
|
|
478
|
+
return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
|
|
479
|
+
}
|
|
480
|
+
const read = module !== "" ? await callAdminEndpoint(deps.broker, {
|
|
481
|
+
namespace: "kernel",
|
|
482
|
+
path: "module/{name}/docs",
|
|
483
|
+
params: { name: module },
|
|
484
|
+
label: module
|
|
485
|
+
}, admin) : await callAdminEndpoint(deps.broker, {
|
|
486
|
+
namespace: "kernel",
|
|
487
|
+
path: "docs",
|
|
488
|
+
label: "documentation"
|
|
489
|
+
}, admin);
|
|
490
|
+
return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
symbols: {
|
|
494
|
+
name: `${PREFIX}symbols`,
|
|
495
|
+
annotations: READ_ONLY_CLOSED,
|
|
496
|
+
description: "Donne la SIGNATURE RÉELLE d'un symbole du framework — ses paramètres, ses types de retour, sa documentation — plus ce qu'il étend ou implémente et où il est défini. À utiliser AVANT d'appeler une classe, une interface ou une fonction du framework, plutôt que d'en deviner les arguments. ⭐ La déclaration est lue dans les types LIVRÉS du paquet installé (`node_modules`, que les outils de recherche de fichiers excluent) : c'est la version qui tourne vraiment, pas une supposition. Sans argument, rend le résumé du graphe ; avec `module`, la surface exportée d'un paquet.",
|
|
497
|
+
inputSchema: {
|
|
498
|
+
type: "object",
|
|
499
|
+
properties: {
|
|
500
|
+
name: {
|
|
501
|
+
type: "string",
|
|
502
|
+
description: "Nom exact du symbole (ex. `AbstractCrudService`, `IKernel`)"
|
|
503
|
+
},
|
|
504
|
+
module: {
|
|
505
|
+
type: "string",
|
|
506
|
+
description: "Paquet dont on veut la surface exportée (ex. `@nodefony/http`)"
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
handler: async (args, caller) => {
|
|
511
|
+
const graph = readSymbolsGraph(deps.projectRoot);
|
|
512
|
+
if (graph === null) return mcpText("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);
|
|
513
|
+
const wanted = typeof args.name === "string" ? args.name : null;
|
|
514
|
+
const module = typeof args.module === "string" ? args.module : null;
|
|
515
|
+
if (wanted !== null) {
|
|
516
|
+
const sym = lookupSymbol(graph, wanted);
|
|
517
|
+
if (!sym) return mcpText(`« ${wanted} » est introuvable dans le graphe (${Object.keys(graph.symbols).length} symboles)`, true);
|
|
518
|
+
const declared = await callAdminEndpoint(deps.broker, {
|
|
519
|
+
namespace: "kernel",
|
|
520
|
+
path: "module/{name}/symbol/{symbol}",
|
|
521
|
+
params: {
|
|
522
|
+
name: moduleKey(sym.module),
|
|
523
|
+
symbol: sym.name
|
|
524
|
+
},
|
|
525
|
+
label: sym.name
|
|
526
|
+
}, adminCallerFromMcp(caller));
|
|
527
|
+
return mcpText(declared.ok ? {
|
|
528
|
+
...sym,
|
|
529
|
+
...declared.data
|
|
530
|
+
} : {
|
|
531
|
+
...sym,
|
|
532
|
+
declaration: null,
|
|
533
|
+
why: declared.message
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
const entries = Object.values(graph.symbols).filter((s) => module === null || s.module === module || moduleKey(s.module) === moduleKey(module));
|
|
537
|
+
if (module !== null) {
|
|
538
|
+
if (entries.length === 0) {
|
|
539
|
+
const packages = [...new Set(Object.values(graph.symbols).map((s) => s.module))].sort();
|
|
540
|
+
return mcpText(`aucun symbole pour « ${module} » — paquets présents dans le graphe : ${packages.join(", ")}`, true);
|
|
541
|
+
}
|
|
542
|
+
return mcpText(entries.sort((a, b) => a.name.localeCompare(b.name)));
|
|
543
|
+
}
|
|
544
|
+
const perPackage = Object.create(null);
|
|
545
|
+
for (const sym of entries) perPackage[sym.module] = (perPackage[sym.module] ?? 0) + 1;
|
|
546
|
+
return mcpText({
|
|
547
|
+
total: entries.length,
|
|
548
|
+
perPackage
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
admin_list: {
|
|
553
|
+
name: `${PREFIX}admin_list`,
|
|
554
|
+
annotations: READ_ONLY_CLOSED,
|
|
555
|
+
description: "Catalogue des lectures d'ADMINISTRATION que cette application expose — bien au-delà des sujets de `nodefony_inspect` : journaux, sessions, comptes, clés d'API, webhooks, profileur, santé des connexions, entités, capacités de pagination. Chaque ligne est directement appelable par `nodefony_admin_call`. À utiliser dès que `nodefony_inspect` ne couvre pas ce qu'on cherche, plutôt que de supposer que la donnée n'existe pas.\n\nUn seul appel rend tout le catalogue ; `namespace` et `q` servent à le réduire quand on sait déjà ce qu'on cherche.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
type: "object",
|
|
558
|
+
properties: {
|
|
559
|
+
namespace: {
|
|
560
|
+
type: "string",
|
|
561
|
+
description: "Ne garder qu'un producteur (`kernel`, `http`, `security`, `syslog`, `user`, `orm`…)"
|
|
562
|
+
},
|
|
563
|
+
q: {
|
|
564
|
+
type: "string",
|
|
565
|
+
description: "Termes cherchés dans le chemin et le résumé ; les espaces séparent des termes CUMULATIFS (`session redis` = les deux)"
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
requiresAuth: true,
|
|
570
|
+
scopes: [ADMIN_SCOPE_READ],
|
|
571
|
+
handler: (args, caller) => mcpText(renderAdminCatalog(adminReadCatalog(deps.broker, adminCallerFromMcp(caller), {
|
|
572
|
+
namespace: typeof args.namespace === "string" ? args.namespace : void 0,
|
|
573
|
+
q: typeof args.q === "string" ? args.q : void 0
|
|
574
|
+
})))
|
|
575
|
+
},
|
|
576
|
+
admin_call: {
|
|
577
|
+
name: `${PREFIX}admin_call`,
|
|
578
|
+
annotations: READ_ONLY_CLOSED,
|
|
579
|
+
description: "Appelle une lecture d'administration, telle que `nodefony_admin_list` la nomme. La réponse vient de l'application qui TOURNE — pas d'une lecture des sources — et elle est identique à ce que rendrait la console d'administration, redaction des secrets comprise.\n\nLECTURE seule : les mutations du plan (créer, modifier, supprimer) ne passent pas par ici. Recopier `namespace` et `path` tels quels ; `params` porte les variables du chemin (`module/{name}` → `{ name: \"http\" }`), `query` les paramètres de requête que l'endpoint accepte (pagination `page`/`limit`, tri `sort`, recherche `q`). Le catalogue publie, sous chaque entrée qui rend une liste, les tris et filtres RÉELLEMENT acceptés — tout le reste est refusé en 400, jamais ignoré.",
|
|
580
|
+
inputSchema: {
|
|
581
|
+
type: "object",
|
|
582
|
+
properties: {
|
|
583
|
+
namespace: {
|
|
584
|
+
type: "string",
|
|
585
|
+
description: "Producteur, tel que le catalogue le titre"
|
|
586
|
+
},
|
|
587
|
+
path: {
|
|
588
|
+
type: "string",
|
|
589
|
+
description: "Chemin, recopié TEL QUEL du catalogue (variables `{…}` comprises)"
|
|
590
|
+
},
|
|
591
|
+
params: {
|
|
592
|
+
type: "object",
|
|
593
|
+
description: "Valeurs des variables du chemin, par nom (`{ name: \"http\" }`)",
|
|
594
|
+
additionalProperties: { type: "string" }
|
|
595
|
+
},
|
|
596
|
+
query: {
|
|
597
|
+
type: "object",
|
|
598
|
+
description: "Paramètres de requête, comme une URL les porterait (`{ limit: \"20\" }`)"
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
required: ["namespace", "path"]
|
|
602
|
+
},
|
|
603
|
+
requiresAuth: true,
|
|
604
|
+
scopes: [ADMIN_SCOPE_READ],
|
|
605
|
+
handler: async (args, caller) => {
|
|
606
|
+
const admin = adminCallerFromMcp(caller);
|
|
607
|
+
const namespace = typeof args.namespace === "string" ? args.namespace.trim() : "";
|
|
608
|
+
const path = typeof args.path === "string" ? args.path.trim() : "";
|
|
609
|
+
if (namespace === "" || path === "") return mcpText("« namespace » et « path » sont requis — demande d'abord nodefony_admin_list, puis recopie les deux tels quels", true);
|
|
610
|
+
const entry = findAdminReadEntry(deps.broker, admin, namespace, path);
|
|
611
|
+
if (!entry) {
|
|
612
|
+
const proches = adminReadCatalog(deps.broker, admin, { namespace });
|
|
613
|
+
return mcpText(proches.entries.length === 0 ? `« ${namespace}/${path} » n'est pas appelable ici, et « ${namespace} » ne propose aucune lecture — soit ce module n'est pas chargé, soit ce jeton ne l'ouvre pas. nodefony_admin_list rend le catalogue réel.` : `« ${namespace}/${path} » n'est pas appelable ici. Chemins de « ${namespace} » : ${proches.entries.map((other) => other.path).join(", ")}`, true);
|
|
614
|
+
}
|
|
615
|
+
const params = stringTable(args.params, "params", false);
|
|
616
|
+
if (!params.ok) return mcpText(params.why, true);
|
|
617
|
+
const query = stringTable(args.query, "query", true);
|
|
618
|
+
if (!query.ok) return mcpText(query.why, true);
|
|
619
|
+
const missing = entry.params.filter((name) => typeof params.value[name] !== "string");
|
|
620
|
+
if (missing.length > 0) return mcpText(`« ${namespace}/${path} » attend ${missing.length > 1 ? "les variables" : "la variable"} ${missing.map((name) => `« ${name} »`).join(", ")} dans « params »`, true);
|
|
621
|
+
const read = await callAdminEndpoint(deps.broker, {
|
|
622
|
+
namespace,
|
|
623
|
+
path,
|
|
624
|
+
method: "GET",
|
|
625
|
+
params: params.value,
|
|
626
|
+
query: query.value,
|
|
627
|
+
label: `${namespace}/${path}`
|
|
628
|
+
}, admin);
|
|
629
|
+
return read.ok ? mcpText(read.data) : mcpAdminFailure(read);
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
observe: observeTool({
|
|
633
|
+
getProfiler: deps.getProfiler,
|
|
634
|
+
servesTraffic: deps.servesTraffic,
|
|
635
|
+
readRequestLogs: (requestId, caller) => readRequestLogs(deps.broker, requestId, caller),
|
|
636
|
+
onShutdown: deps.onShutdown
|
|
637
|
+
}),
|
|
638
|
+
profile_route: profileRouteTool({
|
|
639
|
+
getProfiler: deps.getProfiler,
|
|
640
|
+
servesTraffic: deps.servesTraffic,
|
|
641
|
+
getTarget: deps.profileRoute?.getTarget ?? (() => null),
|
|
642
|
+
issueTicket: deps.profileRoute?.issueTicket,
|
|
643
|
+
closeSession: deps.profileRoute?.closeSession,
|
|
644
|
+
rolesWithin: deps.profileRoute?.rolesWithin,
|
|
645
|
+
readRequestLogs: (requestId, caller) => readRequestLogs(deps.broker, requestId, caller),
|
|
646
|
+
onShutdown: deps.onShutdown
|
|
647
|
+
})
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Présente un appelant MCP au plan d'administration.
|
|
652
|
+
*
|
|
653
|
+
* Aucune décision ici : les rôles ont été posés par la porte, qui seule sait si
|
|
654
|
+
* elle est protégée et par qui. Cette fonction ne fait que TRADUIRE — et c'est
|
|
655
|
+
* volontaire : la traduction est le seul endroit où une identité pourrait se
|
|
656
|
+
* perdre, alors elle ne fait rien d'autre que la transporter.
|
|
657
|
+
*
|
|
658
|
+
* @param caller - l'appelant établi par la porte.
|
|
659
|
+
* @returns la même identité, dans le vocabulaire du plan d'administration.
|
|
660
|
+
*/
|
|
661
|
+
function adminCallerFromMcp(caller) {
|
|
662
|
+
return {
|
|
663
|
+
user: null,
|
|
664
|
+
roles: caller.roles,
|
|
665
|
+
label: caller.subject ? `le porteur « ${caller.subject} »` : caller.authenticated ? "un porteur authentifié" : "un appelant anonyme"
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Les outils du catalogue que l'allowlist de configuration retient.
|
|
670
|
+
*
|
|
671
|
+
* C'est la forme sous laquelle `@nodefony/devkit` les déclare (`getMcpTools()`) :
|
|
672
|
+
* des {@link IMcpTool} ordinaires, collectés par `@nodefony/ai` comme ceux de
|
|
673
|
+
* n'importe quel module — seul le préfixe `nodefony_`, réservé aux modules
|
|
674
|
+
* `@nodefony/*`, les distingue.
|
|
675
|
+
*
|
|
676
|
+
* @param allowlist - clés courtes retenues (cf {@link BUILTIN_MCP_TOOL_KEYS})
|
|
677
|
+
* @param deps - briques qui répondent réellement
|
|
678
|
+
* @returns les outils retenus, dans l'ordre de l'allowlist
|
|
679
|
+
*/
|
|
680
|
+
function selectBuiltinMcpTools(allowlist, deps) {
|
|
681
|
+
const builtin = builtinMcpTools(deps);
|
|
682
|
+
const selected = [];
|
|
683
|
+
for (const key of allowlist) if (isBuiltinKey(builtin, key)) selected.push(builtin[key]);
|
|
684
|
+
return selected;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* La clé désigne-t-elle un outil du catalogue — en PROPRE, jamais par héritage ?
|
|
688
|
+
*
|
|
689
|
+
* @param catalog - le catalogue construit
|
|
690
|
+
* @param key - clé lue dans la configuration
|
|
691
|
+
* @returns `true` si `catalog` porte cette clé en propre
|
|
692
|
+
*/
|
|
693
|
+
function isBuiltinKey(catalog, key) {
|
|
694
|
+
return Object.hasOwn(catalog, key);
|
|
695
|
+
}
|
|
696
|
+
export { BUILTIN_MCP_TOOL_KEYS, builtinMcpTools, compactDoctorReport, groupSkippedChecks, selectBuiltinMcpTools };
|