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

Boîte à outils MCP — exposer les outils d'une application à un agent

stable@nodefony/aimis à jour 2026-10-22

Un agent d’intelligence artificielle (Claude Code, Cursor, un agent maison) appelle les outils d’un logiciel par le Model Context Protocol. Cette boîte à outils fait de chaque module Nodefony un fournisseur d’outils : tu écris une fonction, elle décrit ce qu’elle rend, et le protocole — versions, erreurs, autorisation, progression, annulation — est tenu pour toi, une fois, selon la norme. Ancrée sur src/packages/@nodefony/ai/nodefony/mcp/ et src/nodefony/src/types/IMcpTool.ts.

📍 Documentation › @nodefony/ai › Boîte à outils MCP

🗺️ Schéma général#

Trois étages, trois responsabilités : le protocole (ce fichier le décrit), la porte (une route HTTP qui le sert), les outils (ce que tes modules déclarent). Le protocole ne sait rien du transport ; la porte ne sait rien des outils ; un outil ne sait rien des deux.

📖 Lexique#

Terme Développé En une ligne
MCP Model Context Protocol le protocole par lequel un agent découvre et appelle les outils d’un logiciel
JSON-RPC JSON Remote Procedure Call le format des messages : une requête { id, method, params }, une réponse { id, result } ou { error }
SSE Server-Sent Events une réponse HTTP qui reste ouverte et pousse des événements au fil de l’eau, dans un seul sens
Porte — la route qui reçoit les messages MCP et les confie au protocole
Outil tool une capacité appelable par l’agent : un nom, une description, un schéma d’arguments, une fonction
Appelant caller ce que la porte a ÉTABLI de qui appelle : authentifié ou non, ses scopes, ses rôles
Scope — un droit porté par un jeton OAuth (admin:read) ; un outil peut en exiger
Jeton de progression progressToken l’étiquette que l’agent pose sur sa requête pour recevoir des nouvelles en route
Révision protocol version la version datée de la norme (2026-07-28) que client et serveur négocient
RFC 9728 OAuth Protected Resource Metadata le document qui dit à un client quel serveur d’autorisation délivre les jetons de cette porte

Qu’est-ce que MCP — et pourquoi un agent en a besoin#

Un agent qui travaille sur ton application a deux façons de la connaître : lire ses sources, ou lui demander. Lire les sources donne ce que le code dit ; demander à l’application qui tourne donne ce qu’elle fait — les routes réellement montées, la configuration effective, la donnée en base. Un guichet plutôt qu’un plan : le plan peut être périmé, le guichetier ne l’est jamais.

MCP standardise ce guichet. L’agent demande la liste des outils (tools/list), lit leur description — c’est elle qui décide s’il s’en servira — puis en appelle un (tools/call) avec des arguments conformes au schéma publié. La réponse est du contenu (souvent du texte), et un échec métier se dit dans la réponse (isError) plutôt que par une erreur de protocole : « ta demande est recevable, voici pourquoi elle n’aboutit pas » est une phrase que l’agent sait corriger seul.

La vision Nodefony#

🚀 Démarrage rapide#

Ouvrir la porte MCP de ton application — elle se génère, avec deux outils d’exemple et son test :

npx nodefony create controller mcp --kind mcp                  # porte locale : la machine seulement
npx nodefony create controller mcp --kind mcp --expose oauth   # production : jeton exigé (401 sans)
npx nodefony create controller mcp --kind mcp --expose mixte   # outils publics sans jeton, les autres réservés à un scope

Tu ajoutes ensuite tes outils à la liste de nodefony/mcp/mcp-tools.ts ; la porte (nodefony/mcp/mcp-gate.ts) publie cette liste, et rien d’autre. Chaque outil y porte ses annotations — dit s’il lit ou s’il modifie — et le test généré (tests/mcp-gate.test.ts) refuse celui qui les omet : Dire ce que fait l’outil. Détail des deux expositions : Monter une porte.

Offrir des outils sans ouvrir de porte — un module les DÉCLARE par getMcpTools(), et toute porte en tools: "modules" les ramasse : c’est le cas de la porte de développement de @nodefony/devkit, donc de l’agent de code du développeur. L’exemple ci-dessous expose ainsi deux outils : une lecture courte, et une opération longue qui signale son avancement et s’arrête si l’agent abandonne.

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

/** Le module « shop » d'une application — il offre deux outils à l'agent. */
export class ShopModule extends Module {
  constructor(kernel: Kernel) {
    super("shop", kernel, import.meta.url, {});
  }

  /** Les outils MCP de ce module — relus à chaque requête de la porte. */
  getMcpTools(): IMcpTool[] {
    return [
      {
        name: "shop_stock",
        description:
          "Stock réel d'une référence produit, lu en base. À utiliser avant " +
          "de proposer une commande — la réponse ne vient pas d'un cache.",
        inputSchema: {
          type: "object",
          properties: { sku: { type: "string", description: "Référence" } },
          required: ["sku"],
        },
        // Lecture dans la base de l'application : rien n'est modifié.
        annotations: { readOnlyHint: true, openWorldHint: false },
        handler: (args) => {
          const sku = typeof args.sku === "string" ? args.sku : "";
          if (sku === "") return mcpText("`sku` est requis", true);
          return mcpText({ sku, available: 12 });
        },
      },
      {
        name: "shop_reindex",
        description:
          "Réindexe le catalogue, lot par lot. Long : signale chaque lot, et " +
          "s'arrête proprement si l'appel est abandonné.",
        inputSchema: { type: "object", properties: {} },
        // Une écriture qui reconstruit l'index sans rien perdre, et que
        // rejouer laisse dans le même état.
        annotations: {
          readOnlyHint: false,
          destructiveHint: false,
          idempotentHint: true,
          openWorldHint: false,
        },
        handler: async (_args, _caller, run) => {
          const lots = 5;
          for (let i = 1; i <= lots; i++) {
            if (run?.signal.aborted) return mcpText("interrompu", true);
            await new Promise((resolve) => setTimeout(resolve, 200));
            run?.progress(i, lots, `lot ${i}/${lots} réindexé`);
          }
          return mcpText(`${lots} lots réindexés`);
        },
      },
    ];
  }
}

Ce qu’on observe sur la porte de développement (@nodefony/devkit, POST /nodefony/mcp) — en JSON pour la lecture courte, en flux pour l’outil long quand l’agent le demande :

# Lecture courte : une réponse JSON
curl -sk https://localhost:5152/nodefony/mcp -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"shop_stock","arguments":{"sku":"A-12"}}}'

# Outil long : jeton de progression + flux accepté → notifications, puis la réponse qui clôt le flux
curl -skN https://localhost:5152/nodefony/mcp -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"shop_reindex","arguments":{},"_meta":{"progressToken":"r1"}}}'
# data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"r1","progress":1,"total":5,"message":"lot 1/5 réindexé"}}
# …
# data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"5 lots réindexés"}]}}

Interrompre le second curl (Ctrl+C) ferme le flux : run.signal est abattu, et l’outil rend la main au lot suivant.

Sans jeton de progression, un outil long répond quand même en flux. Au-delà du délai de grâce (MCP_STREAM_GRACE_MS, 5 s), la porte ouvre le flux sans rien y écrire — le flux bat de lui-même — et la réponse finale y part. Ce n’est pas une option : la norme (transports/streamable-http) oblige le client à accepter une réponse en flux, et seul un flux ouvert désarme le délai d’inactivité de la requête (30 s par défaut). Une réponse JSON qui tarde tomberait en 408, quel que soit le travail en cours. Un outil qui répond avant le délai de grâce reste en JSON.

✍️ Écrire un outil#

Le contrat#

Champ Rôle
name identifiant d’appel, forme [a-zA-Z0-9_-]{1,64} (NAME_PATTERN, src/packages/@nodefony/ai/nodefony/mcp/tools.ts#NAME_PATTERN) ; préfixe-le par ton module
description le premier critère de déclenchement : ce que l’outil REND et QUAND s’en servir
inputSchema schéma JSON des arguments ; tout argument non déclaré est REFUSÉ, jamais ignoré
annotations ce que l’outil FAIT (lit ou modifie, efface ou ajoute, sort ou non de l’application) — à poser sur chaque outil : les annotations
scopes scopes OAuth exigés — tous ; absent = outil public
requiresAuth exige une identité prouvée, sans scope particulier
handler (args, caller, run?) => IMcpToolResult — synchrone ou promesse

Un module écrit en JavaScript n’est contrôlé par aucun compilateur : un champ de mauvaise forme (requiresAuth: "oui", scopes: "shop:read", description absente) fait écarter l’outil à la déclaration, motif dit au journal : elle n’est jamais interprétée. Côté appel, des arguments qui ne sont pas un objet sont refusés en -32602.

Trois règles qui évitent les défauts les plus coûteux :

Un outil écarté à la collecte — nom hors forme, nom déjà pris, handler absent, déclaration qui lève — le dit en WARNING dans le journal du serveur ; les outils intégrés gagnent toute collision.

Dire ce que fait l’outil — les annotations#

Avant d’appeler un outil, l’agent (ou son client) doit décider s’il demande confirmation à l’humain. La spec lui donne pour cela des indices de comportement, les ToolAnnotations (révision 2026-07-28, schema/schema.ts ; spec/server/tools.mdx, champ annotations de l’outil). Nodefony les porte dans le contrat (IMcpToolAnnotations, src/nodefony/src/types/IMcpTool.ts#IMcpToolAnnotations) et les publie dans tools/list.

Indice Ce qu’il promet Défaut de la spec N’a de sens que…
title un titre lisible, préféré au name par les interfaces — toujours
readOnlyHint l’outil ne modifie rien de son environnement false toujours
destructiveHint une écriture peut détruire ; false = il ne fait qu’ajouter true si readOnlyHint = false
idempotentHint le rappeler avec les mêmes arguments n’a aucun effet de plus false si readOnlyHint = false
openWorldHint il touche un monde extérieur ouvert (web, API tierce) true toujours

Lire les défauts, c’est comprendre pourquoi il faut annoter. Un outil sans annotation est, pour la spec, un outil qui écrit, détruit, et sort de l’application. Un client prudent demande alors confirmation pour chaque lecture, ce qui le rend inutilisable ; un client pressé n’en demande jamais, ce qui le rend dangereux. Deux règles en découlent :

Choisir les indices — partir de ce que fait le handler, jamais de son nom. Une seule question par indice, posée sur le CODE de l’outil :

Le handler… readOnlyHint destructiveHint idempotentHint openWorldHint
lit la base ou un service de l’application, calcule, formate true — — false
lit une API tierce ou le web (météo, cours de bourse) true — — true
crée une ligne, envoie un courriel, rembourse, met en file false false false selon la cible
remplace une valeur par une autre (set, upsert, PUT) false true true selon la cible
supprime (delete, purge, révocation) false true true selon la cible
reconstruit un dérivé sans perte (index, cache, vue) false false true false

openWorldHint vaut true dès qu’un appel quitte l’application vers un monde qu’elle ne maîtrise pas : envoyer un courriel, appeler une API de paiement, publier un message. Une écriture dans sa propre base reste false. Un « — » : l’indice n’a pas de sens pour une lecture, le client l’ignore ; ne pas le poser.

Les erreurs qui coûtent, de la plus grave à la plus bénigne :

  1. readOnlyHint: true sur un outil qui écrit, même un peu (un compteur de consultation, un horodatage « vu le ») : le client exécute sans confirmation ce que l’humain aurait voulu valider. C’est un mensonge au client — l’annotation doit décrire le pire de ce que fait le handler, pas son intention principale.
  2. destructiveHint: false sur un outil qui écrase : « mettre à jour l’adresse » détruit l’ancienne adresse. Remplacer, c’est effacer.
  3. Aucune annotation : le client tient l’outil pour le pire cas — exactement le problème que les annotations résolvent. Le test généré par nodefony create controller … --kind mcp (tests/<nom>-gate.test.ts) refuse un outil sans readOnlyHint ni openWorldHint, et un outil qui écrit sans destructiveHint : la question se pose à l’ajout, pas en production.
  4. Recopier les annotations d’un outil voisin : elles décrivent CE handler. Un outil copié puis modifié garde des promesses qui ne sont plus vraies.

Un outil qui change de nature (une lecture qui se met à écrire) change d’annotations dans le même commit : c’est un changement de comportement que l’agent ne peut voir que par elles.

const refund: IMcpTool = {
  name: "shop_refund",
  description: "Rembourse une commande du client authentifié.",
  inputSchema: { type: "object", properties: { id: { type: "string" } } },
  scopes: ["shop:billing"],
  annotations: {
    title: "Rembourser une commande",
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: false,
    openWorldHint: false,
  },
  handler: (args) => mcpText({ refunded: args.id }),
};

Ce que Nodefony fait, et ne fait pas :

Réserver un outil#

const invoice: IMcpTool = {
  name: "shop_invoice",
  description: "Facture d'une commande du client authentifié.",
  inputSchema: { type: "object", properties: { id: { type: "string" } } },
  scopes: ["shop:billing"],
  annotations: { readOnlyHint: true, openWorldHint: false },
  handler: (args, caller) =>
    mcpText({ id: args.id, owner: caller.subject ?? null }),
};

caller (IMcpCaller, IMcpTool.ts#IMcpCaller) porte ce que la porte a établi — jamais ce que la requête prétend : authenticated, scopes, roles, subject. Une porte qui n’authentifie personne rend un appelant anonyme, et un outil à scopes n’y sort jamais : fermé par défaut — à savoir avant de chercher une panne.

Agir au nom de l’utilisateur — actsForUser#

caller dit ce que la PORTE a prouvé : un sujet et des scopes. Presque toute logique métier veut davantage — le COMPTE, ses vrais rôles, et des services qui le voient (« mes commandes », un droit, une donnée filtrée par propriétaire). Un outil le demande en une ligne :

import { RequestContext } from "nodefony";

const myOrders: IMcpTool = {
  name: "shop_my_orders",
  description: "Commandes du client au nom duquel l'agent agit.",
  inputSchema: { type: "object", properties: {} },
  annotations: { readOnlyHint: true, openWorldHint: false },
  actsForUser: true, // le compte du propriétaire du jeton, posé AVANT le handler
  scopes: ["shop:read"], // OBLIGATOIRE avec actsForUser — voir ci-dessous
  handler: async () => {
    const user = RequestContext.getAuthenticatedUser(); // le compte, rôles compris
    return mcpText(await orders.listFor(user)); // le service le voit aussi
  },
};

Ce que la porte fait pour un tel outil (gate.ts#runAsAccount) :

Un paramètre qui élargit l’outil — scopesFor#

Un outil qu’un jeton de LECTURE doit voir peut avoir un paramètre qui fait davantage : agir pour un autre compte, écrire au lieu de lire. Le mettre dans scopes le cacherait à ce jeton. Le scope se déclare alors à l’APPEL, selon les arguments :

const report: IMcpTool = {
  name: "shop_report",
  // …
  scopes: ["shop:read"], // pour voir l'outil
  scopesFor: (args) => (args.publish === true ? ["shop:write"] : []), // pour CET appel
  handler: (args) => mcpText(args.publish === true ? publish() : preview()),
};

Un outil LONG — progression et annulation#

Le troisième paramètre, run (IMcpToolRun, IMcpTool.ts#IMcpToolRun), sert l’outil qui dure :

run est optionnel pour l’appelant (un test qui appelle handler(args, caller) directement s’en passe), d’où l’écriture run?.progress(…).

Ce qu’il ne faut PAS chercher : le journal notifications/message. 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, et ce qui doit atteindre le modèle va dans la réponse finale — rien ne garantit qu’un client fasse entrer les notifications dans son contexte.

🚪 Monter une porte#

@nodefony/ai ne monte aucune route : une porte se déclare, par qui en a besoin. La porte de développement est celle de @nodefony/devkit (DevkitService.mcpGate(), DevkitService.ts#DevkitService.mcpGate, servie par McpController.mcp(), McpController.ts#McpController.mcp, policy: "dev"). Une application ouvre la SIENNE — en développement ou en production, selon ses besoins — en deux temps : la porte se définit une fois (defineMcpGate(), gate.ts#defineMcpGate), puis se sert d’une ligne depuis une action de controller. npx nodefony create controller <nom> --kind mcp écrit les deux, avec ses outils d’exemple et son test ; --expose choisit qui peut frapper :

--expose Qui passe Ce qui est écrit en plus
local (défaut) la machine seulement, sans jeton ; page web → 403 rien : en production derrière un relais, tout appelant est distant, donc refusé
oauth quiconque présente un jeton émis pour la porte l’émetteur et l’URI publique (publicBaseUrl), allowRemote, le service qui publie le document RFC 9728 du défi 401, et l’outil <nom>_me qui agit au nom du compte (actsForUser, scope <nom>:read)
mixte tout le monde pour les outils publics ; un jeton porteur de leurs scopes pour les autres ce qu’écrit oauth, plus anonymous: true et scopes: ["<nom>:write"] sur l’outil long — le jeton s’émet avec --scope <nom>:write

Une porte publie ce qu’elle CHOISIT : une liste d’outils importée, comme ci-dessous. tools: "modules" servirait en plus tout outil qu’un module installé déclare, aujourd’hui ou demain — à réserver à l’outillage, jamais à une porte exposée. Aucune ligne de protocole à écrire :

import { Controller, route, controller, Body } from "@nodefony/framework";
import { defineMcpGate } from "@nodefony/ai";
import { stockTool, orderTool } from "./tools";

// Définie UNE fois : validée au chargement — une clé mal écrite, une révision
// du protocole inconnue, un outil mal formé arrêtent le démarrage en la nommant.
const shopGate = defineMcpGate({
  name: "shop", // le nom que l'agent voit
  instructions: "Stock et commandes du magasin.", // QUAND l'agent doit s'en servir
  tools: [stockTool, orderTool], // ce que l'agent peut appeler
});

@controller("/api")
class ShopMcpController extends Controller {
  // POST sert le message ; GET et DELETE répondent 405 (verbes retirés par la spec).
  @route("shop-mcp", {
    path: "/mcp",
    requirements: { methods: ["POST", "GET", "DELETE"] },
  })
  mcp(@Body() body: unknown) {
    return shopGate.serve(this, body);
  }
}
Réglage Défaut Ce qu’il décide
tools — (requis) une liste d’outils, ou "modules" pour ceux que déclarent les modules chargés (getMcpTools())
name, version, instructions nom du projet, version de Nodefony, phrase neutre ce que l’agent lit en arrivant
allowRemote, allowedOrigins false, [] qui peut frapper : la machine locale seulement, aucune page web
authorization aucune serveur(s) d’autorisation et URI de la porte : un jeton est alors exigé
unprotectedRoles [] rôles accordés SANS jeton — refusé à côté d’une authorization
protocol.versions toutes celles du paquet figer les révisions acceptées (porte de production)
cache { ttlMs: 0, scope: "private" } indices de cache des listes, servis aux clients modernes
enabled true coupée, la porte répond 404

⚠️ Figer protocol.versions sur la seule révision 2026-07-28 coupe les clients qui parlent encore l’ancienne forme (initialize) — c’est le cas des clients déployés aujourd’hui.

Ce que la mécanique tient pour toi (gate.respond(), gate.ts#IMcpGate.respond, éprouvable sans serveur) :

Niveau 2 — de vraies requêtes HTTP au nom d’un compte#

actsForUser suffit tant que l’outil appelle des services. Un outil qui doit TIRER de vraies requêtes vers l’application (profiler une page protégée, rejouer un parcours) a besoin d’une session : celle que le pare-feu reconnaît, ouverte par le flux de connexion de l’application, qui revérifie le compte et écrit l’audit. defineTicketSessionGate (ticketSession.ts#defineTicketSessionGate) en est la mécanique, sans route — comme defineMcpGate :

import { defineTicketSessionGate } from "@nodefony/ai";

// Le facteur nomme la connexion dans l'audit (`login.success`, raison « replay »).
export const replayGate = defineTicketSessionGate({ factor: "replay" });

@controller("/api/replay")
class ReplaySessionController extends Controller {
  // Chemin LITTÉRAL : un inventaire des routes doit le voir sans exécuter.
  @route("replay-session", {
    path: "/session",
    requirements: { methods: ["POST"] },
  })
  session() {
    return replayGate.serve(this);
  }
}

L’outil émet un ticket (replayGate.issue(identifiant)), le présente en boucle locale dans l’en-tête x-nodefony-session-ticket, récupère le cookie de la réponse, tire ses requêtes, puis détruit la session (replayGate.close(sessions, ref)). La réponse se lit par readTicketSessionVerdict, écrit à côté de qui la produit.

🔌 Deux transports — HTTP ou stdio#

La norme MCP définit deux façons de joindre un serveur, et le client ne choisit pas : sa configuration le dit. Une entrée qui porte une url se joint en HTTP ; une entrée qui porte une command est un programme que le client LANCE lui-même, et auquel il parle sur l’entrée et la sortie standard (stdio).

L’agent travaille… Transport Ce qu’on écrit
sur ta machine, application démarrée (nodefony dev) HTTP — la porte /nodefony/mcp de devkit npx nodefony ai:mcp écrit .mcp.json, rien à choisir
là où aucun serveur ne tourne : CI, agent cloud dans un runner, client stdio seul stdio — npx nodefony ai:stdio la commande, dans la configuration MCP de l’agent

Pour qui stdio ? Un agent qui code dans une machine virtuelle — l’agent de GitHub qu’on assigne à une issue, un agent de relecture lancé en CI — n’a pas de nodefony dev derrière lui : localhost n’y répond pas, et sans stdio il travaille sans aucun outil de l’application. Avec stdio, il lance la commande, l’application démarre en console (aucun port ouvert), et il retrouve les mêmes outils :

{
  "mcpServers": {
    "nodefony": { "command": "npx", "args": ["nodefony", "ai:stdio"] }
  }
}

(Un client qui type ses entrées — "type": "local", "type": "stdio" — prend la même command et les mêmes args.)

Ce que stdio sert, et ce qu’il ne sert pas :

Une application qui veut SA commande stdio, sur sa propre porte, n’a qu’une ligne à écrire — serveMcpStdio(gate, { input: process.stdin, write }) (stdio.ts#serveMcpStdio) ; la règle vit dans la porte (gate.respondStdio, gate.ts#defineMcpGate), la boucle ne fait que lire et écrire des lignes.

📏 Normes appliquées — ce que la boîte à outils tient pour toi#

Révision de référence : 2026-07-28 (MCP_PROTOCOL_VERSION, protocol.ts#MCP_PROTOCOL_VERSION), servie avec les révisions antérieures que les clients déployés emploient encore (MCP_SUPPORTED_VERSIONS, protocol.ts#MCP_SUPPORTED_VERSIONS).

Exigence de la norme Comment elle est tenue
une notification acceptée → 202 sans corps handleMcpMessage() rend { status: 202, body: null }
l’id d’une requête est une chaîne ou un entier, jamais null refus 400 + -32600 avant tout autre contrôle
params est un objet nommé un tableau → -32602, une valeur primitive → -32600
en-tête MCP-Protocol-Version ≠ _meta → refus 400 + -32020 (McpProtocolError, protocol.ts#McpProtocolError) — deux sources de vérité pour une même requête
révision inconnue → refus avec la liste servie 400 + -32022 et supported (checkProtocolVersion(), server.ts#checkProtocolVersion)
initialize répond la révision DEMANDÉE quand elle est servie negotiateVersion() (server.ts#negotiateVersion) — annoncer la sienne rendait la porte injoignable
ère moderne : resultType sur chaque résultat eraResult() (server.ts#eraResult) donne au résultat la forme de l’ère du CLIENT
méthode non implémentée → 404 + -32601 cas par défaut du protocole
progression : jeton fourni, valeur croissante, cadence bornée, arrêt à la fin createProgressReporter() (src/packages/@nodefony/ai/nodefony/mcp/progress.ts#createProgressReporter) ; arrêt dans le finally de tools/call (server.ts#handleMcpMessage)
flux : notifications liées à la requête, réponse finale qui le termine createMcpResponseStream() (stream.ts#createMcpResponseStream)
sur HTTP, fermer le flux = annuler run.signal = le signal de la requête
le catalogue peut varier selon l’autorisation, jamais selon la connexion collecte à chaque requête, sur les identifiants de CETTE requête

🚫 Ce qui n’est pas servi — et pourquoi#

Méthode / capacité Réponse Raison
subscriptions/listen 404 + -32601, raison dite le catalogue ne change qu’au rechargement de l’application, qui redémarre son process : le flux casserait au lieu de notifier ; le tools/list suivant est à jour
notifications/message (journal) jamais émis déprécié par la révision 2026-07-28
server/discover -32601 servi, il faisait basculer le client dominant sur un fil qu’il n’achève pas (aucun outil enregistré) ; le repli vers initialize que prévoit la norme, lui, fonctionne
GET / DELETE sur la porte 405 + Allow: POST flux autonome et fin de session, retirés par la révision 2026-07-28

🔐 Sécurité#

⚡ Performance & mémoire#

⚠️ Pièges#

Symptôme Cause Correction
mon outil n’apparaît pas dans tools/list il exige des scopes, et la porte n’authentifie pas — ou son nom est hors forme lire le journal : WARNING (écarté) ou DEBUG (retenu, avec la raison)
l’agent reçoit « outil inconnu » sur un outil existant même cause : un outil retenu est inconnu pour cet appelant, c’est voulu présenter un jeton portant les scopes déclarés
aucune notification de progression n’arrive la requête ne porte pas _meta.progressToken, ou le client n’accepte pas text/event-stream les deux sont nécessaires ; sans jeton, un outil de plus de 5 s répond quand même en flux, mais sans notifications — c’est le comportement normal
un outil long reçoit 408 au bout de 30 s le client n’accepte pas text/event-stream : la porte ne peut pas ouvrir de flux, la réponse JSON tombe sous le délai d’inactivité un client conforme l’accepte toujours ; sinon, borner l’outil sous le délai (http.timeout)
la progression s’arrête alors que l’outil avance des valeurs qui ne croissent pas, ou plus d’une par 100 ms passer une valeur strictement croissante ; regrouper les étapes rapprochées
un argument passé « ne fait rien » il n’est pas déclaré dans inputSchema — l’appel est refusé en isError le déclarer, ou additionalProperties: true si l’outil prend des arguments libres
un échec métier apparaît comme une panne serveur (-32603) le handler a levé une exception rendre mcpText(message, true)
le client demande confirmation pour une simple lecture l’outil ne porte pas annotations : la spec le tient pour une écriture destructive poser readOnlyHint: true (et openWorldHint: false s’il reste dans l’application)
le client exécute sans confirmation un outil qui écrit readOnlyHint: true posé sur un handler qui modifie quelque chose readOnlyHint: false, puis destructiveHint selon ce qu’il efface
l’outil continue de tourner après l’abandon de l’agent il ne consulte pas run.signal le tester entre deux étapes, ou le passer aux appels qui l’acceptent

🧪 Tests & couverture#

Trois étages, chacun dans le paquet qui porte ce qu’il éprouve :

Ces deux suites ne se SAUTENT pas en silence : sans serveur, elles le disent, et en CI leur rapporteur transforme le saut en échec (« couverture incomplète »).

Chaque garde de la progression et du flux a été vue échouer en la débranchant. Ce qui n’est pas couvert : aucun banc de charge dédié à la porte, et aucun test du flux derrière un proxy inverse (la tenue d’un flux SSE par un proxy est traitée dans Server-Sent Events). Couverture : npm run coverage dans src/packages/@nodefony/ai.

🔗 Pour aller plus loin#