@nodefony/ai 10.0.0-beta.3 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/dist/_virtual/{_@oxc-project_runtime@0.154.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +21 -14
- package/dist/nodefony/command/StdioCommand.js +75 -0
- package/dist/nodefony/config/config.js +141 -8
- package/dist/nodefony/config/configSchema.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- 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 +1 -2
- package/dist/types/index.d.ts +25 -0
- package/dist/types/nodefony/command/StdioCommand.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +28 -32
- 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/docs/index.md +56 -0
- package/docs/mcp.md +754 -0
- package/package.json +13 -11
- package/dist/_virtual/_@oxc-project_runtime@0.154.0/helpers/esm/decorateMetadata.js +0 -6
- package/dist/nodefony/interfaces/IAiService.js +0 -1
- package/dist/nodefony/interfaces/index.js +0 -1
- package/dist/nodefony/service/AiService.js +0 -96
- package/dist/types/nodefony/interfaces/IAiService.d.ts +0 -15
- package/dist/types/nodefony/interfaces/index.d.ts +0 -1
- package/dist/types/nodefony/service/AiService.d.ts +0 -80
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { JsonRpcError, jsonRpcFailure } from "./protocol.js";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
// nodefony/mcp/stdio.ts
|
|
4
|
+
/** Un puits stdio : chaque envoi devient une ligne, dans l'ordre d'appel. */
|
|
5
|
+
function lineSink(write) {
|
|
6
|
+
return {
|
|
7
|
+
closed: false,
|
|
8
|
+
send(data) {
|
|
9
|
+
write(JSON.stringify(data));
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Sert une porte MCP sur un flux de lignes, jusqu'à la fin de l'entrée.
|
|
15
|
+
*
|
|
16
|
+
* Chaque ligne est servie SANS attendre la précédente : une
|
|
17
|
+
* `notifications/cancelled` doit pouvoir arriver pendant l'appel qu'elle
|
|
18
|
+
* vise. Les réponses partent donc dans l'ordre où elles se terminent — chacune
|
|
19
|
+
* porte son identifiant, c'est ce que JSON-RPC prévoit. Une ligne vide est
|
|
20
|
+
* ignorée ; une ligne illisible rend `-32700` sans arrêter la boucle.
|
|
21
|
+
*
|
|
22
|
+
* L'entrée fermée, le client est parti : les appels encore en cours sont
|
|
23
|
+
* abattus (leur signal), puis la promesse se résout quand tous ont rendu.
|
|
24
|
+
*
|
|
25
|
+
* @param gate - la porte à servir.
|
|
26
|
+
* @param options - entrée, écriture d'une ligne, environnement de la porte.
|
|
27
|
+
* @returns une promesse résolue à la fin de l'entrée, appels en cours clos.
|
|
28
|
+
*/
|
|
29
|
+
function serveMcpStdio(gate, options) {
|
|
30
|
+
const { input, runtime } = options;
|
|
31
|
+
const write = (text) => options.write(`${text}\n`);
|
|
32
|
+
const departure = new AbortController();
|
|
33
|
+
const pending = /* @__PURE__ */ new Set();
|
|
34
|
+
const serve = async (line) => {
|
|
35
|
+
try {
|
|
36
|
+
const response = await gate.respondStdio({
|
|
37
|
+
line,
|
|
38
|
+
signal: departure.signal,
|
|
39
|
+
openStream: () => lineSink(write)
|
|
40
|
+
}, runtime);
|
|
41
|
+
if (response.kind === "json") write(JSON.stringify(response.body));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
runtime?.log?.(`MCP stdio — message non servi : ${error instanceof Error ? error.stack ?? error.message : String(error)}`, "ERROR");
|
|
44
|
+
write(JSON.stringify(jsonRpcFailure(null, JsonRpcError.INTERNAL_ERROR, "erreur interne")));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const lines = createInterface({
|
|
49
|
+
input,
|
|
50
|
+
crlfDelay: Infinity
|
|
51
|
+
});
|
|
52
|
+
lines.on("line", (line) => {
|
|
53
|
+
if (line.trim() === "") return;
|
|
54
|
+
const task = serve(line);
|
|
55
|
+
pending.add(task);
|
|
56
|
+
task.finally(() => pending.delete(task));
|
|
57
|
+
});
|
|
58
|
+
lines.once("close", () => {
|
|
59
|
+
departure.abort();
|
|
60
|
+
Promise.allSettled(pending).then(() => resolve());
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export { serveMcpStdio };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// nodefony/mcp/stream.ts
|
|
2
|
+
/**
|
|
3
|
+
* Le flux de réponse d'une requête MCP, ouvert PARESSEUSEMENT — brique
|
|
4
|
+
* générique de la boîte à outils, indépendante du transport.
|
|
5
|
+
*
|
|
6
|
+
* Toute porte (HTTP de développement, porte d'une application, futur `stdio`)
|
|
7
|
+
* a la même décision à prendre : répondre en un objet, ou en flux quand
|
|
8
|
+
* l'appel a quelque chose à dire en route. La règle vit ICI, une fois : le flux
|
|
9
|
+
* ne s'ouvre qu'au PREMIER envoi, les envois s'enchaînent dans l'ordre, et la
|
|
10
|
+
* réponse finale clôt le flux. Un appel qui ne dit rien en route n'ouvre rien —
|
|
11
|
+
* la porte répond alors comme avant.
|
|
12
|
+
*
|
|
13
|
+
* Exception : un appel encore en cours au bout du délai de grâce
|
|
14
|
+
* ({@link MCP_STREAM_GRACE_MS}) ouvre le flux sans rien y écrire. La norme
|
|
15
|
+
* oblige le client à accepter une réponse SSE (`transports/streamable-http`),
|
|
16
|
+
* avec ou sans `progressToken` ; or une réponse JSON qui tarde tombe sous le
|
|
17
|
+
* délai d'inactivité de la requête (408), quand un flux ouvert le désarme et
|
|
18
|
+
* bat de lui-même. Un outil court n'y voit rien : il a répondu avant.
|
|
19
|
+
*
|
|
20
|
+
* La porte ne fournit que ce qu'elle seule sait faire : OUVRIR son flux
|
|
21
|
+
* (`renderSse()` pour une route HTTP).
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Délai de grâce, en ms : au-delà, un appel sans réponse ouvre le flux. Très en
|
|
25
|
+
* deçà du délai d'inactivité d'une requête (30 s par défaut), assez long pour
|
|
26
|
+
* qu'un outil ordinaire réponde en JSON comme avant.
|
|
27
|
+
*/
|
|
28
|
+
const MCP_STREAM_GRACE_MS = 5e3;
|
|
29
|
+
/**
|
|
30
|
+
* Fabrique le flux de réponse paresseux d'une requête MCP.
|
|
31
|
+
*
|
|
32
|
+
* @param options - signal d'abandon, ouverture du flux, journal des pertes.
|
|
33
|
+
* @returns le transport à donner au protocole, et `finish()`.
|
|
34
|
+
*/
|
|
35
|
+
function createMcpResponseStream(options) {
|
|
36
|
+
const { signal, open, onError, graceMs } = options;
|
|
37
|
+
let sink = null;
|
|
38
|
+
let pending = Promise.resolve();
|
|
39
|
+
let grace = null;
|
|
40
|
+
const transport = {};
|
|
41
|
+
if (signal) transport.signal = signal;
|
|
42
|
+
if (open && graceMs !== void 0 && graceMs > 0) {
|
|
43
|
+
grace = setTimeout(() => {
|
|
44
|
+
grace = null;
|
|
45
|
+
if (sink !== null || signal?.aborted === true) return;
|
|
46
|
+
const previous = pending;
|
|
47
|
+
pending = (async () => {
|
|
48
|
+
await previous;
|
|
49
|
+
sink ??= await open();
|
|
50
|
+
})().catch((error) => onError?.(error, false));
|
|
51
|
+
}, graceMs);
|
|
52
|
+
grace.unref();
|
|
53
|
+
}
|
|
54
|
+
if (open) transport.notify = (notification) => {
|
|
55
|
+
const previous = pending;
|
|
56
|
+
pending = (async () => {
|
|
57
|
+
await previous;
|
|
58
|
+
sink ??= await open();
|
|
59
|
+
if (!sink.closed) await sink.send(notification);
|
|
60
|
+
})().catch((error) => onError?.(error, false));
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
transport,
|
|
64
|
+
async finish(body) {
|
|
65
|
+
if (grace !== null) {
|
|
66
|
+
clearTimeout(grace);
|
|
67
|
+
grace = null;
|
|
68
|
+
}
|
|
69
|
+
await pending;
|
|
70
|
+
if (sink === null) return null;
|
|
71
|
+
if (!sink.closed) try {
|
|
72
|
+
await sink.send(body);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
onError?.(error, true);
|
|
75
|
+
}
|
|
76
|
+
return sink;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export { MCP_STREAM_GRACE_MS, createMcpResponseStream };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { isLocalAddress, isRelayedRequest } from "./guard.js";
|
|
2
|
+
import { SESSION_TICKET_HEADER, SessionTicketStore } from "./sessionTicket.js";
|
|
3
|
+
import { parseModuleConfig } from "nodefony";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
// nodefony/mcp/ticketSession.ts
|
|
6
|
+
/** Message unique de tout refus : la cause fine ne sort jamais vers l'appelant. */
|
|
7
|
+
const TICKET_SESSION_REFUSED = "ticket de session refusé";
|
|
8
|
+
const REFUSED = {
|
|
9
|
+
status: 403,
|
|
10
|
+
message: TICKET_SESSION_REFUSED
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Décide l'ouverture d'une session par ticket.
|
|
14
|
+
*
|
|
15
|
+
* Ordre délibéré : la localité, puis la présence d'un flux de session, et
|
|
16
|
+
* seulement ensuite le rachat du ticket — un appel qui ne peut pas aboutir ne
|
|
17
|
+
* consomme pas le ticket. L'identité vient du TICKET, jamais de la requête.
|
|
18
|
+
*
|
|
19
|
+
* @param request - le ticket présenté et l'adresse de l'appelant.
|
|
20
|
+
* @param deps - magasin de tickets, flux de session, exigence de localité.
|
|
21
|
+
* @returns `200` avec le compte et le pseudonyme de session, `403` uniforme, ou
|
|
22
|
+
* `503` quand l'application n'a pas de flux de session.
|
|
23
|
+
*/
|
|
24
|
+
async function openTicketSession(request, deps) {
|
|
25
|
+
if (deps.localOnly !== false && (request.relayed === true || !isLocalAddress(request.remoteAddress))) return REFUSED;
|
|
26
|
+
if (deps.establish === null) return {
|
|
27
|
+
status: 503,
|
|
28
|
+
message: "aucun flux de session : la session par ticket exige @nodefony/security"
|
|
29
|
+
};
|
|
30
|
+
const identifier = deps.tickets?.redeem(request.ticket) ?? null;
|
|
31
|
+
if (identifier === null) return REFUSED;
|
|
32
|
+
let user;
|
|
33
|
+
try {
|
|
34
|
+
user = await deps.establish(identifier);
|
|
35
|
+
} catch {
|
|
36
|
+
return REFUSED;
|
|
37
|
+
}
|
|
38
|
+
const ref = deps.sessionRef();
|
|
39
|
+
if (ref === null) return {
|
|
40
|
+
status: 503,
|
|
41
|
+
message: "session par ticket non ouverte par le flux de session"
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
status: 200,
|
|
45
|
+
identifier: user.identifier,
|
|
46
|
+
roles: user.roles ?? [],
|
|
47
|
+
ref
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Le service résolu est-il un flux de session utilisable ?
|
|
52
|
+
*
|
|
53
|
+
* @param value - service lu dans le conteneur.
|
|
54
|
+
* @returns `true` s'il ouvre une session pour une identité prouvée.
|
|
55
|
+
*/
|
|
56
|
+
function isSessionFlowView(value) {
|
|
57
|
+
return typeof value === "object" && value !== null && "establishSessionFor" in value && typeof value.establishSessionFor === "function";
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Le service résolu est-il un service de sessions utilisable ?
|
|
61
|
+
*
|
|
62
|
+
* @param value - service lu dans le conteneur.
|
|
63
|
+
* @returns `true` s'il dérive un pseudonyme et détruit une session par lui.
|
|
64
|
+
*/
|
|
65
|
+
function isSessionServiceView(value) {
|
|
66
|
+
return typeof value === "object" && value !== null && "sessionRef" in value && typeof value.sessionRef === "function" && "destroyByRef" in value && typeof value.destroyByRef === "function";
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lit le compte rendu par le flux de session, sans lui faire confiance.
|
|
70
|
+
*
|
|
71
|
+
* @param value - retour de `establishSessionFor`.
|
|
72
|
+
* @param identifier - identité du ticket, retenue si le retour n'en porte pas.
|
|
73
|
+
* @returns l'identifiant et les rôles chaîne du compte.
|
|
74
|
+
*/
|
|
75
|
+
function toTicketSessionUser(value, identifier) {
|
|
76
|
+
if (typeof value !== "object" || value === null) return { identifier };
|
|
77
|
+
return {
|
|
78
|
+
identifier: "identifier" in value && typeof value.identifier === "string" ? value.identifier : identifier,
|
|
79
|
+
roles: "roles" in value && Array.isArray(value.roles) ? value.roles.filter((r) => typeof r === "string") : []
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Lit, côté outil, la réponse de la route de connexion par ticket — sans lui
|
|
84
|
+
* faire confiance. Vit à côté de {@link openTicketSession}, qui en produit la
|
|
85
|
+
* forme : deux copies du format divergeraient.
|
|
86
|
+
*
|
|
87
|
+
* @param body - corps de la réponse, `null` si aucun.
|
|
88
|
+
* @returns le compte et le pseudonyme de session, ou le message du refus.
|
|
89
|
+
*/
|
|
90
|
+
function readTicketSessionVerdict(body) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(body ?? "");
|
|
94
|
+
} catch {
|
|
95
|
+
return { message: "réponse illisible" };
|
|
96
|
+
}
|
|
97
|
+
if (typeof parsed !== "object" || parsed === null) return { message: "réponse illisible" };
|
|
98
|
+
if ("identifier" in parsed && typeof parsed.identifier === "string" && "ref" in parsed && typeof parsed.ref === "string") {
|
|
99
|
+
const roles = "roles" in parsed && Array.isArray(parsed.roles) ? parsed.roles.filter((r) => typeof r === "string") : [];
|
|
100
|
+
return {
|
|
101
|
+
identifier: parsed.identifier,
|
|
102
|
+
roles,
|
|
103
|
+
ref: parsed.ref
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { message: "message" in parsed && typeof parsed.message === "string" ? parsed.message : "réponse sans compte" };
|
|
107
|
+
}
|
|
108
|
+
/** Définition d'une porte de session par ticket — validée et figée au boot. */
|
|
109
|
+
const ticketSessionGateSchema = z.strictObject({
|
|
110
|
+
/**
|
|
111
|
+
* Facteur déclaré au flux de session : l'audit écrit `login.success` avec
|
|
112
|
+
* cette raison, ce qui distingue une session d'outil d'une vraie connexion.
|
|
113
|
+
*/
|
|
114
|
+
factor: z.string().regex(/^[a-z][a-z0-9_-]{0,31}$/u),
|
|
115
|
+
/**
|
|
116
|
+
* Refuser tout appel non local. Défaut `true` : une porte qui accepte un
|
|
117
|
+
* appel distant doit l'ÉCRIRE — le ticket seul la protège alors.
|
|
118
|
+
*/
|
|
119
|
+
localOnly: z.boolean().default(true)
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* Définit une porte de session par ticket : ce qui permet à un outil MCP de
|
|
123
|
+
* tirer de vraies requêtes HTTP vers l'application AU NOM d'un compte.
|
|
124
|
+
*
|
|
125
|
+
* Une session ne s'ouvre que par le flux de session de l'application
|
|
126
|
+
* (`authFlow.establishSessionFor`) : il revérifie le compte (actif, non
|
|
127
|
+
* verrouillé), régénère l'identifiant et écrit l'audit. Ce flux exige une
|
|
128
|
+
* requête en cours ; l'outil en envoie donc une, en boucle locale, à la route
|
|
129
|
+
* que le controller déclare, et récupère le cookie que la réponse pose.
|
|
130
|
+
* Fabriquer la session sans requête aurait recopié la règle de connexion.
|
|
131
|
+
*
|
|
132
|
+
* Comme {@link defineMcpGate}, la porte ne monte AUCUNE route : le controller
|
|
133
|
+
* qui en a besoin la déclare, chemin écrit en littéral, et appelle `serve`.
|
|
134
|
+
* Le magasin de tickets n'est alloué qu'au premier ticket émis.
|
|
135
|
+
*
|
|
136
|
+
* @param definition - facteur d'audit et exigence de localité.
|
|
137
|
+
* @returns la porte, figée.
|
|
138
|
+
* @throws BootConfigurationError - définition hors schéma.
|
|
139
|
+
*/
|
|
140
|
+
function defineTicketSessionGate(definition) {
|
|
141
|
+
const settings = Object.freeze(parseModuleConfig(ticketSessionGateSchema, definition, "porte de session par ticket"));
|
|
142
|
+
let tickets = null;
|
|
143
|
+
return {
|
|
144
|
+
settings,
|
|
145
|
+
get pending() {
|
|
146
|
+
return tickets?.pending ?? 0;
|
|
147
|
+
},
|
|
148
|
+
issue(identifier) {
|
|
149
|
+
tickets ??= new SessionTicketStore();
|
|
150
|
+
return tickets.issue(identifier);
|
|
151
|
+
},
|
|
152
|
+
async serve(controller) {
|
|
153
|
+
const context = controller.context;
|
|
154
|
+
if (!context?.request) throw new Error("session par ticket : aucun contexte de requête");
|
|
155
|
+
const flow = controller.get("authFlow");
|
|
156
|
+
const sessions = controller.get("sessions");
|
|
157
|
+
const verdict = await openTicketSession({
|
|
158
|
+
ticket: context.request.headers[SESSION_TICKET_HEADER],
|
|
159
|
+
remoteAddress: context.remoteAddress ?? void 0,
|
|
160
|
+
relayed: isRelayedRequest(context.request.headers)
|
|
161
|
+
}, {
|
|
162
|
+
tickets,
|
|
163
|
+
localOnly: settings.localOnly,
|
|
164
|
+
establish: isSessionFlowView(flow) ? async (identifier) => toTicketSessionUser(await flow.establishSessionFor(context, identifier, settings.factor), identifier) : null,
|
|
165
|
+
sessionRef: () => {
|
|
166
|
+
const id = context.session?.id;
|
|
167
|
+
return typeof id === "string" && isSessionServiceView(sessions) ? sessions.sessionRef(id) : null;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return controller.renderJson(verdict, verdict.status);
|
|
171
|
+
},
|
|
172
|
+
async close(sessions, ref) {
|
|
173
|
+
return isSessionServiceView(sessions) ? sessions.destroyByRef(ref, settings.factor) : false;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export { TICKET_SESSION_REFUSED, defineTicketSessionGate, isSessionFlowView, isSessionServiceView, openTicketSession, readTicketSessionVerdict, ticketSessionGateSchema, toTicketSessionUser };
|