@nodefony/devkit 10.0.0-beta.3 → 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.
Files changed (54) hide show
  1. package/README.md +146 -4
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +25 -226
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  18. package/dist/nodefony/src/profileSession.js +14 -0
  19. package/dist/types/index.d.ts +15 -1
  20. package/dist/types/nodefony/config/config.d.ts +1 -1
  21. package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
  22. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  23. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  24. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  25. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  26. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  27. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  28. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  29. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  30. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  31. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  32. package/docs/index.md +160 -11
  33. package/package.json +16 -11
  34. package/skills/nodefony-add-crud/SKILL.md +5 -0
  35. package/skills/nodefony-add-service/SKILL.md +3 -3
  36. package/skills/nodefony-authentication/SKILL.md +5 -5
  37. package/skills/nodefony-browser/SKILL.md +20 -0
  38. package/skills/nodefony-browser/references/socket.md +13 -4
  39. package/skills/nodefony-browser/scripts/inspect.mjs +1 -1
  40. package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
  41. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  42. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  43. package/skills/nodefony-dev/SKILL.md +31 -1
  44. package/skills/nodefony-dev/scripts/docs.mjs +3 -3
  45. package/skills/nodefony-devops/SKILL.md +85 -64
  46. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  47. package/skills/nodefony-devops/references/frontal.md +277 -47
  48. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  49. package/skills/nodefony-devops/references/secrets.md +13 -2
  50. package/skills/nodefony-devops/references/variables.md +14 -12
  51. package/skills/nodefony-protect-route/SKILL.md +6 -4
  52. package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
  53. package/skills/nodefony-troubleshoot/SKILL.md +86 -12
  54. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -0,0 +1,87 @@
1
+ import { type IMcpCaller, type IMcpTool, type IRolesWithin } from "nodefony";
2
+ import { type IProfilerReader } from "@nodefony/http";
3
+ import { type IObserveProbe } from "./observe.js";
4
+ import { type RequestLogs } from "./profileReport.js";
5
+ import { type IShotClient, type IShotTarget } from "./shotClient.js";
6
+ import { type OnShutdown } from "./shutdown.js";
7
+ /** Requêtes mesurées par défaut. */
8
+ export declare const PROFILE_ROUTE_DEFAULT_COUNT = 50;
9
+ /**
10
+ * Requêtes mesurées au plus. Avec la chauffe, sous les 500 profils que garde le
11
+ * profileur : au-delà, les premiers seraient évincés avant d'être lus.
12
+ */
13
+ export declare const PROFILE_ROUTE_MAX_COUNT = 200;
14
+ /** Requêtes simultanées par défaut. */
15
+ export declare const PROFILE_ROUTE_DEFAULT_CONCURRENCY = 4;
16
+ /** Requêtes simultanées au plus : mesurer une route, pas saturer le process. */
17
+ export declare const PROFILE_ROUTE_MAX_CONCURRENCY = 8;
18
+ /** Requêtes de chauffe par défaut, tirées AVANT et hors des mesures. */
19
+ export declare const PROFILE_ROUTE_DEFAULT_WARMUP = 3;
20
+ /** Requêtes de chauffe au plus. */
21
+ export declare const PROFILE_ROUTE_MAX_WARMUP = 20;
22
+ /**
23
+ * Budget de temps d'un tir, en ms. Sous les 60 s qu'un client MCP attend par
24
+ * défaut : au-delà, l'agent couperait l'appel juste avant le rapport.
25
+ */
26
+ export declare const PROFILE_ROUTE_BUDGET_MS = 45000;
27
+ /**
28
+ * Attente maximale, en ms, d'un profil après la réponse : le profileur le
29
+ * collecte au `close` de la réponse, émis en différé — rien ne garantit qu'il
30
+ * précède la fin de lecture côté client, dans le même process.
31
+ */
32
+ export declare const PROFILE_COLLECT_WAIT_MS = 500;
33
+ /** Ce dont l'outil a besoin — injecté, pour l'éprouver sans application. */
34
+ export interface IProfileRouteDeps {
35
+ /** Le profileur de l'application, ou `undefined` hors développement. */
36
+ getProfiler: () => IProfilerReader | undefined;
37
+ /**
38
+ * Ce process sert-il des requêtes ? Rend `false` sous la porte stdio :
39
+ * l'outil le dit ({@link NOT_SERVING_MESSAGE}). Absent : supposé servir.
40
+ */
41
+ servesTraffic?: (() => boolean) | undefined;
42
+ /** L'adresse d'écoute HTTP en clair de l'application, ou `null`. */
43
+ getTarget: () => IShotTarget | null;
44
+ /** Fabrique du client du tir (défaut : `node:http`, cf {@link createShotClient}). */
45
+ createClient?: ((target: IShotTarget, concurrency: number) => IShotClient) | undefined;
46
+ /** Fabrique de la sonde du process (défaut : `ProcessProbe` du cœur). */
47
+ createProbe?: (() => IObserveProbe) | undefined;
48
+ /** Émet le ticket à usage unique qui ouvrira la session de `identifier`. */
49
+ issueTicket?: ((identifier: string) => string) | undefined;
50
+ /** Détruit la session de profilage désignée par son pseudonyme. */
51
+ closeSession?: ((ref: string) => Promise<unknown>) | undefined;
52
+ /** Les rôles de `target` sont-ils inclus dans ceux de `owner` (hiérarchie comprise) ? */
53
+ rolesWithin?: IRolesWithin | undefined;
54
+ /** Lit les lignes de journal d'une requête en erreur. */
55
+ readRequestLogs?: ((requestId: string, caller: IMcpCaller) => Promise<RequestLogs>) | undefined;
56
+ /** S'abonne à l'arrêt du serveur pour la durée d'un appel. */
57
+ onShutdown?: OnShutdown | undefined;
58
+ /** Attente d'un profil après sa réponse (défaut {@link PROFILE_COLLECT_WAIT_MS}). */
59
+ collectWaitMs?: number | undefined;
60
+ /** Budget du tir (défaut {@link PROFILE_ROUTE_BUDGET_MS}). */
61
+ budgetMs?: number | undefined;
62
+ }
63
+ /**
64
+ * Contrôle le chemin à profiler : un CHEMIN de l'application, jamais une URL —
65
+ * l'outil ne doit pouvoir viser qu'elle.
66
+ *
67
+ * @param raw - l'argument `path`, non fiable.
68
+ * @returns le chemin normalisé (fragment retiré), ou la raison du refus.
69
+ */
70
+ export declare function checkProfilePath(raw: unknown): {
71
+ ok: true;
72
+ path: string;
73
+ } | {
74
+ ok: false;
75
+ reason: string;
76
+ };
77
+ /**
78
+ * L'outil `nodefony_profile_route` : envoie lui-même N requêtes à une route de
79
+ * l'application, au nom d'un compte s'il le faut, et rend leur profil agrégé.
80
+ *
81
+ * Rien n'est armé hors d'un appel : le client du tir (ses connexions), la
82
+ * session de profilage et l'écoute de l'arrêt du serveur naissent avec l'appel
83
+ * et sont libérés à sa sortie, quelle qu'elle soit.
84
+ *
85
+ * @param deps - profileur, adresse d'écoute, ouverture de session, journal.
86
+ */
87
+ export declare function profileRouteTool(deps: IProfileRouteDeps): IMcpTool;
@@ -0,0 +1,53 @@
1
+ /** Délai d'une requête du tir, en ms : au-delà, elle compte « sans réponse ». */
2
+ export declare const SHOT_REQUEST_TIMEOUT_MS = 15000;
3
+ /** Où l'application écoute, vue de son propre process. */
4
+ export interface IShotTarget {
5
+ /** Adresse de connexion (boucle locale). */
6
+ host: string;
7
+ port: number;
8
+ /** En-tête `Host` envoyé : le domaine que l'application attend. */
9
+ hostHeader: string;
10
+ }
11
+ /** Une requête du tir. */
12
+ export interface IShotRequest {
13
+ method: "GET" | "HEAD" | "POST";
14
+ path: string;
15
+ headers: Readonly<Record<string, string>>;
16
+ /** Lire le corps de la réponse (connexion seulement ; un tir le jette). */
17
+ readBody?: boolean | undefined;
18
+ }
19
+ /** Ce que le client retient d'une réponse. */
20
+ export interface IShotResponse {
21
+ status: number;
22
+ location: string | null;
23
+ setCookie: readonly string[];
24
+ body: string | null;
25
+ }
26
+ /** Le client d'UN tir : ses connexions vivent le temps de l'appel. */
27
+ export interface IShotClient {
28
+ send(request: IShotRequest, signal: AbortSignal): Promise<IShotResponse>;
29
+ /** Ferme les connexions gardées ouvertes ; les requêtes en vol échouent. */
30
+ close(): void;
31
+ }
32
+ /**
33
+ * Client `node:http` d'un tir : connexions gardées ouvertes entre deux requêtes,
34
+ * comme un navigateur, au plus `concurrency` à la fois, toutes fermées par
35
+ * `close()`. Un `fetch` passerait par un pool global, qui survivrait à l'appel.
36
+ *
37
+ * @param target - l'adresse d'écoute de l'application.
38
+ * @param concurrency - connexions simultanées au plus.
39
+ * @returns le client, à fermer en fin de tir.
40
+ */
41
+ export declare function createShotClient(target: IShotTarget, concurrency: number): IShotClient;
42
+ /**
43
+ * Lit l'adresse d'écoute du serveur HTTP en clair de l'application (service
44
+ * `server-http`), telle qu'il l'a réellement obtenue — port de repli compris.
45
+ *
46
+ * Une écoute sur toutes les interfaces (`0.0.0.0`, `::`) se joint par la boucle
47
+ * locale : c'est aussi ce qui rend l'appel LOCAL pour la route de connexion du
48
+ * profilage.
49
+ *
50
+ * @param server - ce que le conteneur rend pour `server-http`.
51
+ * @returns la cible, ou `null` si aucun serveur n'est prêt.
52
+ */
53
+ export declare function readShotTarget(server: unknown): IShotTarget | null;
@@ -0,0 +1,25 @@
1
+ import type { IMcpToolRun } from "nodefony";
2
+ /** Abonnement à l'arrêt du serveur, pour la durée d'UN appel d'outil. */
3
+ export type OnShutdown = (listener: () => Promise<void>) => () => void;
4
+ /** Ce qu'un outil long lit pour savoir s'il doit s'arrêter, et pourquoi. */
5
+ export interface IToolStop {
6
+ /** Avorté quand l'agent abandonne l'appel OU que le serveur s'arrête. */
7
+ readonly signal: AbortSignal;
8
+ /** `true` si c'est l'arrêt du serveur qui a clos l'appel. */
9
+ readonly interrupted: boolean;
10
+ /**
11
+ * Le rapport est rendu : l'arrêt du serveur, s'il attendait, reprend, et
12
+ * l'écouteur quitte le kernel. À appeler sur TOUS les chemins de sortie.
13
+ */
14
+ release(): void;
15
+ }
16
+ /**
17
+ * Arme l'arrêt d'un outil long : l'agent qui abandonne, ou le serveur qui
18
+ * s'arrête (rechargement après une modification de fichier). Dans le second
19
+ * cas, l'arrêt attend — borné — que le rapport partiel soit parti.
20
+ *
21
+ * @param run - l'appel en cours, porteur du signal d'abandon de l'agent.
22
+ * @param onShutdown - abonnement à l'arrêt du serveur ; absent hors application.
23
+ * @returns le signal combiné, la cause, et la libération.
24
+ */
25
+ export declare function watchToolStop(run: IMcpToolRun | undefined, onShutdown: OnShutdown | undefined): IToolStop;
@@ -0,0 +1,109 @@
1
+ import { skippedChecks } from "nodefony";
2
+ import type { IDoctorReport, IAdminBrokerLike, IMcpTool } from "nodefony";
3
+ import type { IProfilerReader } from "@nodefony/http";
4
+ import { type IProfileRouteDeps } from "./profileRoute.js";
5
+ /** Ce dont les outils INTÉGRÉS ont besoin — injecté, jamais lu ici. */
6
+ export interface IMcpToolDeps {
7
+ /** Le service `adminBroker` du conteneur, ou `undefined` s'il manque. */
8
+ broker: IAdminBrokerLike | undefined;
9
+ /** Compose la carte de visite de l'application. */
10
+ getCard: () => unknown;
11
+ /**
12
+ * Racine depuis laquelle lire le disque (graphe symbolique, diagnostic).
13
+ *
14
+ * Injectée plutôt que lue par `process.cwd()` : le serveur répond dans le
15
+ * process de l'application, dont le dossier courant n'est pas garanti être
16
+ * celui du projet — et un outil qui diagnostiquerait le mauvais dossier
17
+ * conclurait « rien à signaler » avec aplomb.
18
+ */
19
+ projectRoot: string;
20
+ /**
21
+ * Le profileur de développement (`"profiler"` du conteneur), ou `undefined`
22
+ * hors développement : `nodefony_observe` le dit au lieu de planter.
23
+ */
24
+ getProfiler: () => IProfilerReader | undefined;
25
+ /**
26
+ * Ce process sert-il des requêtes ? `false` sous la porte stdio, où les
27
+ * outils de MESURE refusent en le disant ; absent : supposé servir.
28
+ */
29
+ servesTraffic?: (() => boolean) | undefined;
30
+ /**
31
+ * S'abonne à l'arrêt du serveur pour la durée d'un appel (rapport partiel de
32
+ * `nodefony_observe`) ; absent hors application.
33
+ */
34
+ onShutdown?: ((listener: () => Promise<void>) => () => void) | undefined;
35
+ /**
36
+ * Ce que `nodefony_profile_route` demande à l'application : son adresse
37
+ * d'écoute, l'ouverture et la fermeture de la session de profilage, la
38
+ * comparaison des rôles de deux comptes. Absent hors application : l'outil
39
+ * dit qu'il n'a rien à viser.
40
+ */
41
+ profileRoute?: Pick<IProfileRouteDeps, "getTarget" | "issueTicket" | "closeSession" | "rolesWithin"> | undefined;
42
+ }
43
+ /**
44
+ * Clés des outils intégrés — ce que l'allowlist de configuration nomme.
45
+ *
46
+ * Publiée pour qu'un schéma de configuration puisse la valider au lieu de la
47
+ * retaper : une clé inconnue en config est silencieusement sans effet, et
48
+ * l'utilisateur croit avoir activé quelque chose.
49
+ */
50
+ export declare const BUILTIN_MCP_TOOL_KEYS: readonly ["inspect", "card", "check", "symbols", "docs", "admin_list", "admin_call", "observe", "profile_route"];
51
+ /** Clé d'un outil intégré. */
52
+ export type BuiltinMcpToolKey = (typeof BUILTIN_MCP_TOOL_KEYS)[number];
53
+ /** Contrôles sautés pour la MÊME raison : la raison une fois, ses familles ensuite. */
54
+ export interface ISkippedCheckGroup {
55
+ /** Famille → titre, dans l'ordre de lecture du rapport. */
56
+ families: Record<string, string>;
57
+ reason: string;
58
+ unlock?: string | undefined;
59
+ onDemand?: boolean | undefined;
60
+ notApplicable?: boolean | undefined;
61
+ advisory?: boolean | undefined;
62
+ }
63
+ /**
64
+ * Regroupe les contrôles sautés qui disent exactement la même chose.
65
+ *
66
+ * Sur l'application de développement, sept familles sautées partagent trois
67
+ * raisons : les répéter famille par famille coûtait ~1 800 caractères à chaque
68
+ * appel, relus ensuite à chaque tour de l'agent.
69
+ *
70
+ * @param skipped - les contrôles sautés, dans l'ordre du rapport
71
+ * @returns un groupe par raison distincte, dans l'ordre de première apparition
72
+ */
73
+ export declare function groupSkippedChecks(skipped: ReturnType<typeof skippedChecks>): ISkippedCheckGroup[];
74
+ /**
75
+ * Taille le rapport de diagnostic pour un AGENT : les mêmes faits, sans redite.
76
+ *
77
+ * Le `--json` de `nodefony doctor` reste complet ; seule la porte MCP taille,
78
+ * parce que ce qu'elle rend se relit à chaque tour suivant de l'agent. Trois
79
+ * coupes : `execution` (que `skipped` résume déjà), les raisons de `skipped`
80
+ * regroupées ({@link groupSkippedChecks}), les liens `open` des derniers
81
+ * démarrages (faits pour un humain qui clique).
82
+ *
83
+ * @param report - le rapport de `collectDoctorReport`
84
+ * @returns le verdict, son compte, puis le rapport sans redite
85
+ */
86
+ export declare function compactDoctorReport(report: IDoctorReport): Record<string, unknown>;
87
+ /**
88
+ * Catalogue intégré du framework, indexé par clé d'allowlist.
89
+ *
90
+ * Chaque handler capture ses dépendances par fermeture : c'est ce qui permet à
91
+ * ces outils d'être des {@link IMcpTool} ordinaires, du même type que
92
+ * ceux d'une application — le serveur n'a plus aucun cas particulier à traiter.
93
+ *
94
+ * @param deps - briques qui répondent réellement
95
+ */
96
+ export declare function builtinMcpTools(deps: IMcpToolDeps): Record<BuiltinMcpToolKey, IMcpTool>;
97
+ /**
98
+ * Les outils du catalogue que l'allowlist de configuration retient.
99
+ *
100
+ * C'est la forme sous laquelle `@nodefony/devkit` les déclare (`getMcpTools()`) :
101
+ * des {@link IMcpTool} ordinaires, collectés par `@nodefony/ai` comme ceux de
102
+ * n'importe quel module — seul le préfixe `nodefony_`, réservé aux modules
103
+ * `@nodefony/*`, les distingue.
104
+ *
105
+ * @param allowlist - clés courtes retenues (cf {@link BUILTIN_MCP_TOOL_KEYS})
106
+ * @param deps - briques qui répondent réellement
107
+ * @returns les outils retenus, dans l'ordre de l'allowlist
108
+ */
109
+ export declare function selectBuiltinMcpTools(allowlist: readonly string[], deps: IMcpToolDeps): IMcpTool[];
@@ -1,5 +1,7 @@
1
1
  import { Service, Module } from "nodefony";
2
- import type { IMcpToolDeps, IProtectedResourceInput } from "nodefony";
2
+ import type { IMcpTool, IProtectedResourceInput } from "nodefony";
3
+ import { type IMcpGate, type ITicketSessionGate } from "@nodefony/ai";
4
+ import { type IMcpToolDeps } from "../mcp/tools.js";
3
5
  import type { IDevkitCard, IDevkitService } from "../interfaces/IDevkitService.js";
4
6
  import { type IDevkitConfig } from "../config/config.js";
5
7
  /**
@@ -12,7 +14,7 @@ import { type IDevkitConfig } from "../config/config.js";
12
14
  *
13
15
  * Un service porte DEUX noms, et c'est normal :
14
16
  * `@injectable()` → nomme la CLASSE (`DevkitService`),
15
- * c'est ce qu'on écrit dans `@inject("…")`
17
+ * c'est ce qu'on écrit dans `@Inject("…")`
16
18
  * `super("devkit", …)` → nomme l'INSTANCE, sa clé dans le conteneur,
17
19
  * c'est ce qu'on écrit dans `kernel.get("…")`
18
20
  * Les deux mènent à la MÊME instance : le conteneur les réconcilie via la classe.
@@ -28,13 +30,13 @@ import { type IDevkitConfig } from "../config/config.js";
28
30
  * ```ts
29
31
  * // 1. INJECTION par le constructeur — la dépendance est DÉCLARÉE, donc le
30
32
  * // conteneur l'ordonnance et elle se voit dans la signature (nom de CLASSE).
31
- * import { inject, injectable, Service, Module } from "nodefony";
33
+ * import { Inject, injectable, Service, Module } from "nodefony";
32
34
  *
33
35
  * @injectable()
34
36
  * class ReportService extends Service {
35
37
  * constructor(
36
38
  * module: Module,
37
- * @inject("DevkitService") private devkit: DevkitService,
39
+ * @Inject("DevkitService") private devkit: DevkitService,
38
40
  * ) {
39
41
  * super("report", module.container, module.notificationsCenter);
40
42
  * }
@@ -46,6 +48,7 @@ import { type IDevkitConfig } from "../config/config.js";
46
48
  * ```
47
49
  */
48
50
  declare class DevkitService extends Service implements IDevkitService {
51
+ #private;
49
52
  module: Module;
50
53
  private readonly cfg;
51
54
  constructor(module: Module);
@@ -97,6 +100,16 @@ declare class DevkitService extends Service implements IDevkitService {
97
100
  * n'est pas garanti être celui du projet).
98
101
  */
99
102
  mcpToolDeps(): IMcpToolDeps;
103
+ /**
104
+ * Les outils `nodefony_*` retenus par l'allowlist `mcp.tools`.
105
+ *
106
+ * Recomposés à chaque appel, jamais mémorisés : la collecte a lieu à la
107
+ * requête MCP, sur une porte qui n'existe qu'en développement — un cache
108
+ * serait une structure de plus à invalider pour un gain nul.
109
+ *
110
+ * @returns les outils, dans l'ordre de l'allowlist
111
+ */
112
+ mcpTools(): IMcpTool[];
100
113
  /**
101
114
  * Les scopes que la porte MCP EXIGE — dérivés des outils qu'elle déclare.
102
115
  *
@@ -136,6 +149,30 @@ declare class DevkitService extends Service implements IDevkitService {
136
149
  * @returns une entrée pour la porte MCP, ou aucune
137
150
  */
138
151
  publishedProtectedResources(): readonly IProtectedResourceInput[];
152
+ /**
153
+ * La porte MCP de développement — définie au premier usage, une fois.
154
+ *
155
+ * Bâtie sur `defineMcpGate` comme celle de n'importe quelle application : ses
156
+ * réglages viennent de `devkit.mcp.*`, elle sert les outils de TOUS les modules
157
+ * chargés (`tools: "modules"` — l'outillage éprouve aussi ceux de l'app), et
158
+ * accorde le rôle d'opérateur à qui l'atteint sans jeton. Ce rôle n'est sûr que
159
+ * parce que son PÉRIMÈTRE la protège (module `policy: "dev"`, absent en
160
+ * production) ; une porte protégée par un serveur d'autorisation le tire, elle,
161
+ * des scopes du jeton.
162
+ *
163
+ * @returns la porte, prête à servir.
164
+ */
165
+ mcpGate(): IMcpGate;
166
+ /**
167
+ * La porte de session du profilage — définie au premier usage, une fois.
168
+ *
169
+ * Bâtie sur `defineTicketSessionGate` comme le ferait une application pour
170
+ * ses propres outils : locale seulement (défaut de la porte), facteur d'audit
171
+ * {@link PROFILE_SESSION_FACTOR}.
172
+ *
173
+ * @returns la porte, prête à émettre et à servir.
174
+ */
175
+ profileSessionGate(): ITicketSessionGate;
139
176
  status(): {
140
177
  ready: boolean;
141
178
  };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Facteur d'authentification déclaré au flux de session : l'audit écrit
3
+ * `login.success` avec cette raison, ce qui distingue une session de profilage
4
+ * d'une vraie connexion.
5
+ */
6
+ export declare const PROFILE_SESSION_FACTOR = "devkit";
7
+ /**
8
+ * Chemin de la route de connexion du profilage, que l'outil appelle. Hors du
9
+ * motif de la zone d'administration (`^/nodefony/[^/]+/api`). Le contrôleur
10
+ * l'écrit en littéraux ; un test tient l'égalité.
11
+ */
12
+ export declare const PROFILE_SESSION_PATH = "/nodefony/devkit/profile-session";
package/docs/index.md CHANGED
@@ -106,13 +106,13 @@ la rend éprouvable sans serveur, et ce qui l'empêche d'inventer quoi que ce so
106
106
  npx nodefony ai:mcp # écrit .mcp.json ; --dry-run pour voir sans écrire
107
107
  ```
108
108
 
109
- Sept outils. Quatre sont les commandes que vous connaissez déjà :
109
+ Neuf outils. Quatre sont les commandes que vous connaissez déjà :
110
110
  `nodefony_inspect` (ce qui est monté), `nodefony_check` (ce qui manque),
111
111
  `nodefony_symbols` (ce qu'une API signifie), `nodefony_card` (par où commencer).
112
112
  `nodefony_docs` sert la documentation des modules. `nodefony_admin_list` et
113
113
  `nodefony_admin_call` ouvrent les lectures du data plane d'administration — le
114
114
  catalogue, puis l'appel — et exigent un jeton porteur du scope `admin:read` :
115
- sans lui, ils n'apparaissent pas dans la liste.
115
+ sans lui, ils n'apparaissent pas dans la liste. `nodefony_observe` mesure ce que l'application fait réellement de ses requêtes, pendant qu'elle tourne ; `nodefony_profile_route` mesure une route en lui envoyant lui-même ses requêtes (sections suivantes).
116
116
 
117
117
  **Il n'y a pas de process à lancer.** Depuis la révision `2026-07-28` du
118
118
  transport, un serveur MCP est un endpoint `POST` sans session : c'est donc une
@@ -143,13 +143,145 @@ Trois choses à savoir avant de s'étonner d'un refus :
143
143
 
144
144
  Les outils intégrés sont en lecture seule, et `mcp.tools` est une allowlist.
145
145
 
146
- ### Obtenir le porteur — `security:token`
146
+ ### Mesurer l'application qui tourne — `nodefony_observe`
147
+
148
+ Un agent de code qui vient de modifier un contrôleur ne voit pas l'effet de son
149
+ changement : la barre de debug et la console d'administration affichent les
150
+ mesures du profileur, mais il ne les lit pas. `nodefony_observe` les lui rend.
151
+ Pendant une fenêtre de 1 à 50 secondes (10 par défaut), l'outil relève le
152
+ profileur de développement toutes les deux secondes et rend un rapport :
153
+
154
+ 1. un **verdict** — `ok`, `anomalies (N)`, et `INCOMPLÈTE` quand les chiffres ne
155
+ portent pas sur tout (profils perdus, aucun trafic, appel interrompu), avec
156
+ la raison ;
157
+ 2. les **anomalies**, nommées par route et par contrôleur : une requête SQL
158
+ répétée dans une boucle (« N+1 »), les erreurs `5xx` avec leur `requestId` et
159
+ leurs lignes de journal, les routes dont le p95 dépasse 500 ms avec ce qui
160
+ coûte par requête, un blocage de la boucle d'événements de 100 ms ou plus ;
161
+ 3. un tableau par route, les requêtes SQL les plus coûteuses, et un bloc JSON
162
+ pour comparer deux appels — avant et après une correction.
163
+
164
+ ```jsonc
165
+ { "name": "nodefony_observe", "arguments": { "durationSec": 10 } }
166
+ // "route": "<nom de route>" pour n'en observer qu'une ; "includeTooling": true
167
+ // pour compter aussi les routes /nodefony/… de l'outillage
168
+ ```
169
+
170
+ **L'outil ne génère aucun trafic.** On ouvre la page, on joue les tests ou on
171
+ envoie des requêtes pendant que la fenêtre court ; sans trafic, il n'a rien à
172
+ juger, et il le dit.
173
+
174
+ Ce que les chiffres veulent dire :
175
+
176
+ - **durées et temps « par requête »** portent sur les requêtes **servies**
177
+ (statut inférieur à 400, sans exception). Un refus `401`/`403` rendu en
178
+ quelques millisecondes ne mesure pas la route : il est compté à part ;
179
+ - **`action.self`** est le temps de l'action et des services qu'elle appelle,
180
+ une fois retirés les phases imbriquées (initialisation, rendu, envoi) et le
181
+ SQL — les intervalles sont fusionnés, une requête lancée pendant le rendu
182
+ n'est pas comptée deux fois. **« SQL (cumul) »** additionne les durées des
183
+ requêtes, même lancées en parallèle ;
184
+ - **la boucle d'événements** est rapportée en retard moyen et en **pire
185
+ blocage** : un gel unique de 200 ms ne déplace la moyenne d'un intervalle de
186
+ deux secondes que de quelques millisecondes ;
187
+ - **la forme d'une requête SQL** remplace chaque valeur par `?` : le rapport ne
188
+ contient jamais une donnée. Le journal d'une erreur n'en garde que les lignes
189
+ WARNING et plus graves ;
190
+ - la **variation du tas** est mesurée sans GC forcé : elle est indicative,
191
+ jamais une anomalie.
192
+
193
+ Ce qui encadre l'outil :
194
+
195
+ - c'est un **outil long** : chaque relevé part en progression, et un client qui l'annule l'arrête au relevé suivant, qu'il ferme le flux de la réponse (révision courante de MCP) ou envoie une notification d'annulation (révision précédente, celle du SDK officiel) ;
196
+ - un **rechargement du serveur** pendant la fenêtre (un fichier modifié) ne fait
197
+ pas perdre l'observation : l'outil rend un rapport partiel qui le dit, avant
198
+ que le serveur ne ferme ses connexions ;
199
+ - le **journal des erreurs** est lu au relevé qui voit la requête — le syslog de
200
+ développement ne garde que ses dernières lignes — et seulement pour un appel
201
+ **authentifié** (scope `admin:read`), par la même voie que
202
+ `nodefony_admin_call` ;
203
+ - le profileur garde les 500 dernières requêtes : au-delà de ce débit entre deux
204
+ relevés, les profils perdus sont comptés, et le verdict le dit.
205
+
206
+ La lecture de chaque anomalie et le geste qui la corrige sont dans le skill
207
+ d'agent `nodefony-troubleshoot`, section « Mesurer l'application qui tourne ».
208
+
209
+ ### Mesurer une route — `nodefony_profile_route`
210
+
211
+ Là où `nodefony_observe` attend le trafic, `nodefony_profile_route` le **produit** :
212
+ il envoie lui-même N requêtes `GET` (ou `HEAD`) sur un chemin de l'application,
213
+ retrouve leurs profils par l'identifiant qu'il pose (`x-request-id`) et rend le
214
+ même rapport. C'est la boucle « je modifie → je mesure » en un appel.
215
+
216
+ ```jsonc
217
+ // tools/call
218
+ {
219
+ "name": "nodefony_profile_route",
220
+ "arguments": { "path": "/orders?page=2", "count": 50 },
221
+ }
222
+ // optionnel : "concurrency" (défaut 4, max 8), "warmup" (défaut 3, hors mesures),
223
+ // "method": "HEAD", "as": "<identifiant>", "anonymous": true
224
+ ```
225
+
226
+ `path` est un **chemin**, jamais une URL : hôte, schéma et `//` sont refusés,
227
+ l'outil ne vise que l'application elle-même. Au plus 200 requêtes par appel —
228
+ sous les 500 profils que garde le profileur — et 45 secondes de tir.
229
+
230
+ **Au nom de qui.** Une page protégée vue sans compte ne rend que des refus.
231
+ Quand le jeton MCP porte le scope `admin:write`, l'outil joue son
232
+ **propriétaire** : il ouvre une vraie session à son nom par le flux de connexion
233
+ de l'application (un ticket à usage unique, présenté en boucle locale à
234
+ `POST /nodefony/devkit/profile-session`), tire avec, puis la détruit — l'audit
235
+ l'écrit sous la raison `devkit`. `as` joue un autre compte, à condition que ses
236
+ rôles soient inclus dans ceux du propriétaire, hiérarchie comprise : jouer un
237
+ compte ne monte jamais en droits. Sans ce scope, l'outil joue l'anonyme et le
238
+ rapport donne la commande :
239
+
240
+ ```bash
241
+ npx nodefony security:mcp:token --scope "admin:read admin:write" --write
242
+ ```
243
+
244
+ Extrait de deux appels réels, avant puis après la correction d'un N+1 (la même
245
+ lecture, une requête par compte, puis une seule requête `$in`) :
246
+
247
+ ```text
248
+ Verdict : anomalies (1) — observation complète
249
+ Tir : 30/30 requête(s) GET /nodefony/test/db/n-plus-one · concurrence 4 · 106 ms · chauffe de 3 requête(s) hors mesures (p50 1.5 ms, max 2.5 ms)
250
+ Joué en tant que : `admin` (ROLE_ADMIN, ROLE_NODEFONY_ADMIN, ROLE_DEV, ROLE_SUPERVISOR)
251
+ Reçu par le client : 2xx 30 · 3xx 0 · 401 0 · 403 0 · autres 4xx 0 · 5xx 0 · sans réponse 0
252
+
253
+ Anomalies (les plus coûteuses d'abord) :
254
+ 1. N+1 — route `DbController::nPlusOne` (DbController.nPlusOne) : `select … from "User" where "User"."id" = ? …` répétée ×16 dans une requête (30 requête(s) touchée(s), 14 ms cumulés). […] Remède habituel : charger la relation en une requête (`relations`, `$in`).
255
+
256
+ route | type | n | p50 | p95 | max | 2xx/3xx/4xx/5xx | SQL par requête moy/max | temps par requête
257
+ `DbController::nPlusOne` (DbController.nPlusOne) | http | 30 | 1.2 ms | 1.7 ms | 1.7 ms | 30/0/0/0 | 18.0/18 | SQL (cumul) 0.7 ms · action.self 0.2 ms · send 0.1 ms
258
+
259
+ — après correction —
260
+ Verdict : ok — observation complète
261
+ `DbController::nPlusOneFixed` (DbController.nPlusOneFixed) | http | 30 | 0.7 ms | 0.9 ms | 2.1 ms | 30/0/0/0 | 3.0/3 | SQL (cumul) 0.4 ms · send 0.1 ms · action.self 0.1 ms
262
+ ```
263
+
264
+ Ce que le rapport dit **en tête**, avant tout chiffre : les requêtes refusées
265
+ (`401`/`403` — verdict « rien à juger »), redirigées (vers la page de
266
+ connexion), restées sans réponse, et les profils non retrouvés. Une mesure qui ne
267
+ porte pas sur ce qui était demandé le dit d'abord.
268
+
269
+ À savoir :
270
+
271
+ - ce sont de **vraies requêtes** : une page qui écrit en `GET` écrit N fois — un
272
+ compteur, ou une session créée pour chaque visiteur anonyme ;
273
+ - les zones à jeton (`jwt`, `external-jwt`) ne sont pas jouées : leurs requêtes
274
+ rendent `401`, et le rapport le dit ;
275
+ - un tir plus court que cinq périodes de la sonde laisse la boucle d'événements
276
+ « non mesurée » plutôt que d'afficher un zéro trompeur.
277
+
278
+ ### Obtenir le porteur — `security:mcp:token`
147
279
 
148
280
  Dès que la porte demande un jeton, il faut en produire un. L'application **le signe elle-même**, en
149
281
  ligne de commande, sans qu'aucun serveur n'écoute :
150
282
 
151
283
  ```bash
152
- npx nodefony security:token --write # pose NF_MCP_TOKEN chez les agents présents
284
+ npx nodefony security:mcp:token --write # pose NF_MCP_TOKEN chez les agents présents
153
285
  npx nodefony ai:mcp --auth # l'en-tête porte ${NF_MCP_TOKEN}, jamais le jeton
154
286
  ```
155
287
 
@@ -203,7 +335,8 @@ métier — et c'est précisément ce qu'un agent invente le plus mal. N'importe
203
335
  module de votre application peut donc en publier :
204
336
 
205
337
  ```ts
206
- import { Module, mcpText, type IMcpTool } from "nodefony";
338
+ import { Module, type IMcpTool } from "nodefony";
339
+ import { mcpText } from "@nodefony/ai";
207
340
 
208
341
  class Shop extends Module {
209
342
  getMcpTools(): IMcpTool[] {
@@ -221,6 +354,10 @@ class Shop extends Module {
221
354
  properties: { sku: { type: "string", description: "Référence" } },
222
355
  required: ["sku"],
223
356
  },
357
+ // Il LIT la base et ne sort pas de l'application : il le dit, sinon la
358
+ // spec le tient pour destructif et l'agent demande confirmation à
359
+ // chaque lecture.
360
+ annotations: { readOnlyHint: true, openWorldHint: false },
224
361
  handler: async (args) => mcpText(await this.stock(String(args.sku))),
225
362
  },
226
363
  ];
@@ -228,8 +365,14 @@ class Shop extends Module {
228
365
  }
229
366
  ```
230
367
 
231
- Trois choses à savoir :
368
+ Quatre choses à savoir :
232
369
 
370
+ - **un outil dit ce qu'il fait** (`annotations`, indices `ToolAnnotations` de la
371
+ spec MCP) : sans annotation, la spec le tient pour un outil qui écrit, détruit
372
+ et sort de l'application. Un outil qui lit se déclare `readOnlyHint: true` ;
373
+ un outil qui modifie, `readOnlyHint: false`. Un indice n'est jamais une
374
+ autorisation — le détail des cinq indices et de leurs défauts :
375
+ [écrire un outil](../../ai/docs/mcp.md#dire-ce-que-fait-loutil--les-annotations) ;
233
376
  - **rien ne s'enregistre au démarrage** : la liste est relue à chaque requête,
234
377
  donc un module ajouté apparaît sans redémarrer quoi que ce soit ;
235
378
  - **`mcp.tools` ne filtre que les outils intégrés** — le vôtre est publié dès
@@ -280,7 +423,9 @@ est absent de `tools/list` **et** inappelable en le nommant, et le refus dit
280
423
  ### Un outil LONG — progression et annulation
281
424
 
282
425
  Le troisième paramètre du handler sert l'outil qui dure : `run.signal` est
283
- abattu quand l'agent abandonne l'appel, `run.progress()` lui donne signe de vie.
426
+ abattu quand le client annule — qu'il ferme le flux de la réponse (révision courante
427
+ de MCP) ou envoie `notifications/cancelled` (révision précédente) —,
428
+ `run.progress()` lui donne signe de vie.
284
429
  Un outil court l'ignore ; un appel direct (un test) ne le fournit pas, d'où le
285
430
  `run?.` :
286
431
 
@@ -303,10 +448,14 @@ Un outil court l'ignore ; un appel direct (un test) ne le fournit pas, d'où le
303
448
 
304
449
  Ce que la porte fait du reste :
305
450
 
306
- - **le flux ne s'ouvre qu'à la première progression.** Un agent qui envoie un
307
- `progressToken` et accepte `text/event-stream` reçoit des
308
- `notifications/progress`, puis la réponse, qui clôt le flux. Sans jeton, ou
309
- pour un outil qui ne signale rien, la réponse reste du JSON ;
451
+ - **le flux s'ouvre à la première progression, ou au bout de cinq secondes.**
452
+ Un agent qui envoie un `progressToken` et accepte `text/event-stream` reçoit
453
+ des `notifications/progress`, puis la réponse, qui clôt le flux. Un outil
454
+ encore en cours après le délai de grâce (`MCP_STREAM_GRACE_MS`, 5 s) ouvre le
455
+ flux même sans jeton : la norme oblige le client à accepter une réponse en
456
+ flux, et c'est ce qui désarme le délai d'inactivité de la requête (30 s par
457
+ défaut) — sans cela, un outil long répondant en JSON recevrait un `408`. Un
458
+ outil qui répond avant reste en JSON ;
310
459
  - **la norme est tenue pour vous** : une valeur qui ne croît pas est ignorée, la
311
460
  cadence est bornée (une notification par 100 ms au plus), et plus rien ne
312
461
  part une fois l'outil terminé ;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/devkit",
3
- "version": "10.0.0-beta.3",
3
+ "version": "10.0.0-beta.4",
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>",
@@ -20,22 +20,27 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "build": "rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
23
+ "clean": "node --eval \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
23
24
  "typecheck": "tsgo --noEmit -p tsconfig.json && tsgo --noEmit -p tsconfig.tests.json",
24
25
  "test": "vitest run",
25
- "coverage": "vitest run --coverage"
26
+ "coverage": "vitest run --coverage",
27
+ "test:integration": "vitest run --config vitest.integration.config.ts"
26
28
  },
27
29
  "peerDependencies": {
28
- "@nodefony/framework": "^10.0.0-beta.3",
29
- "@nodefony/http": "^10.0.0-beta.3",
30
- "nodefony": "^10.0.0-beta.3",
31
- "zod": "^4.6.7",
30
+ "@nodefony/ai": "^10.0.0-beta.4",
31
+ "@nodefony/framework": "^10.0.0-beta.4",
32
+ "@nodefony/http": "^10.0.0-beta.4",
33
+ "lighthouse": "^13.0.0",
34
+ "nodefony": "^10.0.0-beta.4",
32
35
  "playwright": "^1.50.0",
33
- "lighthouse": "^13.0.0"
36
+ "zod": "^4.6.7"
34
37
  },
35
38
  "devDependencies": {
36
- "@nodefony/framework": "^10.0.0-beta.3",
37
- "@nodefony/http": "^10.0.0-beta.3",
38
- "nodefony": "^10.0.0-beta.3"
39
+ "@modelcontextprotocol/sdk": "1.32.1",
40
+ "@nodefony/ai": "^10.0.0-beta.4",
41
+ "@nodefony/framework": "^10.0.0-beta.4",
42
+ "@nodefony/http": "^10.0.0-beta.4",
43
+ "nodefony": "^10.0.0-beta.4"
39
44
  },
40
45
  "dependencies": {
41
46
  "axe-core": "4.14.0",
@@ -72,6 +77,6 @@
72
77
  "esm"
73
78
  ],
74
79
  "engines": {
75
- "node": ">=24.0.0"
80
+ "node": ">=24.14.0"
76
81
  }
77
82
  }