Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

@nodefony/devkit — l'outillage de développement d'une application

stable@nodefony/devkitmis à jour 2026-09-01

L’outillage de développement d’une application : sa carte de visite, les skills qui disent à un agent comment faire les tâches courantes, et les portes qui mènent au reste.

Cette page est surfacée dans Studio (onglet Docs du module).

📍 Documentation › @nodefony/devkit

Par où commencer#

Le problème qu’il résout#

Une application Nodefony sait beaucoup de choses sur elle-même — ses modules, ses routes, sa configuration, la documentation installée avec chaque paquet. Mais elle ne le dit à personne. Celui qui arrive — un développeur qui reprend le projet, un agent qui code — n’a d’autre choix que de deviner : lire les sources, supposer une convention, inventer une route.

Le devkit répond à la question d’ouverture, et à elle seule : qui répond ici, et où faut-il aller ensuite ?

npx nodefony card               # -j pour du JSON (| jq)

La réponse tient en trois blocs : l’identité (nom, version, environnement, cœur), les modules, puis où lire et quoi lancer.

Cette commande-là est servie par le cœur, pas par ce module : une carte de visite qui exigerait une application déjà construite, ou une variable d’environnement posée, serait fermée au moment exact où l’on en a besoin. Elle ne lit que des fichiers — et quand rien n’a démarré, elle annonce des modules installés plutôt que chargés, en renvoyant à npx nodefony inspect modules.

Pourquoi il n’existe pas en production#

Ce que la carte expose — modules chargés, chemins de documentation, commandes — aide pendant le développement. En production, c’est une description de votre architecture offerte à qui la demande : une divulgation, pas une fonctionnalité.

D’où la double protection, et les deux moitiés comptent :

// nodefony.config.ts — posé par `nodefony create app`
use("@nodefony/devkit", {}, { policy: "dev" }),

Corollaire à connaître : hors développement, c’est la route qui n’existe pas. La commande, elle, répond — elle ne passe pas par ce module.

Quatre portes, une seule source#

Porte Pour qui
npx nodefony card un agent, un humain au terminal — servie par le cœur
GET /nodefony/devkit/api/card Studio, un script authentifié — modules réellement CHARGÉS
POST /nodefony/mcp un agent qui appelle des outils (Model Context Protocol)
buildCard() (export du cœur) une porte de plus, à écrire — rien à réimplémenter

Ajouter une porte n’ajoute jamais une vérité : toutes lisent le même service, qui dérive le même Kernel. La construction elle-même vit dans une fonction pure (buildCard) qui reçoit son état au lieu de le lire — c’est ce qui la rend éprouvable sans serveur, et ce qui l’empêche d’inventer quoi que ce soit.

La route de la carte vit sous /nodefony/<module>/api, que le pare-feu d’une application réelle couvre : un agent qui code ne s’authentifie pas et n’a pas de navigateur. La porte qui compte pour lui est la commande — ou le serveur MCP ci-dessous.

Le serveur MCP — les mêmes réponses, en outils#

npx nodefony ai:mcp        # écrit .mcp.json ; --dry-run pour voir sans écrire

Neuf outils. Quatre sont les commandes que vous connaissez déjà : nodefony_inspect (ce qui est monté), nodefony_check (ce qui manque), nodefony_symbols (ce qu’une API signifie), nodefony_card (par où commencer). nodefony_docs sert la documentation des modules. nodefony_admin_list et nodefony_admin_call ouvrent les lectures du data plane d’administration — le catalogue, puis l’appel — et exigent un jeton porteur du scope admin:read : 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).

Il n’y a pas de process à lancer. Depuis la révision 2026-07-28 du transport, un serveur MCP est un endpoint POST sans session : c’est donc une route de votre application. Elle vit tant qu’elle tourne, suit chaque rechargement du serveur de développement, et n’a aucun cache à invalider.

La déclaration reste dans VOTRE projet. ai:mcp écrit .mcp.json à la racine du projet — jamais dans une configuration globale. Ce n’est pas un détail de rangement : l’URL porte un port, et deux applications Nodefony ouvertes en même temps n’écoutent pas sur le même. Une déclaration globale en désignerait forcément une, au hasard. (Pour Mistral Vibe, l’équivalent par projet est .vibe/config.toml, section [[mcp_servers]].)

Cinq révisions servies — 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 — et le serveur répond celle que le client demande. Annoncer la plus récente à tout le monde rend la porte injoignable : un SDK qui ne connaît pas encore cette révision raccroche, et le serveur le plus conforme du monde ne parle alors à personne.

Trois choses à savoir avant de s’étonner d’un refus :

Les outils intégrés sont en lecture seule, et mcp.tools est une allowlist.

Mesurer l’application qui tourne — nodefony_observe#

Un agent de code qui vient de modifier un contrôleur ne voit pas l’effet de son changement : la barre de debug et la console d’administration affichent les mesures du profileur, mais il ne les lit pas. nodefony_observe les lui rend. Pendant une fenêtre de 1 à 50 secondes (10 par défaut), l’outil relève le profileur de développement toutes les deux secondes et rend un rapport :

  1. un verdict — ok, anomalies (N), et INCOMPLÈTE quand les chiffres ne portent pas sur tout (profils perdus, aucun trafic, appel interrompu), avec la raison ;
  2. les anomalies, nommées par route et par contrôleur : une requête SQL répétée dans une boucle (« N+1 »), les erreurs 5xx avec leur requestId et leurs lignes de journal, les routes dont le p95 dépasse 500 ms avec ce qui coûte par requête, un blocage de la boucle d’événements de 100 ms ou plus ;
  3. un tableau par route, les requêtes SQL les plus coûteuses, et un bloc JSON pour comparer deux appels — avant et après une correction.
{ "name": "nodefony_observe", "arguments": { "durationSec": 10 } }
// "route": "<nom de route>" pour n'en observer qu'une ; "includeTooling": true
// pour compter aussi les routes /nodefony/… de l'outillage

L’outil ne génère aucun trafic. On ouvre la page, on joue les tests ou on envoie des requêtes pendant que la fenêtre court ; sans trafic, il n’a rien à juger, et il le dit.

Ce que les chiffres veulent dire :

Ce qui encadre l’outil :

La lecture de chaque anomalie et le geste qui la corrige sont dans le skill d’agent nodefony-troubleshoot, section « Mesurer l’application qui tourne ».

Mesurer une route — nodefony_profile_route#

Là où nodefony_observe attend le trafic, nodefony_profile_route le produit : il envoie lui-même N requêtes GET (ou HEAD) sur un chemin de l’application, retrouve leurs profils par l’identifiant qu’il pose (x-request-id) et rend le même rapport. C’est la boucle « je modifie → je mesure » en un appel.

// tools/call
{
  "name": "nodefony_profile_route",
  "arguments": { "path": "/orders?page=2", "count": 50 },
}
// optionnel : "concurrency" (défaut 4, max 8), "warmup" (défaut 3, hors mesures),
//             "method": "HEAD", "as": "<identifiant>", "anonymous": true

path est un chemin, jamais une URL : hôte, schéma et // sont refusés, l’outil ne vise que l’application elle-même. Au plus 200 requêtes par appel — sous les 500 profils que garde le profileur — et 45 secondes de tir.

Au nom de qui. Une page protégée vue sans compte ne rend que des refus. Quand le jeton MCP porte le scope admin:write, l’outil joue son propriétaire : il ouvre une vraie session à son nom par le flux de connexion de l’application (un ticket à usage unique, présenté en boucle locale à POST /nodefony/devkit/profile-session), tire avec, puis la détruit — l’audit l’écrit sous la raison devkit. as joue un autre compte, à condition que ses rôles soient inclus dans ceux du propriétaire, hiérarchie comprise : jouer un compte ne monte jamais en droits. Sans ce scope, l’outil joue l’anonyme et le rapport donne la commande :

npx nodefony security:mcp:token --scope "admin:read admin:write" --write

Extrait de deux appels réels, avant puis après la correction d’un N+1 (la même lecture, une requête par compte, puis une seule requête $in) :

Verdict : anomalies (1) — observation complète
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)
Joué en tant que : `admin` (ROLE_ADMIN, ROLE_NODEFONY_ADMIN, ROLE_DEV, ROLE_SUPERVISOR)
Reçu par le client : 2xx 30 · 3xx 0 · 401 0 · 403 0 · autres 4xx 0 · 5xx 0 · sans réponse 0

Anomalies (les plus coûteuses d'abord) :
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`).

route | type | n | p50 | p95 | max | 2xx/3xx/4xx/5xx | SQL par requête moy/max | temps par requête
`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

— après correction —
Verdict : ok — observation complète
`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

Ce que le rapport dit en tête, avant tout chiffre : les requêtes refusées (401/403 — verdict « rien à juger »), redirigées (vers la page de connexion), restées sans réponse, et les profils non retrouvés. Une mesure qui ne porte pas sur ce qui était demandé le dit d’abord.

À savoir :

Obtenir le porteur — security:mcp:token#

Dès que la porte demande un jeton, il faut en produire un. L’application le signe elle-même, en ligne de commande, sans qu’aucun serveur n’écoute :

npx nodefony security:mcp:token --write   # pose NF_MCP_TOKEN chez les agents présents
npx nodefony ai:mcp --auth            # l'en-tête porte ${NF_MCP_TOKEN}, jamais le jeton

.mcp.json ne contient donc que le nom de la variable. Le flux complet — audience à déclarer, durée, rotation, et le refus qu’on rencontre en premier — vit dans obtenir un jeton.

Passer la porte sous autorisation OAuth 2.1#

Le périmètre ci-dessus suffit à un poste de développement. Dès que la porte doit répondre à quelqu’un d’autre, déclarez un serveur d’autorisation : elle devient alors un resource server au sens de la spécification — elle publie ses métadonnées (RFC 9728), valide le porteur, refuse en 401/WWW-Authenticate (RFC 6750) et vérifie l’audience (RFC 8707).

use("@nodefony/devkit", {
  mcp: {
    authorization: {
      authorizationServers: ["https://auth.example"],
      resource: "https://mon-app.example/nodefony/mcp",
    },
  },
});
Ce qui se passe alors Où
Le document de métadonnées est publié GET /.well-known/oauth-protected-resource/nodefony/mcp
Une requête sans jeton — ou sans jeton LISIBLE — est refusée 401 + WWW-Authenticate: Bearer resource_metadata="…"
Un jeton d’une autre audience n’ouvre rien 401 — ou servi en anonyme si anonymous: true
Un outil déclarant des scopes devient atteignable pour qui présente tous ces scopes
Les scopes publiés sont l’union de ceux des outils scopes_supported du document, et scope du défi

Le serveur d’autorisation n’est jamais à écrire : la spécification le place « beyond the scope […] or a separate entity ». N’importe quel émetteur OAuth 2.1 convient.

🔴 Ce module ne valide pas les jetons lui-même — il est policy: "dev" et ne porte aucune cryptographie. Il cherche un service accessTokenVerifier dans le conteneur (contrat IAccessTokenVerifier, exporté par nodefony). Sans lui, une porte déclarée protégée répond 503 et le journal le dit en CRITIC : accepter des porteurs sans les lire serait pire que rester anonyme.

Ajouter VOS outils — ce que l’agent ne peut pas deviner#

Les outils intégrés ci-dessus décrivent le framework. Ils ne savent rien de votre métier — et c’est précisément ce qu’un agent invente le plus mal. N’importe quel module de votre application peut donc en publier :

import { Module, type IMcpTool } from "nodefony";
import { mcpText } from "@nodefony/ai";

class Shop extends Module {
  getMcpTools(): IMcpTool[] {
    return [
      {
        name: "shop_stock",
        // ⭐ La description est ce qui DÉCLENCHE l'outil. Dire ce qu'il rend
        // ET quand s'en servir — un modèle n'appelle pas ce qu'il ne
        // comprend pas.
        description:
          "Stock réel d'une référence produit. À utiliser avant de proposer " +
          "une commande — la réponse vient de la base, pas d'un cache.",
        inputSchema: {
          type: "object",
          properties: { sku: { type: "string", description: "Référence" } },
          required: ["sku"],
        },
        // Il LIT la base et ne sort pas de l'application : il le dit, sinon la
        // spec le tient pour destructif et l'agent demande confirmation à
        // chaque lecture.
        annotations: { readOnlyHint: true, openWorldHint: false },
        handler: async (args) => mcpText(await this.stock(String(args.sku))),
      },
    ];
  }
}

Quatre choses à savoir :

Réserver un outil à qui est autorisé#

Un outil peut exiger des scopes (tous, pas au moins un) et/ou une identité prouvée. Son handler reçoit alors l’appelant en second paramètre, pour borner ce qu’il rend et pas seulement décider s’il répond :

{
  name: "shop_invoice",
  description: "Facture d'une commande.",
  inputSchema: { type: "object", properties: { id: { type: "string" } } },
  scopes: ["shop:read", "shop:billing"],   // ou : requiresAuth: true
  handler: async (args, caller) =>
    mcpText(await this.invoice(String(args.id), caller.subject)),
}

La spec le prévoit explicitement : le jeu d’outils « MAY vary by the authorization presented on the request — for example, returning only the tools the caller’s granted scopes permit », précisément parce que les identifiants sont une entrée de requête, pas un état de connexion. C’est pourquoi la liste est recollectée à chaque appel.

Le filtre s’applique à la collecte, donc en un seul point : un outil retenu est absent de tools/list et inappelable en le nommant, et le refus dit « outil inconnu » plutôt qu’« interdit » — son existence même n’est pas révélée.

🔴 Tant que la porte n’authentifie personne, un outil à scopes ne sortira jamais. C’est le comportement voulu — fermé par défaut — mais il faut le savoir avant de chercher une panne : caller vaut { authenticated: false, scopes: [] } tant que le rôle resource server décrit plus haut n’est pas branché. Le jour où il le sera, ces déclarations prendront effet sans qu’une ligne d’outil change.

⚠️ Cette porte n’est pas authentifiée (voir l’écart ci-dessus) et le module est policy: "dev". Avant d’exposer une donnée par un outil, se demander si elle supporterait d’être lue sans identification, par qui a accès à la machine.

Un outil LONG — progression et annulation#

Le troisième paramètre du handler sert l’outil qui dure : run.signal est abattu quand le client annule — qu’il ferme le flux de la réponse (révision courante de MCP) ou envoie notifications/cancelled (révision précédente) —, run.progress() lui donne signe de vie. Un outil court l’ignore ; un appel direct (un test) ne le fournit pas, d’où le run?. :

{
  name: "shop_reindex",
  description: "Réindexe le catalogue, lot par lot. Long : signale chaque lot.",
  inputSchema: { type: "object", properties: {} },
  handler: async (_args, _caller, run) => {
    const lots = await this.lots();
    for (const [i, lot] of lots.entries()) {
      if (run?.signal.aborted) return mcpText("interrompu", true);
      await this.reindex(lot, run?.signal);
      run?.progress(i + 1, lots.length, `lot ${i + 1}/${lots.length}`);
    }
    return mcpText(`${lots.length} lots réindexés`);
  },
}

Ce que la porte fait du reste :

Le journal notifications/message n’est pas proposé : la révision 2026-07-28 le déprécie (« New implementations SHOULD NOT adopt it »). Ce qu’un outil veut dire en route passe par le message de la progression ; ce qui doit atteindre le modèle va dans la réponse finale. subscriptions/listen est refusé par une erreur explicite (-32601) : le catalogue ne change qu’au rechargement de l’application, qui redémarre son process, si bien qu’un flux d’abonnement casserait au lieu de notifier. Le tools/list suivant rend l’état à jour.

Les skills d’agent — répondre à « comment fait-on ça, ici ? »#

La carte dit où aller. Elle ne dit pas comment faire. Or c’est là qu’un agent invente : faute d’une marche à suivre, il écrit un CRUD à la main, un service à méthodes static que le conteneur ne voit pas, un contrôle de droits dans le corps de l’action.

Le paquet livre donc onze skills au format Agent Skills : un qui porte la conduite commune de toute tâche, un qui part du symptôme d’une panne, et un par geste où l’invention coûte cher :

Skill Le besoin qu’il couvre
nodefony-dev conduire une tâche de bout en bout, avant toute modification
nodefony-add-crud exposer une ressource REST complète, entité comprise
nodefony-add-service ajouter de la logique métier réutilisable, vue du conteneur
nodefony-authentication connecter un utilisateur ou un programme, sans rien réécrire
nodefony-protect-route réserver une route à qui est habilité
nodefony-add-realtime-channel ouvrir un canal temps réel où le serveur pousse
nodefony-browser voir et mesurer un écran, sans navigateur sur le poste
nodefony-migrate-schema faire évoluer le schéma d’une base et le porter en production
nodefony-devops exploiter l’application en conteneur, sans réécrire son contrat
nodefony-troubleshoot partir d’un symptôme de panne pour trouver le geste qui la règle
nodefony-security-review relire la sécurité d’un diff avant de le commiter
nodefony-upgrade monter le framework de version sans rien laisser derrière

Tous portent le préfixe nodefony- : leurs pointeurs arrivent dans le dossier où vous écrivez aussi les vôtres, et sans namespace un skill maison du même nom serait écrasé à la synchronisation suivante.

nodefony create app les met à disposition à la création ; après une montée de version, npx nodefony ai:sync les remet à jour (--dry-run montre sans écrire).

Ce qui est écrit dans le projet est un pointeur, jamais une copie. Le contenu reste dans le paquet installé et suit npm update : une recette recopiée dans un projet décrit, six mois plus tard, un framework qui a changé — et comme rien ne casse, personne ne s’en aperçoit. C’est le même principe que le reste du devkit : rien de figé n’est copié chez l’utilisateur.

Le dossier visé — .agents/skills/ — est celui que tous les clients conformes lisent, plutôt que le dossier propriétaire d’un seul d’entre eux. Ces fichiers sont faits pour être commités : l’équipe entière et l’intégration continue travaillent alors avec les mêmes recettes.

Aucun postinstall ne les pose : --ignore-scripts est courant, les scripts d’installation sont un vecteur d’attaque connu de l’écosystème npm, et écrire dans un dossier versionné à chaque installation produirait des différences surprises.

Ce qu’il ne fait pas#

Le scaffold (nodefony create …), le diagnostic (nodefony doctor) et l’introspection (nodefony inspect) ne sont pas ici : ils vivent dans le cœur, parce qu’ils doivent répondre sans qu’aucun module soit installé — et surtout quand l’application est cassée. Un outil de diagnostic qui exige que l’application démarre ne sert pas au moment où on en a besoin.

Même partage pour les skills, et il se retient en une phrase : le VERBE vit dans le cœur, le CONTENU dans ce paquet. ai:sync doit répondre dans un terminal qui n’a rien posé (portée par un module policy: "dev", elle serait absente sans NODE_ENV) ; les skills, eux, doivent se mettre à jour par npm.

Configuration#

Clé Type Défaut Rôle
enabled boolean true Interrupteur du module
mcp.enabled boolean true Répond-on aux requêtes MCP ? Coupé → 404
mcp.allowedOrigins string[] [] Origines de navigateur admises ; vide = aucune
mcp.allowRemote boolean false Accepter un appel d’une adresse non locale
mcp.tools string[] les sept outils intégrés (BUILTIN_MCP_TOOL_KEYS) Allowlist des outils — lecture seule

Les clés, leurs types et leurs défauts viennent du schéma Zod (nodefony/config/config.ts) — source unique dont dérivent la documentation, la validation au boot et le formulaire d’édition de Studio.

Pour aller plus loin#