@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.
- package/LICENSE +202 -0
- package/README.md +123 -2
- package/dist/_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js +8 -0
- package/dist/index.js +37 -0
- package/dist/nodefony/command/StdioCommand.js +75 -0
- package/dist/nodefony/config/config.js +181 -0
- package/dist/nodefony/config/configSchema.js +18 -0
- package/dist/nodefony/config/defineModuleConfig.js +51 -0
- package/dist/nodefony/mcp/caller.js +38 -0
- package/dist/nodefony/mcp/gate.js +628 -0
- package/dist/nodefony/mcp/guard.js +82 -0
- package/dist/nodefony/mcp/progress.js +74 -0
- package/dist/nodefony/mcp/protocol.js +76 -0
- package/dist/nodefony/mcp/server.js +302 -0
- package/dist/nodefony/mcp/sessionTicket.js +89 -0
- package/dist/nodefony/mcp/stdio.js +64 -0
- package/dist/nodefony/mcp/stream.js +80 -0
- package/dist/nodefony/mcp/ticketSession.js +177 -0
- package/dist/nodefony/mcp/tools.js +485 -0
- package/dist/nodefony/src/errors/AiError.js +20 -0
- package/dist/types/index.d.ts +56 -0
- package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +44 -0
- package/dist/types/nodefony/config/configSchema.d.ts +12 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +43 -0
- package/dist/types/nodefony/mcp/caller.d.ts +54 -0
- package/dist/types/nodefony/mcp/gate.d.ts +235 -0
- package/dist/types/nodefony/mcp/guard.d.ts +89 -0
- package/dist/types/nodefony/mcp/progress.d.ts +48 -0
- package/dist/types/nodefony/mcp/protocol.d.ts +107 -0
- package/dist/types/nodefony/mcp/server.d.ts +156 -0
- package/dist/types/nodefony/mcp/sessionTicket.d.ts +60 -0
- package/dist/types/nodefony/mcp/stdio.d.ts +38 -0
- package/dist/types/nodefony/mcp/stream.d.ts +76 -0
- package/dist/types/nodefony/mcp/ticketSession.d.ts +174 -0
- package/dist/types/nodefony/mcp/tools.d.ts +232 -0
- package/dist/types/nodefony/src/errors/AiError.d.ts +14 -0
- package/docs/index.md +56 -0
- package/docs/mcp.md +754 -0
- 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 };
|