@nodefony/ai 0.0.0-stage → 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 (40) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +123 -2
  3. package/dist/_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js +8 -0
  4. package/dist/index.js +37 -0
  5. package/dist/nodefony/command/StdioCommand.js +75 -0
  6. package/dist/nodefony/config/config.js +181 -0
  7. package/dist/nodefony/config/configSchema.js +18 -0
  8. package/dist/nodefony/config/defineModuleConfig.js +51 -0
  9. package/dist/nodefony/mcp/caller.js +38 -0
  10. package/dist/nodefony/mcp/gate.js +628 -0
  11. package/dist/nodefony/mcp/guard.js +82 -0
  12. package/dist/nodefony/mcp/progress.js +74 -0
  13. package/dist/nodefony/mcp/protocol.js +76 -0
  14. package/dist/nodefony/mcp/server.js +302 -0
  15. package/dist/nodefony/mcp/sessionTicket.js +89 -0
  16. package/dist/nodefony/mcp/stdio.js +64 -0
  17. package/dist/nodefony/mcp/stream.js +80 -0
  18. package/dist/nodefony/mcp/ticketSession.js +177 -0
  19. package/dist/nodefony/mcp/tools.js +485 -0
  20. package/dist/nodefony/src/errors/AiError.js +20 -0
  21. package/dist/types/index.d.ts +56 -0
  22. package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
  23. package/dist/types/nodefony/config/config.d.ts +44 -0
  24. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  25. package/dist/types/nodefony/config/defineModuleConfig.d.ts +43 -0
  26. package/dist/types/nodefony/mcp/caller.d.ts +54 -0
  27. package/dist/types/nodefony/mcp/gate.d.ts +235 -0
  28. package/dist/types/nodefony/mcp/guard.d.ts +89 -0
  29. package/dist/types/nodefony/mcp/progress.d.ts +48 -0
  30. package/dist/types/nodefony/mcp/protocol.d.ts +107 -0
  31. package/dist/types/nodefony/mcp/server.d.ts +156 -0
  32. package/dist/types/nodefony/mcp/sessionTicket.d.ts +60 -0
  33. package/dist/types/nodefony/mcp/stdio.d.ts +38 -0
  34. package/dist/types/nodefony/mcp/stream.d.ts +76 -0
  35. package/dist/types/nodefony/mcp/ticketSession.d.ts +174 -0
  36. package/dist/types/nodefony/mcp/tools.d.ts +232 -0
  37. package/dist/types/nodefony/src/errors/AiError.d.ts +14 -0
  38. package/docs/index.md +56 -0
  39. package/docs/mcp.md +754 -0
  40. package/package.json +83 -4
@@ -0,0 +1,82 @@
1
+ // nodefony/mcp/guard.ts
2
+ /**
3
+ * Une adresse est-elle celle de la machine locale ?
4
+ *
5
+ * Couvre les trois formes que Node rend selon la pile réseau : IPv4
6
+ * (`127.x.x.x`), IPv6 (`::1`), et l'IPv4 encapsulée en IPv6
7
+ * (`::ffff:127.0.0.1`) — la plus fréquente sur un serveur à double pile, et
8
+ * celle qu'une comparaison naïve à `"127.0.0.1"` rate.
9
+ *
10
+ * @param address - `socket.remoteAddress`, éventuellement absent
11
+ * @returns `true` si l'appel vient de cette machine
12
+ */
13
+ /**
14
+ * Les en-têtes qu'un relais pose — leur présence suffit à dire que la requête
15
+ * n'est pas arrivée directement, quelle que soit l'adresse de la connexion.
16
+ */
17
+ const RELAY_HEADERS = [
18
+ "forwarded",
19
+ "x-forwarded-for",
20
+ "x-real-ip",
21
+ "via"
22
+ ];
23
+ /**
24
+ * La requête a-t-elle traversé un relais (proxy inverse, répartiteur) ?
25
+ *
26
+ * Un relais posé sur la MÊME machine fait arriver un client distant depuis la
27
+ * boucle locale : l'adresse de la connexion ne prouve alors plus rien. Toute
28
+ * porte qui exige la localité refuse donc aussi l'appel relayé — la porte MCP
29
+ * comme la porte de session par ticket, une seule règle.
30
+ *
31
+ * @param headers - en-têtes reçus (`request.headers` de Node)
32
+ * @returns `true` si un en-tête de relais non vide est présent
33
+ */
34
+ function isRelayedRequest(headers) {
35
+ return RELAY_HEADERS.some((name) => {
36
+ const value = headers[name];
37
+ const first = Array.isArray(value) ? value[0] : value;
38
+ return first !== void 0 && first !== "";
39
+ });
40
+ }
41
+ function isLocalAddress(address) {
42
+ if (!address) return false;
43
+ const bare = address.startsWith("::ffff:") ? address.slice(7) : address;
44
+ if (bare === "::1" || bare === "localhost") return true;
45
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare);
46
+ }
47
+ /**
48
+ * Décide si un appel MCP peut être servi.
49
+ *
50
+ * ⭐ **La règle sur `Origin` est contre-intuitive, et c'est elle qui protège.**
51
+ * Un client MCP légitime est un *process* (Cursor, Claude Code, un agent tiers)
52
+ * : il n'envoie **aucun** `Origin`, cet en-tête étant posé par les navigateurs.
53
+ * Une page web malveillante, elle, en pose **toujours** un lorsqu'elle vise
54
+ * `https://localhost:5152`. Donc : *absent* → on passe ; *présent et hors
55
+ * allowlist* → `403`. C'est ce qui referme le DNS rebinding, le seul vecteur
56
+ * réel contre un serveur MCP local.
57
+ *
58
+ * L'ordre des contrôles est délibéré : la localité d'abord, parce qu'un appel
59
+ * distant ne doit même pas apprendre quelles origines sont admises.
60
+ *
61
+ * @param input - ce que la requête présente
62
+ * @param policy - les réglages du module
63
+ * @returns le verdict, avec le motif quand il refuse
64
+ */
65
+ function checkMcpAccess(input, policy) {
66
+ if (!policy.allowRemote && !isLocalAddress(input.remoteAddress)) return {
67
+ allowed: false,
68
+ why: `adresse non locale (${input.remoteAddress ?? "inconnue"}) — voir l'option \`allowRemote\` du serveur MCP`
69
+ };
70
+ if (!policy.allowRemote && input.relayed === true) return {
71
+ allowed: false,
72
+ why: "appel relayé (en-tête Forwarded, X-Forwarded-For, X-Real-IP ou Via) sur une porte locale — la localité ne se prouve pas à travers un relais ; voir l'option `allowRemote` du serveur MCP"
73
+ };
74
+ if (input.origin !== void 0 && input.origin !== "") {
75
+ if (!policy.allowedOrigins.includes(input.origin)) return {
76
+ allowed: false,
77
+ why: `origine « ${input.origin} » non admise — voir l'option \`allowedOrigins\` du serveur MCP`
78
+ };
79
+ }
80
+ return { allowed: true };
81
+ }
82
+ export { RELAY_HEADERS, checkMcpAccess, isLocalAddress, isRelayedRequest };
@@ -0,0 +1,74 @@
1
+ import { jsonRpcNotification } from "nodefony";
2
+ // nodefony/mcp/progress.ts
3
+ /**
4
+ * La progression d'un appel d'outil (`notifications/progress`) — règle PURE,
5
+ * horloge injectée.
6
+ *
7
+ * La norme (`basic/patterns/progress`) pose quatre exigences, toutes tenues
8
+ * ICI plutôt que dans chaque outil :
9
+ * - une notification ne cite qu'un jeton fourni par une requête EN COURS — le
10
+ * rapporteur se tait dès que l'outil a rendu sa réponse ({@link IMcpProgressReporter.end}) ;
11
+ * - `progress` CROÎT à chaque notification — une valeur qui stagne ou recule
12
+ * est ignorée, pas transmise ;
13
+ * - la cadence est bornée (« SHOULD implement rate limiting ») ;
14
+ * - sans jeton, rien n'est émis : l'agent n'a rien demandé.
15
+ */
16
+ /** Intervalle minimal entre deux notifications de progression, en millisecondes. */
17
+ const MCP_PROGRESS_MIN_INTERVAL_MS = 100;
18
+ /** Longueur maximale du `message` d'une progression, en caractères. */
19
+ const MCP_PROGRESS_MESSAGE_MAX = 1e3;
20
+ /**
21
+ * Lit le jeton de progression d'une requête (`params._meta.progressToken`).
22
+ *
23
+ * @param params - les `params` de la requête.
24
+ * @returns le jeton, ou `null` s'il manque ou n'est ni une chaîne ni un entier.
25
+ */
26
+ function readProgressToken(params) {
27
+ const meta = params._meta;
28
+ if (typeof meta !== "object" || meta === null || !("progressToken" in meta)) return null;
29
+ const token = meta.progressToken;
30
+ if (typeof token === "string") return token;
31
+ if (typeof token === "number" && Number.isInteger(token)) return token;
32
+ return null;
33
+ }
34
+ /** Le rapporteur muet : aucun jeton, ou aucune porte capable d'un flux. */
35
+ const SILENT = {
36
+ progress: () => {},
37
+ end: () => {}
38
+ };
39
+ /**
40
+ * Fabrique le rapporteur de progression d'un appel.
41
+ *
42
+ * @param token - le jeton de la requête ({@link readProgressToken}), ou `null`.
43
+ * @param notify - émet une notification sur le flux de la réponse ; absent
44
+ * quand la porte ne sait pas tenir de flux.
45
+ * @param now - horloge (millisecondes), injectée pour l'épreuve.
46
+ * @param minIntervalMs - intervalle minimal entre deux notifications.
47
+ * @returns un rapporteur, muet quand il n'y a ni jeton ni flux.
48
+ */
49
+ function createProgressReporter(token, notify, now = Date.now, minIntervalMs = 100) {
50
+ if (token === null || notify === void 0) return SILENT;
51
+ let ended = false;
52
+ let last = Number.NEGATIVE_INFINITY;
53
+ let lastAt = Number.NEGATIVE_INFINITY;
54
+ return {
55
+ progress(progress, total, message) {
56
+ if (ended || !Number.isFinite(progress) || progress <= last) return;
57
+ const at = now();
58
+ if (at - lastAt < minIntervalMs) return;
59
+ last = progress;
60
+ lastAt = at;
61
+ const params = {
62
+ progressToken: token,
63
+ progress
64
+ };
65
+ if (total !== void 0 && Number.isFinite(total)) params.total = total;
66
+ if (message !== void 0) params.message = message.slice(0, MCP_PROGRESS_MESSAGE_MAX);
67
+ notify(jsonRpcNotification("notifications/progress", params));
68
+ },
69
+ end() {
70
+ ended = true;
71
+ }
72
+ };
73
+ }
74
+ export { MCP_PROGRESS_MESSAGE_MAX, MCP_PROGRESS_MIN_INTERVAL_MS, createProgressReporter, readProgressToken };
@@ -0,0 +1,76 @@
1
+ import { JsonRpcError, isNotification, jsonRpcFailure, jsonRpcSuccess } from "nodefony";
2
+ // nodefony/mcp/protocol.ts
3
+ /** Révision PRÉFÉRÉE de ce serveur — celle qu'il met en tête de ce qu'il sait faire. */
4
+ const MCP_PROTOCOL_VERSION = "2026-07-28";
5
+ /**
6
+ * Révision supposée quand un client n'en déclare AUCUNE.
7
+ *
8
+ * Ce n'est pas un choix : la spec impose de traiter une requête sans
9
+ * déclaration comme `2025-03-26`, et le SDK de référence en fait sa
10
+ * `DEFAULT_NEGOTIATED_PROTOCOL_VERSION`. Répondre notre préférée à un client
11
+ * muet reviendrait à lui imposer une révision qu'il n'a pas demandée.
12
+ */
13
+ const MCP_DEFAULT_NEGOTIATED_VERSION = "2025-03-26";
14
+ /**
15
+ * Versions que ce serveur sait servir — publiées par `server/discover`, listées
16
+ * dans l'erreur `UnsupportedProtocolVersion`, et candidates à l'écho
17
+ * d'`initialize`. De la plus récente à la plus ancienne.
18
+ *
19
+ * 🔴 **N'en servir qu'une était un défaut, et il a coûté la connexion.** Le
20
+ * serveur annonçait `2026-07-28` à TOUT client, y compris à ceux qui
21
+ * demandaient autre chose — or aucun client déployé ne connaît cette révision :
22
+ * le SDK de référence (`@modelcontextprotocol/sdk@1.30.0`) porte
23
+ * `LATEST = 2025-11-25` et refuse net toute réponse hors de sa liste
24
+ * (« Server's protocol version is not supported »). Une porte parfaitement
25
+ * conforme à la dernière spec, et injoignable par tout le monde.
26
+ *
27
+ * Ces cinq révisions sont celles que la spec a publiées. Les servir toutes est
28
+ * honnête ici, et vérifiable : les quatre méthodes de ce serveur — `initialize`,
29
+ * `ping`, `tools/list`, `tools/call` — ont la même forme de REQUÊTE dans
30
+ * chacune. La forme du RÉSULTAT, elle, dépend de l'ÈRE du client : depuis
31
+ * `2026-07-28`, tout résultat porte `resultType` (MUST du schéma) et `_meta`
32
+ * serverInfo — servis par `eraResult` (server.ts) à un client moderne, jamais
33
+ * à un client legacy qui ne définit pas ces champs. Un test EXERCE chacune,
34
+ * plutôt que de se fier à cette phrase.
35
+ */
36
+ const MCP_SUPPORTED_VERSIONS = [
37
+ MCP_PROTOCOL_VERSION,
38
+ "2025-11-25",
39
+ "2025-06-18",
40
+ MCP_DEFAULT_NEGOTIATED_VERSION,
41
+ "2024-11-05"
42
+ ];
43
+ /**
44
+ * Clé de métadonnée par laquelle un client MODERNE déclare sa révision.
45
+ *
46
+ * ⭐ **C'est la différence d'ÈRE, et elle commande tout le reste.** Jusqu'à
47
+ * `2025-11-25` (ère « legacy »), un client ouvrait une session par un handshake
48
+ * `initialize`. Depuis `2026-07-28` (ère « modern »), il n'y a plus de session :
49
+ * chaque requête porte elle-même sa version et les capacités du client, dans
50
+ * `params._meta`. Un serveur qui n'écouterait que `initialize` serait un
51
+ * serveur *legacy* — quelle que soit la version qu'il prétend annoncer.
52
+ */
53
+ const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
54
+ /** Clé de métadonnée portant l'identité du serveur dans `server/discover`. */
55
+ const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
56
+ /**
57
+ * Codes réservés par la spec MCP, hors plage JSON-RPC standard.
58
+ *
59
+ * Ils ne sont pas décoratifs : un client s'en sert pour se rattraper seul —
60
+ * renégocier une version sur `-32022`, relire `tools/list` puis réessayer sur
61
+ * `-32020`. Rendre un `-32600` générique à leur place le priverait de cette
62
+ * reprise et transformerait un désaccord réparable en échec définitif.
63
+ */
64
+ const McpProtocolError = {
65
+ /**
66
+ * Les en-têtes HTTP contredisent le corps, ou un en-tête requis manque.
67
+ * La spec impose `400` **et** ce code (`streamable-http` §Server Validation).
68
+ */
69
+ HEADER_MISMATCH: -32020,
70
+ /**
71
+ * La révision demandée n'est pas servie. La réponse **doit** lister celles
72
+ * qu'on sert, sans quoi le client n'a rien pour choisir.
73
+ */
74
+ UNSUPPORTED_PROTOCOL_VERSION: -32022
75
+ };
76
+ export { JsonRpcError, MCP_DEFAULT_NEGOTIATED_VERSION, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, McpProtocolError, isNotification, jsonRpcFailure, jsonRpcSuccess };
@@ -0,0 +1,302 @@
1
+ import { JsonRpcError, MCP_DEFAULT_NEGOTIATED_VERSION, MCP_SUPPORTED_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, McpProtocolError, jsonRpcFailure, jsonRpcSuccess } from "./protocol.js";
2
+ import { createProgressReporter, readProgressToken } from "./progress.js";
3
+ import { callMcpTool, publishMcpTools } from "./tools.js";
4
+ import { classifyJsonRpcFrame, isPlainObject } from "nodefony";
5
+ // nodefony/mcp/server.ts
6
+ /**
7
+ * Défaut sûr : rien n'est frais, rien n'est partageable — le catalogue d'une
8
+ * porte varie selon l'autorisation présentée, et en développement à chaque
9
+ * rechargement.
10
+ */
11
+ const DEFAULT_CACHE_HINTS = Object.freeze({
12
+ ttlMs: 0,
13
+ cacheScope: "private"
14
+ });
15
+ /** Phrase d'ouverture des `instructions` quand la porte n'en donne pas. */
16
+ const DEFAULT_INSTRUCTIONS = "Outils de cette application.";
17
+ /** Signal des appels sans transport qui annule : jamais abattu, partagé. */
18
+ const NEVER_ABORTED = new AbortController().signal;
19
+ /**
20
+ * Extrait la révision déclarée dans `params._meta`, s'il y en a une.
21
+ *
22
+ * Un client MODERNE la pose à chaque requête ; un client LEGACY ne pose rien
23
+ * et négocie par `initialize`. L'absence n'est donc pas une faute : c'est un
24
+ * indice d'ère.
25
+ */
26
+ function metaVersion(params) {
27
+ const meta = params._meta;
28
+ if (!isPlainObject(meta)) return void 0;
29
+ const value = meta[META_PROTOCOL_VERSION];
30
+ return typeof value === "string" ? value : void 0;
31
+ }
32
+ /**
33
+ * La requête relève-t-elle de l'ère MODERNE (≥ 2026-07-28) ?
34
+ *
35
+ * L'ère se lit de la déclaration du client — `_meta` OU en-tête, la spec les
36
+ * fait équivalents (leur discordance est déjà refusée en amont). Aucune
37
+ * déclaration = client legacy. Les révisions sont des dates ISO : la
38
+ * comparaison lexicale EST la comparaison chronologique, et une révision
39
+ * future reste moderne sans qu'on y revienne.
40
+ */
41
+ function isModernRequest(params, headers) {
42
+ const declared = metaVersion(params) ?? headers.protocolVersion;
43
+ return declared !== void 0 && declared >= "2026-07-28";
44
+ }
45
+ /**
46
+ * Donne à un résultat la FORME de l'ère du client.
47
+ *
48
+ * Ère moderne : `resultType` est un **MUST** du schéma (« Servers implementing
49
+ * this protocol version MUST include this field ») et `_meta` serverInfo un
50
+ * SHOULD sur chaque réponse. Payé par une connexion réelle : le client
51
+ * officiel (claude-code 2.1.238) rejoue quatre fois un `tools/list` moderne
52
+ * sans `resultType`, puis n'enregistre AUCUN outil — serveur « connected »,
53
+ * porte morte. Un serveur qui annonce l'ère moderne et répond en forme legacy
54
+ * est injoignable, exactement comme l'inverse l'était (cf
55
+ * `MCP_SUPPORTED_VERSIONS`).
56
+ *
57
+ * Ère legacy : résultat inchangé — un client ≤ 2025-11-25 ne définit pas ces
58
+ * champs, les lui envoyer serait du bruit d'une autre ère.
59
+ */
60
+ function eraResult(modern, serverInfo, result) {
61
+ if (!modern) return result;
62
+ return {
63
+ ...result,
64
+ resultType: "complete",
65
+ _meta: {
66
+ ...isPlainObject(result._meta) ? result._meta : void 0,
67
+ [META_SERVER_INFO]: serverInfo
68
+ }
69
+ };
70
+ }
71
+ /**
72
+ * Compose les `instructions` annoncées au client (`initialize` aujourd'hui ;
73
+ * `server/discover` le jour de sa réactivation — cf le retrait plus bas).
74
+ *
75
+ * ⭐ **Le seul endroit où un catalogue filtré peut cesser de mentir par
76
+ * omission.** Un outil retenu faute d'autorisation est, pour le client,
77
+ * indistinguable d'un outil inexistant — c'est voulu (ne pas révéler ce qu'on
78
+ * protège), mais pris au pied de la lettre cela ferait conclure « cette
79
+ * application n'a rien de plus », quand la vérité est « rien de plus POUR TOI,
80
+ * en l'état ». Un agent qui l'ignore ne demandera jamais de jeton.
81
+ *
82
+ * Le compromis retenu : dire qu'il EXISTE des outils réservés et combien, sans
83
+ * jamais les nommer ni dire ce qu'ils font. C'est strictement moins que ce
84
+ * qu'un `401` normalisé révélera le jour où le rôle *resource server* sera
85
+ * branché (RFC 6750 + RFC 9728 disent « il faut un jeton » ET où l'obtenir) —
86
+ * donc rien n'est concédé ici qui ne le soit déjà par la norme.
87
+ *
88
+ * Zéro outil retenu → aucune phrase : annoncer une réserve vide entraînerait un
89
+ * agent à chercher une porte qui n'existe pas.
90
+ */
91
+ function discoverInstructions(context) {
92
+ const names = context.tools.map((tool) => tool.name);
93
+ const base = `${context.instructions ?? DEFAULT_INSTRUCTIONS} ` + (names.length > 0 ? `Disponibles ici : ${names.join(", ")}.` : "Aucun outil n'est servi à cet appelant.");
94
+ const withheld = context.withheldCount ?? 0;
95
+ if (withheld <= 0) return base;
96
+ const reserved = `${base} ${withheld} outil(s) supplémentaire(s) sont RÉSERVÉS et ne figurent pas dans \`tools/list\` : ils exigent une autorisation que cette requête ne présente pas. Ce n'est pas une panne, et rien ne sert de les deviner — `;
97
+ return context.authorizable === true ? `${reserved}le document de ressource protégée de cette porte (RFC 9728) nomme les scopes à demander, et le défi du refus dit où le lire. Ce sont eux qu'il faut faire porter au jeton, pas un contournement.` : `${reserved}cette porte n'en reçoit aucune : ils ne sont servis que par une porte HTTP protégée de l'application, à qui l'on présente un jeton.`;
98
+ }
99
+ /**
100
+ * Choisit la révision à ANNONCER en réponse à `initialize`.
101
+ *
102
+ * 🔴 **Répondre sa propre révision préférée est un bug, pas une politesse.** La
103
+ * spec est explicite : si le serveur sait servir la version demandée, il DOIT
104
+ * répondre celle-là ; sinon, une autre qu'il sait servir — à charge pour le
105
+ * client de raccrocher. Ignorer `params.protocolVersion` et annoncer sa
106
+ * dernière révision rend la porte injoignable par tout client qui ne la connaît
107
+ * pas encore, ce qui est le cas de TOUS tant qu'un SDK n'a pas rattrapé la spec.
108
+ * Vécu : un serveur annonçant `2026-07-28` était refusé par
109
+ * `@modelcontextprotocol/sdk@1.30.0` (« Server's protocol version is not
110
+ * supported ») — conforme à la dernière norme, et parlant à personne.
111
+ *
112
+ * Un client MUET n'obtient pas notre préférée non plus, mais
113
+ * {@link MCP_DEFAULT_NEGOTIATED_VERSION} : c'est la révision que la spec impose
114
+ * de supposer en l'absence de déclaration.
115
+ *
116
+ * @param params - `params` de la requête `initialize`
117
+ * @returns la révision à annoncer
118
+ */
119
+ function negotiateVersion(params, versions) {
120
+ const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : metaVersion(params);
121
+ const preferred = versions[0] ?? "2026-07-28";
122
+ if (asked === void 0) return versions.includes("2025-03-26") ? MCP_DEFAULT_NEGOTIATED_VERSION : preferred;
123
+ return versions.includes(asked) ? asked : preferred;
124
+ }
125
+ /**
126
+ * Contrôle la cohérence et le support de la révision annoncée.
127
+ *
128
+ * Deux refus distincts, et la spec impose les deux :
129
+ * - **en-tête ≠ `_meta`** → `400` + `HeaderMismatch` (`-32020`). Le motif est
130
+ * une vraie faille : un répartiteur de charge peut router sur l'en-tête
131
+ * pendant que le serveur exécute d'après le corps — deux sources de vérité
132
+ * pour une même requête.
133
+ * - **révision inconnue** → `400` + `UnsupportedProtocolVersion` (`-32022`),
134
+ * **avec la liste de celles qu'on sert** : c'est elle qui permet au client
135
+ * de se rattraper au lieu d'abandonner.
136
+ *
137
+ * @returns `null` si tout va bien, sinon la réponse de refus
138
+ */
139
+ function checkProtocolVersion(id, params, headers, versions) {
140
+ const fromMeta = metaVersion(params);
141
+ const fromHeader = headers.protocolVersion;
142
+ if (fromMeta && fromHeader && fromMeta !== fromHeader) return {
143
+ status: 400,
144
+ body: jsonRpcFailure(id, McpProtocolError.HEADER_MISMATCH, "MCP-Protocol-Version ne correspond pas à _meta", {
145
+ header: fromHeader,
146
+ meta: fromMeta
147
+ })
148
+ };
149
+ const declared = fromMeta ?? fromHeader;
150
+ if (!declared) return null;
151
+ if (!versions.includes(declared)) return {
152
+ status: 400,
153
+ body: jsonRpcFailure(id, McpProtocolError.UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {
154
+ supported: [...versions],
155
+ requested: declared
156
+ })
157
+ };
158
+ return null;
159
+ }
160
+ /**
161
+ * Refus d'un message qui n'est pas une requête MCP acceptable : `400` + `-32600`.
162
+ *
163
+ * @param id - l'`id` de la requête quand il a été lu, sinon `null`
164
+ * @param message - motif, une phrase
165
+ * @returns la réponse HTTP à écrire
166
+ */
167
+ function invalidRequest(id, message) {
168
+ return {
169
+ status: 400,
170
+ body: jsonRpcFailure(id, JsonRpcError.INVALID_REQUEST, message)
171
+ };
172
+ }
173
+ /**
174
+ * Transmet une `notifications/cancelled` à la table des appels en cours.
175
+ *
176
+ * Une annulation mal formée s'IGNORE, comme celle qui ne trouve rien : la spec
177
+ * permet d'ignorer une annulation, et une notification n'a pas de réponse où
178
+ * dire la faute. Le motif n'est jamais renvoyé, seulement journalisé.
179
+ *
180
+ * @param params - paramètres de la notification
181
+ * @param context - porte qui tient la table, liée à l'appelant
182
+ */
183
+ function cancelRequest(params, context) {
184
+ const requestId = params.requestId;
185
+ if (typeof requestId !== "string" && !(typeof requestId === "number" && Number.isInteger(requestId))) return;
186
+ const reason = typeof params.reason === "string" ? params.reason : void 0;
187
+ context.cancellation?.cancel(requestId, reason);
188
+ }
189
+ /**
190
+ * Traite UN message JSON-RPC.
191
+ *
192
+ * @param message - corps du `POST`, déjà parsé
193
+ * @param context - outils autorisés et briques qui répondent
194
+ * @param headers - en-têtes du transport (`MCP-Protocol-Version`)
195
+ * @returns statut HTTP et corps à écrire (corps `null` = `202` sans contenu)
196
+ */
197
+ async function handleMcpMessage(message, context, headers = {}) {
198
+ const kind = classifyJsonRpcFrame(message);
199
+ if (kind === "invalid") return invalidRequest(null, "message JSON-RPC 2.0 invalide");
200
+ if (kind === "response") return invalidRequest(null, "un client MCP n'envoie pas de réponse JSON-RPC sur cette porte");
201
+ const frame = message;
202
+ const method = frame.method;
203
+ const replyId = kind === "request" ? frame.id : null;
204
+ if (typeof replyId === "number" && !Number.isInteger(replyId)) return invalidRequest(null, "l'identifiant d'une requête MCP est une chaîne ou un entier");
205
+ const rawParams = frame.params;
206
+ let params = {};
207
+ if (rawParams !== void 0) {
208
+ if (Array.isArray(rawParams)) return {
209
+ status: 400,
210
+ body: jsonRpcFailure(replyId, JsonRpcError.INVALID_PARAMS, "`params` doit être un objet nommé : le MCP n'emploie pas de paramètres positionnels")
211
+ };
212
+ if (!isPlainObject(rawParams)) return invalidRequest(replyId, "`params` doit être une valeur structurée (JSON-RPC 2.0 §4.2)");
213
+ params = rawParams;
214
+ }
215
+ if (kind === "notification") {
216
+ if (method === "notifications/cancelled") cancelRequest(params, context);
217
+ return {
218
+ status: 202,
219
+ body: null
220
+ };
221
+ }
222
+ const id = frame.id;
223
+ const versions = context.versions ?? MCP_SUPPORTED_VERSIONS;
224
+ const refusal = checkProtocolVersion(id, params, headers, versions);
225
+ if (refusal) return refusal;
226
+ const modern = isModernRequest(params, headers);
227
+ switch (method) {
228
+ case "initialize": return {
229
+ status: 200,
230
+ body: jsonRpcSuccess(id, {
231
+ protocolVersion: negotiateVersion(params, versions),
232
+ capabilities: { tools: {} },
233
+ serverInfo: context.serverInfo,
234
+ instructions: discoverInstructions(context)
235
+ })
236
+ };
237
+ case "ping": return {
238
+ status: 200,
239
+ body: jsonRpcSuccess(id, eraResult(modern, context.serverInfo, {}))
240
+ };
241
+ case "tools/list": return {
242
+ status: 200,
243
+ body: jsonRpcSuccess(id, eraResult(modern, context.serverInfo, {
244
+ tools: publishMcpTools(context.tools),
245
+ ...modern ? context.cache ?? DEFAULT_CACHE_HINTS : {}
246
+ }))
247
+ };
248
+ case "tools/call": {
249
+ const name = typeof params.name === "string" ? params.name : "";
250
+ const rawArgs = params.arguments;
251
+ if (rawArgs !== void 0 && !isPlainObject(rawArgs)) return {
252
+ status: 400,
253
+ body: jsonRpcFailure(id, JsonRpcError.INVALID_PARAMS, "`arguments` doit être un objet nommé pour `tools/call`")
254
+ };
255
+ const args = rawArgs ?? {};
256
+ if (!name) return {
257
+ status: 400,
258
+ body: jsonRpcFailure(id, JsonRpcError.INVALID_PARAMS, "`name` est requis pour `tools/call`")
259
+ };
260
+ const reporter = createProgressReporter(readProgressToken(params), context.transport?.notify);
261
+ const call = context.cancellation?.open(id);
262
+ const closed = context.transport?.signal;
263
+ const signal = call ? closed ? AbortSignal.any([closed, call.signal]) : call.signal : closed ?? NEVER_ABORTED;
264
+ let result;
265
+ try {
266
+ result = await callMcpTool(name, args, context.tools, context.caller, {
267
+ signal,
268
+ progress: (value, total, text) => reporter.progress(value, total, text)
269
+ });
270
+ } catch (error) {
271
+ return {
272
+ status: 200,
273
+ body: jsonRpcFailure(id, JsonRpcError.INTERNAL_ERROR, `l'outil « ${name} » a échoué — détail dans le journal du serveur`),
274
+ failure: {
275
+ tool: name,
276
+ error
277
+ }
278
+ };
279
+ } finally {
280
+ reporter.end();
281
+ call?.close();
282
+ }
283
+ if (result === null) return {
284
+ status: 200,
285
+ body: jsonRpcFailure(id, JsonRpcError.INVALID_PARAMS, `outil inconnu « ${name} » — voir tools/list`)
286
+ };
287
+ return {
288
+ status: 200,
289
+ body: jsonRpcSuccess(id, eraResult(modern, context.serverInfo, { ...result }))
290
+ };
291
+ }
292
+ case "subscriptions/listen": return {
293
+ status: 404,
294
+ body: jsonRpcFailure(id, JsonRpcError.METHOD_NOT_FOUND, "`subscriptions/listen` n'est pas servi : le catalogue ne change qu'au rechargement de l'application, qui redémarre son process — rappeler `tools/list` après une coupure rend l'état à jour.")
295
+ };
296
+ default: return {
297
+ status: 404,
298
+ body: jsonRpcFailure(id, JsonRpcError.METHOD_NOT_FOUND, `méthode inconnue « ${method} »`)
299
+ };
300
+ }
301
+ }
302
+ export { handleMcpMessage };
@@ -0,0 +1,89 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ // nodefony/mcp/sessionTicket.ts
3
+ /** Durée de vie d'un ticket : le temps d'une requête de connexion en boucle locale. */
4
+ const SESSION_TICKET_TTL_MS = 1e4;
5
+ /**
6
+ * Tickets en attente au plus. Un outil en émet UN et le rachète aussitôt :
7
+ * en atteindre huit signale un usage anormal, refusé plutôt qu'accumulé.
8
+ */
9
+ const SESSION_TICKET_MAX_PENDING = 8;
10
+ /** Longueur d'un ticket : 32 octets d'aléa en base64url, sans remplissage. */
11
+ const SESSION_TICKET_LENGTH = 43;
12
+ /** En-tête qui porte le ticket vers la route de connexion par ticket. */
13
+ const SESSION_TICKET_HEADER = "x-nodefony-session-ticket";
14
+ const TICKET_SHAPE = /^[A-Za-z0-9_-]+$/u;
15
+ /** Empreinte sous laquelle un ticket est rangé : le ticket brut n'est jamais gardé. */
16
+ function fingerprintSessionTicket(ticket) {
17
+ return createHash("sha256").update(ticket).digest("hex");
18
+ }
19
+ /**
20
+ * Tickets à usage unique qui autorisent l'ouverture d'UNE session pour une
21
+ * identité donnée — celle au nom de laquelle un outil MCP tire de vraies
22
+ * requêtes HTTP vers l'application.
23
+ *
24
+ * Le ticket ne quitte jamais le process : l'outil qui l'émet est celui qui le
25
+ * présente, en boucle locale, à la route de connexion de sa porte
26
+ * (`defineTicketSessionGate`). L'identité est fixée à l'émission — la
27
+ * requête de rachat n'en porte aucune, donc ne peut pas en choisir une autre.
28
+ *
29
+ * Aucune minuterie : les tickets expirés sont purgés à chaque émission et à
30
+ * chaque rachat, sur au plus {@link SESSION_TICKET_MAX_PENDING} entrées.
31
+ */
32
+ var SessionTicketStore = class {
33
+ #now;
34
+ #fingerprint;
35
+ #pending = /* @__PURE__ */ new Map();
36
+ /**
37
+ * @param options - horloge et empreinte, injectées par les tests.
38
+ */
39
+ constructor(options = {}) {
40
+ this.#now = options.now ?? Date.now;
41
+ this.#fingerprint = options.fingerprint ?? fingerprintSessionTicket;
42
+ }
43
+ /** Nombre de tickets émis, ni rachetés ni expirés. */
44
+ get pending() {
45
+ this.#purge();
46
+ return this.#pending.size;
47
+ }
48
+ /**
49
+ * Émet un ticket pour `identifier`, valable {@link SESSION_TICKET_TTL_MS} ms.
50
+ *
51
+ * @param identifier - identifiant du compte pour lequel la session s'ouvrira.
52
+ * @returns le ticket, à présenter dans {@link SESSION_TICKET_HEADER}.
53
+ * @throws Error - identifiant vide, ou plafond de tickets en attente atteint.
54
+ */
55
+ issue(identifier) {
56
+ if (identifier.length === 0) throw new Error("ticket de session : identifiant vide");
57
+ this.#purge();
58
+ if (this.#pending.size >= 8) throw new Error(`ticket de session : 8 tickets déjà en attente`);
59
+ const ticket = randomBytes(32).toString("base64url");
60
+ this.#pending.set(this.#fingerprint(ticket), {
61
+ identifier,
62
+ expiresAt: this.#now() + SESSION_TICKET_TTL_MS
63
+ });
64
+ return ticket;
65
+ }
66
+ /**
67
+ * Rachète un ticket : le retire, et rend l'identité pour laquelle il a été émis.
68
+ *
69
+ * La forme est contrôlée AVANT l'empreinte : une entrée hostile (1 Mo, autre
70
+ * type) ne coûte pas un hachage.
71
+ *
72
+ * @param ticket - valeur reçue, non fiable.
73
+ * @returns l'identifiant, ou `null` (absent, malformé, inconnu, expiré, déjà racheté).
74
+ */
75
+ redeem(ticket) {
76
+ if (typeof ticket !== "string" || ticket.length !== 43 || !TICKET_SHAPE.test(ticket)) return null;
77
+ this.#purge();
78
+ const key = this.#fingerprint(ticket);
79
+ const entry = this.#pending.get(key);
80
+ if (entry === void 0) return null;
81
+ this.#pending.delete(key);
82
+ return entry.identifier;
83
+ }
84
+ #purge() {
85
+ const now = this.#now();
86
+ for (const [key, entry] of this.#pending) if (entry.expiresAt < now) this.#pending.delete(key);
87
+ }
88
+ };
89
+ export { SESSION_TICKET_HEADER, SESSION_TICKET_LENGTH, SESSION_TICKET_MAX_PENDING, SESSION_TICKET_TTL_MS, SessionTicketStore, fingerprintSessionTicket };