@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,628 @@
|
|
|
1
|
+
import { mcpConfigSchema } from "../config/config.js";
|
|
2
|
+
import { mcpCallerRoles } from "./caller.js";
|
|
3
|
+
import { checkMcpAccess, isRelayedRequest } from "./guard.js";
|
|
4
|
+
import { JsonRpcError, MCP_SUPPORTED_VERSIONS, jsonRpcFailure } from "./protocol.js";
|
|
5
|
+
import { collectMcpTools, declareMcpTools, insufficientScopeMessage, mcpCallScopes, mcpDeclaredScopes, mcpText } from "./tools.js";
|
|
6
|
+
import { handleMcpMessage } from "./server.js";
|
|
7
|
+
import { MCP_STREAM_GRACE_MS, createMcpResponseStream } from "./stream.js";
|
|
8
|
+
import { ACCESS_ACCOUNT_RESOLVER, ACCESS_TOKEN_VERIFIER, BearerError, BootConfigurationError, Nodefony, RequestContext, authorizeProtectedResource, buildBearerChallenge, isPlainObject, missingScopes, parseModuleConfig, protectedResourceMetadataUrl, readBearerHeader } from "nodefony";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
// nodefony/mcp/gate.ts
|
|
11
|
+
/**
|
|
12
|
+
* Une porte MCP sur HTTP — la mécanique, sans la route.
|
|
13
|
+
*
|
|
14
|
+
* `@nodefony/ai` ne monte AUCUNE route : une porte se DÉCLARE, par qui en a
|
|
15
|
+
* besoin — devkit pour l'outillage de développement, une application pour ses
|
|
16
|
+
* propres outils, en développement ou en production selon son choix. On la
|
|
17
|
+
* définit une fois ({@link defineMcpGate}), on la sert d'une ligne
|
|
18
|
+
* (`gate.serve(this, body)`). Gardes d'origine et de localité, validation du
|
|
19
|
+
* jeton, défi `401` avec `resource_metadata`, traduction HTTP ↔ JSON-RPC, flux
|
|
20
|
+
* SSE, refus `405` des verbes retirés : une porte qui recoderait l'une de ces
|
|
21
|
+
* règles finirait plus permissive que les autres sans que rien ne le dise.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Plafond d'un événement sur le flux de réponse MCP, octets UTF-8 — celui de la
|
|
25
|
+
* réponse finale, qui porte le résultat entier de l'outil (16 Mio).
|
|
26
|
+
*/
|
|
27
|
+
const MCP_STREAM_MAX_EVENT_BYTES = 16777216;
|
|
28
|
+
/** Un objet a-t-il la forme d'un outil MCP (nom, description, schéma, handler) ? */
|
|
29
|
+
function isMcpToolLike(value) {
|
|
30
|
+
return typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" && "description" in value && typeof value.description === "string" && "inputSchema" in value && typeof value.inputSchema === "object" && value.inputSchema !== null && "handler" in value && typeof value.handler === "function";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Le schéma d'une porte — les réglages de transport et d'autorisation de
|
|
34
|
+
* {@link mcpConfigSchema} (les MÊMES clés que `devkit.mcp.*`), plus ce que la
|
|
35
|
+
* porte publie et comment. Strict : une clé inconnue arrête le démarrage en la
|
|
36
|
+
* nommant, au lieu d'être ignorée.
|
|
37
|
+
*/
|
|
38
|
+
const mcpGateSchema = mcpConfigSchema.extend({
|
|
39
|
+
name: z.string().min(1).optional().describe("Nom annoncé à l'agent ; défaut : le nom du projet"),
|
|
40
|
+
version: z.string().min(1).optional().describe("Version annoncée à l'agent ; défaut : celle de Nodefony"),
|
|
41
|
+
instructions: z.string().min(1).optional().describe("Ce que l'agent lit pour savoir QUAND se servir de la porte"),
|
|
42
|
+
tools: z.union([z.literal("modules"), z.array(z.custom(isMcpToolLike, { message: "un outil MCP porte `name`, `description`, `inputSchema` et `handler`" }))]).describe("Les outils servis : une liste, ou `\"modules\"` pour ceux que déclarent les modules chargés (`getMcpTools()`)"),
|
|
43
|
+
unprotectedRoles: z.array(z.string().min(1)).default([]).describe("Rôles accordés à qui entre SANS jeton (porte sans `authorization`) ; défaut : aucun"),
|
|
44
|
+
protocol: z.strictObject({ versions: z.array(z.enum(MCP_SUPPORTED_VERSIONS)).min(1).optional().describe("Révisions du protocole acceptées ; défaut : toutes celles du paquet installé") }).default({}),
|
|
45
|
+
cache: z.strictObject({
|
|
46
|
+
ttlMs: z.number().int().min(0).default(0).describe("Durée de fraîcheur des listes, en millisecondes"),
|
|
47
|
+
scope: z.enum(["public", "private"]).default("private").describe("`private` dès que la liste dépend de l'appelant")
|
|
48
|
+
}).default({
|
|
49
|
+
ttlMs: 0,
|
|
50
|
+
scope: "private"
|
|
51
|
+
})
|
|
52
|
+
}).superRefine((value, ctx) => {
|
|
53
|
+
if (value.unprotectedRoles.length > 0 && value.authorization.authorizationServers.length > 0) ctx.addIssue({
|
|
54
|
+
code: "custom",
|
|
55
|
+
path: ["unprotectedRoles"],
|
|
56
|
+
message: "`unprotectedRoles` ne s'applique qu'à une porte SANS serveur d'autorisation : une porte protégée tire les rôles des scopes du jeton."
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* Un `tools/call` exige-t-il, par ses ARGUMENTS, des scopes que l'appelant n'a
|
|
61
|
+
* pas ? Seul un outil SERVI est jugé : un nom retenu reste « inconnu », son
|
|
62
|
+
* existence n'est pas révélée par un défi.
|
|
63
|
+
*
|
|
64
|
+
* @param body - message JSON-RPC reçu (objet)
|
|
65
|
+
* @param tools - outils servis à cet appelant
|
|
66
|
+
* @param caller - appelant établi
|
|
67
|
+
* @returns les scopes de l'opération et le message, ou `null` si rien ne manque
|
|
68
|
+
* (ou si l'appel n'est pas jugeable ici — le moteur le refusera lui-même)
|
|
69
|
+
*/
|
|
70
|
+
function requiredElevation(body, tools, caller) {
|
|
71
|
+
if (!("method" in body) || body.method !== "tools/call") return null;
|
|
72
|
+
if (!("params" in body) || typeof body.params !== "object") return null;
|
|
73
|
+
const params = body.params;
|
|
74
|
+
if (params === null || !("name" in params)) return null;
|
|
75
|
+
const tool = tools.find((candidate) => candidate.name === params.name);
|
|
76
|
+
if (tool?.scopesFor === void 0) return null;
|
|
77
|
+
const args = "arguments" in params && isPlainObject(params.arguments) ? params.arguments : {};
|
|
78
|
+
const scopes = mcpCallScopes(tool, args);
|
|
79
|
+
if (scopes === null) return null;
|
|
80
|
+
const missing = missingScopes(caller.scopes, scopes);
|
|
81
|
+
return missing.length === 0 ? null : {
|
|
82
|
+
scopes,
|
|
83
|
+
missing,
|
|
84
|
+
message: insufficientScopeMessage(tool.name, missing)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const EMPTY_PROTECTED_RESOURCES = Object.freeze([]);
|
|
88
|
+
/** La porte coupée par configuration n'existe pas : elle ne se défend pas. */
|
|
89
|
+
const DISABLED = Object.freeze({
|
|
90
|
+
kind: "json",
|
|
91
|
+
status: 404,
|
|
92
|
+
body: Object.freeze({ error: "MCP désactivé" })
|
|
93
|
+
});
|
|
94
|
+
/** `GET` / `DELETE` : `405`, `Allow: POST`. */
|
|
95
|
+
const METHOD_NOT_ALLOWED = Object.freeze({
|
|
96
|
+
kind: "empty",
|
|
97
|
+
status: 405,
|
|
98
|
+
headers: Object.freeze({ allow: "POST" })
|
|
99
|
+
});
|
|
100
|
+
function failure(status, code, message, headers) {
|
|
101
|
+
const response = {
|
|
102
|
+
kind: "json",
|
|
103
|
+
status,
|
|
104
|
+
body: jsonRpcFailure(null, code, message)
|
|
105
|
+
};
|
|
106
|
+
if (headers) response.headers = headers;
|
|
107
|
+
return response;
|
|
108
|
+
}
|
|
109
|
+
/** JSON-RPC 2.0 §5.1 : `-32700`, un texte qui ne se PARSE pas. */
|
|
110
|
+
function parseFailure() {
|
|
111
|
+
return failure(400, JsonRpcError.PARSE_ERROR, "corps illisible : JSON invalide");
|
|
112
|
+
}
|
|
113
|
+
/** JSON-RPC 2.0 §5.1 : `-32600`, un JSON lisible qui n'est pas un message. */
|
|
114
|
+
function notAMessage() {
|
|
115
|
+
return failure(400, JsonRpcError.INVALID_REQUEST, "corps attendu : un message JSON-RPC (objet)");
|
|
116
|
+
}
|
|
117
|
+
/** Clé d'annulation de l'unique appelant d'un transport de process. */
|
|
118
|
+
const STDIO_CALLER_KEY = "stdio";
|
|
119
|
+
/** Ce qu'une porte stdio répond à un appel annulé : rien. */
|
|
120
|
+
const CANCELLED_SILENCE = Object.freeze({
|
|
121
|
+
kind: "empty",
|
|
122
|
+
status: 202
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Branche un témoin sur la table d'annulation : il est prévenu quand le client
|
|
126
|
+
* annule l'appel inscrit. Sans témoin, la table est rendue telle quelle.
|
|
127
|
+
*/
|
|
128
|
+
function trackCancellation(cancellation, onCancelled) {
|
|
129
|
+
if (onCancelled === void 0) return cancellation;
|
|
130
|
+
return {
|
|
131
|
+
open(id) {
|
|
132
|
+
const handle = cancellation.open(id);
|
|
133
|
+
handle.signal.addEventListener("abort", onCancelled, { once: true });
|
|
134
|
+
return handle;
|
|
135
|
+
},
|
|
136
|
+
cancel: (id, reason) => cancellation.cancel(id, reason)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* L'en-tête porte-t-il un gabarit d'interpolation jamais substitué ?
|
|
141
|
+
*
|
|
142
|
+
* Un `.mcp.json` déclare son jeton par `${NF_MCP_TOKEN}` : le client développe
|
|
143
|
+
* la variable, ou transmet la chaîne telle quelle si elle manque. Le second cas
|
|
144
|
+
* ne se distingue d'un jeton invalide par AUCUN symptôme observable de
|
|
145
|
+
* l'extérieur, et c'est pourtant le plus fréquent des deux.
|
|
146
|
+
*/
|
|
147
|
+
function isUnsubstitutedTemplate(header) {
|
|
148
|
+
const read = readBearerHeader(header);
|
|
149
|
+
return read.kind === "token" && read.token.startsWith("${") && read.token.endsWith("}");
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Le corps reçu est-il un texte qui ne se PARSE pas en JSON ?
|
|
153
|
+
*
|
|
154
|
+
* Le parseur HTTP ignore un JSON malformé et rend `{}` : seul le brut permet
|
|
155
|
+
* de distinguer un `{}` envoyé d'un texte illisible. Relu uniquement sur ce
|
|
156
|
+
* chemin froid (corps vide d'apparence).
|
|
157
|
+
*/
|
|
158
|
+
function unparsableBody(body, rawBody) {
|
|
159
|
+
if (rawBody === void 0 || body === null || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length > 0) return false;
|
|
160
|
+
const text = rawBody()?.trim();
|
|
161
|
+
if (!text) return false;
|
|
162
|
+
try {
|
|
163
|
+
JSON.parse(text);
|
|
164
|
+
return false;
|
|
165
|
+
} catch {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Première valeur d'un en-tête HTTP, ou rien. */
|
|
170
|
+
function firstHeader(value) {
|
|
171
|
+
return Array.isArray(value) ? value[0] : value;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Définit une porte MCP — UNE fois, à côté de son controller.
|
|
175
|
+
*
|
|
176
|
+
* La définition est validée et figée ici : une clé inconnue, une révision du
|
|
177
|
+
* protocole que le paquet installé ne sait pas servir, un outil mal formé, un
|
|
178
|
+
* nom en double ou réservé (`nodefony_`) lèvent une `BootConfigurationError`
|
|
179
|
+
* qui nomme la faute — au démarrage, jamais à la première requête. Chaque
|
|
180
|
+
* requête ne paie ensuite aucune validation.
|
|
181
|
+
*
|
|
182
|
+
* ```ts
|
|
183
|
+
* const shopGate = defineMcpGate({
|
|
184
|
+
* name: "shop",
|
|
185
|
+
* instructions: "Stock et commandes du magasin.",
|
|
186
|
+
* tools: [stockTool, orderTool],
|
|
187
|
+
* });
|
|
188
|
+
*
|
|
189
|
+
* @route("shop-mcp", { path: "/mcp", requirements: { methods: ["POST", "GET", "DELETE"] } })
|
|
190
|
+
* mcp(@Body() body: unknown) {
|
|
191
|
+
* return shopGate.serve(this, body);
|
|
192
|
+
* }
|
|
193
|
+
* ```
|
|
194
|
+
*
|
|
195
|
+
* Défauts : appels locaux seulement (`allowRemote: false`), aucune origine
|
|
196
|
+
* navigateur, aucun jeton exigé, AUCUN rôle accordé — les outils réservés
|
|
197
|
+
* (`scopes`, `requiresAuth`) restent retenus tant qu'une `authorization` ne
|
|
198
|
+
* les ouvre pas.
|
|
199
|
+
*
|
|
200
|
+
* @param definition - ce que la porte publie, à qui, et comment.
|
|
201
|
+
* @returns la porte, prête à servir.
|
|
202
|
+
* @throws BootConfigurationError si la définition est invalide.
|
|
203
|
+
*/
|
|
204
|
+
function defineMcpGate(definition) {
|
|
205
|
+
const label = typeof definition.name === "string" && definition.name.length > 0 ? `porte MCP « ${definition.name} »` : "porte MCP";
|
|
206
|
+
const settings = Object.freeze(parseModuleConfig(mcpGateSchema, definition, label));
|
|
207
|
+
let fixedModules = null;
|
|
208
|
+
if (settings.tools !== "modules") {
|
|
209
|
+
const tools = settings.tools;
|
|
210
|
+
fixedModules = Object.freeze({ gate: Object.freeze({
|
|
211
|
+
getModuleName: () => void 0,
|
|
212
|
+
getMcpTools: () => tools
|
|
213
|
+
}) });
|
|
214
|
+
const faults = [];
|
|
215
|
+
declareMcpTools({
|
|
216
|
+
modules: fixedModules,
|
|
217
|
+
onSkip: (why) => faults.push(why)
|
|
218
|
+
});
|
|
219
|
+
if (faults.length > 0) throw new BootConfigurationError(`[${label}] outils refusés — ${faults.join(" ; ")}`);
|
|
220
|
+
}
|
|
221
|
+
const pinned = settings.protocol.versions;
|
|
222
|
+
const versions = pinned ? Object.freeze(MCP_SUPPORTED_VERSIONS.filter((v) => pinned.includes(v))) : MCP_SUPPORTED_VERSIONS;
|
|
223
|
+
const cache = Object.freeze({
|
|
224
|
+
ttlMs: settings.cache.ttlMs,
|
|
225
|
+
cacheScope: settings.cache.scope
|
|
226
|
+
});
|
|
227
|
+
const gateProtected = settings.authorization.authorizationServers.length > 0;
|
|
228
|
+
/** Les modules dont la porte sert les outils, pour CETTE requête. */
|
|
229
|
+
function servedModules(runtime) {
|
|
230
|
+
return fixedModules ?? runtime?.modules;
|
|
231
|
+
}
|
|
232
|
+
let inflight = null;
|
|
233
|
+
/**
|
|
234
|
+
* La table des appels en cours, vue d'UN appelant : il n'atteint que les
|
|
235
|
+
* appels qu'il a lui-même émis.
|
|
236
|
+
*/
|
|
237
|
+
function cancellationFor(callerKey, log) {
|
|
238
|
+
const keyOf = (id) => `${callerKey}\u0000${typeof id === "number" ? "n" : "s"}${id}`;
|
|
239
|
+
return {
|
|
240
|
+
open(id) {
|
|
241
|
+
const key = keyOf(id);
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
inflight ??= /* @__PURE__ */ new Map();
|
|
244
|
+
const owned = !inflight.has(key);
|
|
245
|
+
if (owned) inflight.set(key, controller);
|
|
246
|
+
return {
|
|
247
|
+
signal: controller.signal,
|
|
248
|
+
close() {
|
|
249
|
+
if (owned && inflight?.get(key) === controller) inflight.delete(key);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
cancel(id, reason) {
|
|
254
|
+
const controller = inflight?.get(keyOf(id));
|
|
255
|
+
if (controller === void 0) return;
|
|
256
|
+
log?.(`MCP — appel ${JSON.stringify(id)} annulé par le client${reason ? ` : ${reason}` : ""}`, "DEBUG");
|
|
257
|
+
controller.abort();
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
let defaultServerInfo = null;
|
|
262
|
+
/** Identité annoncée — résolue au premier usage, le kernel existant alors. */
|
|
263
|
+
function serverInfo() {
|
|
264
|
+
defaultServerInfo ??= Object.freeze({
|
|
265
|
+
name: settings.name ?? Nodefony.getKernel()?.projectName ?? "nodefony",
|
|
266
|
+
version: settings.version ?? Nodefony.version
|
|
267
|
+
});
|
|
268
|
+
return defaultServerInfo;
|
|
269
|
+
}
|
|
270
|
+
async function respond(request, runtime) {
|
|
271
|
+
const log = runtime?.log;
|
|
272
|
+
if (!settings.enabled) return DISABLED;
|
|
273
|
+
const verdict = checkMcpAccess({
|
|
274
|
+
origin: request.origin,
|
|
275
|
+
remoteAddress: request.remoteAddress,
|
|
276
|
+
relayed: request.relayed
|
|
277
|
+
}, {
|
|
278
|
+
allowedOrigins: settings.allowedOrigins,
|
|
279
|
+
allowRemote: settings.allowRemote
|
|
280
|
+
});
|
|
281
|
+
if (!verdict.allowed) {
|
|
282
|
+
log?.(`MCP refusé — ${verdict.why}`, "WARNING");
|
|
283
|
+
return failure(403, JsonRpcError.INVALID_REQUEST, "origine refusée");
|
|
284
|
+
}
|
|
285
|
+
const { body } = request;
|
|
286
|
+
if (unparsableBody(body, request.rawBody)) return parseFailure();
|
|
287
|
+
if (body === null || typeof body !== "object") return notAMessage();
|
|
288
|
+
const modules = servedModules(runtime);
|
|
289
|
+
const authz = settings.authorization;
|
|
290
|
+
let caller = {
|
|
291
|
+
authenticated: false,
|
|
292
|
+
scopes: [],
|
|
293
|
+
roles: mcpCallerRoles({
|
|
294
|
+
protected: gateProtected,
|
|
295
|
+
authenticated: false,
|
|
296
|
+
scopes: []
|
|
297
|
+
}, settings.unprotectedRoles)
|
|
298
|
+
};
|
|
299
|
+
let callerKey = `addr\u0000${request.remoteAddress ?? ""}`;
|
|
300
|
+
let principal = null;
|
|
301
|
+
if (gateProtected) {
|
|
302
|
+
const authVerdict = await authorizeProtectedResource(request.authorization, {
|
|
303
|
+
resource: authz.resource,
|
|
304
|
+
acceptedResources: authz.additionalResources,
|
|
305
|
+
metadataUrl: protectedResourceMetadataUrl(authz.resource),
|
|
306
|
+
scopes: mcpDeclaredScopes({ modules }),
|
|
307
|
+
allowAnonymous: authz.anonymous
|
|
308
|
+
}, runtime?.verifier);
|
|
309
|
+
switch (authVerdict.outcome) {
|
|
310
|
+
case "unverifiable":
|
|
311
|
+
log?.(authVerdict.why ? `MCP — la vérification du jeton a ÉCHOUÉ, le jeton n'est donc ni accepté ni refusé : la porte refuse de servir (503). Cause — ${authVerdict.why}` : "MCP — la porte déclare un serveur d'autorisation, mais aucun vérificateur de jeton n'est posé (`accessTokenVerifier`). La porte refuse de servir : accepter les porteurs sans les valider serait pire que rester anonyme.", "CRITIC");
|
|
312
|
+
return failure(503, JsonRpcError.INTERNAL_ERROR, "autorisation indisponible");
|
|
313
|
+
case "challenge":
|
|
314
|
+
if (isUnsubstitutedTemplate(request.authorization)) log?.("MCP — l'en-tête `Authorization` porte un GABARIT non substitué : la variable d'environnement du jeton n'est pas posée dans le process du client. Émettre le jeton (`nodefony security:mcp:token`) et le poser, ou rendre la porte anonyme pour ce client (`nodefony ai:mcp --no-auth`) — sans en-tête, les outils publics sont servis.", "WARNING");
|
|
315
|
+
return failure(authVerdict.status, JsonRpcError.INVALID_REQUEST, "autorisation requise", { "WWW-Authenticate": authVerdict.wwwAuthenticate });
|
|
316
|
+
case "authenticated":
|
|
317
|
+
caller = {
|
|
318
|
+
authenticated: true,
|
|
319
|
+
scopes: authVerdict.principal.scopes,
|
|
320
|
+
subject: authVerdict.principal.subject,
|
|
321
|
+
roles: mcpCallerRoles({
|
|
322
|
+
protected: true,
|
|
323
|
+
authenticated: true,
|
|
324
|
+
scopes: authVerdict.principal.scopes
|
|
325
|
+
})
|
|
326
|
+
};
|
|
327
|
+
callerKey = `sub\u0000${authVerdict.principal.issuer}\u0000${authVerdict.principal.subject}`;
|
|
328
|
+
principal = authVerdict.principal;
|
|
329
|
+
break;
|
|
330
|
+
case "anonymous":
|
|
331
|
+
if (authVerdict.rejected) log?.("MCP — jeton REJETÉ (expiré, mauvaise audience ou signature invalide) : la requête est servie en ANONYME, les outils réservés restent retenus. Renouveler le jeton (`nodefony security:mcp:token`) pour les retrouver.", "WARNING");
|
|
332
|
+
caller = {
|
|
333
|
+
authenticated: false,
|
|
334
|
+
scopes: [],
|
|
335
|
+
roles: mcpCallerRoles({
|
|
336
|
+
protected: true,
|
|
337
|
+
authenticated: false,
|
|
338
|
+
scopes: []
|
|
339
|
+
})
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return dispatch(body, {
|
|
344
|
+
caller,
|
|
345
|
+
callerKey,
|
|
346
|
+
principal,
|
|
347
|
+
challenge: gateProtected
|
|
348
|
+
}, request, modules, runtime);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Répond à UNE ligne reçue sur l'entrée standard.
|
|
352
|
+
*
|
|
353
|
+
* La frontière d'un transport de process est celle du process : qui a lancé
|
|
354
|
+
* la commande a déjà un terminal dans l'application. Aucune origine, aucune
|
|
355
|
+
* adresse, aucun jeton n'y voyage — les gardes de transport HTTP n'y ont rien
|
|
356
|
+
* à juger. L'appelant reste donc anonyme et ne reçoit que les rôles d'une
|
|
357
|
+
* porte NON protégée (aucun par défaut). Une porte protégée ne s'ouvre pas
|
|
358
|
+
* davantage : sans jeton, ses outils réservés restent retenus.
|
|
359
|
+
*/
|
|
360
|
+
async function respondStdio(request, runtime) {
|
|
361
|
+
if (!settings.enabled) return failure(404, JsonRpcError.INVALID_REQUEST, "MCP désactivé");
|
|
362
|
+
let body;
|
|
363
|
+
try {
|
|
364
|
+
body = JSON.parse(request.line);
|
|
365
|
+
} catch {
|
|
366
|
+
return parseFailure();
|
|
367
|
+
}
|
|
368
|
+
if (body === null || typeof body !== "object") return notAMessage();
|
|
369
|
+
const call = { cancelled: false };
|
|
370
|
+
const open = request.openStream;
|
|
371
|
+
const exchange = {
|
|
372
|
+
signal: request.signal,
|
|
373
|
+
openStream: open && (async () => {
|
|
374
|
+
const sink = await open();
|
|
375
|
+
return {
|
|
376
|
+
get closed() {
|
|
377
|
+
return call.cancelled || sink.closed;
|
|
378
|
+
},
|
|
379
|
+
send: (data) => sink.send(data)
|
|
380
|
+
};
|
|
381
|
+
})
|
|
382
|
+
};
|
|
383
|
+
const reply = await dispatch(body, {
|
|
384
|
+
caller: {
|
|
385
|
+
authenticated: false,
|
|
386
|
+
scopes: [],
|
|
387
|
+
roles: mcpCallerRoles({
|
|
388
|
+
protected: gateProtected,
|
|
389
|
+
authenticated: false,
|
|
390
|
+
scopes: []
|
|
391
|
+
}, settings.unprotectedRoles)
|
|
392
|
+
},
|
|
393
|
+
callerKey: STDIO_CALLER_KEY,
|
|
394
|
+
principal: null,
|
|
395
|
+
challenge: false,
|
|
396
|
+
onCancelled: () => {
|
|
397
|
+
call.cancelled = true;
|
|
398
|
+
}
|
|
399
|
+
}, exchange, servedModules(runtime), runtime);
|
|
400
|
+
return call.cancelled ? CANCELLED_SILENCE : reply;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Le tronc commun à tous les transports, une fois l'appelant établi :
|
|
404
|
+
* collecte, élévation, flux, moteur.
|
|
405
|
+
*/
|
|
406
|
+
async function dispatch(body, admitted, exchange, modules, runtime) {
|
|
407
|
+
const log = runtime?.log;
|
|
408
|
+
const { caller, principal } = admitted;
|
|
409
|
+
let withheldCount = 0;
|
|
410
|
+
const tools = collectMcpTools({
|
|
411
|
+
modules,
|
|
412
|
+
caller,
|
|
413
|
+
onSkip: (why) => log?.(`MCP — ${why}`, "WARNING"),
|
|
414
|
+
onWithheld: (name, why) => {
|
|
415
|
+
withheldCount += 1;
|
|
416
|
+
log?.(`MCP — outil « ${name} » retenu : ${why}`, "DEBUG");
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
if (admitted.challenge) {
|
|
420
|
+
const elevation = requiredElevation(body, tools, caller);
|
|
421
|
+
if (elevation !== null) {
|
|
422
|
+
const metadataUrl = protectedResourceMetadataUrl(settings.authorization.resource);
|
|
423
|
+
return failure(caller.authenticated ? 403 : 401, JsonRpcError.INVALID_REQUEST, elevation.message, { "WWW-Authenticate": buildBearerChallenge({
|
|
424
|
+
resourceMetadataUrl: metadataUrl,
|
|
425
|
+
scopes: elevation.scopes,
|
|
426
|
+
...caller.authenticated ? {
|
|
427
|
+
error: BearerError.INSUFFICIENT_SCOPE,
|
|
428
|
+
description: `missing scope: ${elevation.missing.join(" ")}`
|
|
429
|
+
} : {}
|
|
430
|
+
}) });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const stream = createMcpResponseStream({
|
|
434
|
+
signal: exchange.signal,
|
|
435
|
+
open: exchange.openStream,
|
|
436
|
+
graceMs: MCP_STREAM_GRACE_MS,
|
|
437
|
+
onError: (error, final) => log?.(`MCP — ${final ? "réponse finale" : "notification"} perdue sur le flux : ${error instanceof Error ? error.message : String(error)}`, final ? "WARNING" : "DEBUG")
|
|
438
|
+
});
|
|
439
|
+
const served = principal !== null && !("method" in body && body.method === "tools/list") ? actingTools(tools, {
|
|
440
|
+
principal,
|
|
441
|
+
resolver: runtime?.accountResolver,
|
|
442
|
+
onAccount: exchange.onAccount,
|
|
443
|
+
log
|
|
444
|
+
}) : tools;
|
|
445
|
+
const reply = await handleMcpMessage(body, {
|
|
446
|
+
tools: served,
|
|
447
|
+
caller,
|
|
448
|
+
withheldCount,
|
|
449
|
+
authorizable: admitted.challenge,
|
|
450
|
+
transport: stream.transport,
|
|
451
|
+
cancellation: trackCancellation(cancellationFor(admitted.callerKey, log), admitted.onCancelled),
|
|
452
|
+
serverInfo: runtime?.serverInfo ?? serverInfo(),
|
|
453
|
+
versions,
|
|
454
|
+
cache,
|
|
455
|
+
...settings.instructions !== void 0 ? { instructions: settings.instructions } : {}
|
|
456
|
+
}, { protocolVersion: exchange.protocolVersion });
|
|
457
|
+
if (reply.failure) {
|
|
458
|
+
const { tool, error } = reply.failure;
|
|
459
|
+
log?.(`MCP — l'outil « ${tool} » a levé : ${error instanceof Error ? error.stack ?? error.message : String(error)}`, "ERROR");
|
|
460
|
+
}
|
|
461
|
+
const sse = await stream.finish(reply.body);
|
|
462
|
+
if (sse !== null) return {
|
|
463
|
+
kind: "stream",
|
|
464
|
+
stream: sse
|
|
465
|
+
};
|
|
466
|
+
if (reply.body === null) return {
|
|
467
|
+
kind: "empty",
|
|
468
|
+
status: reply.status
|
|
469
|
+
};
|
|
470
|
+
return {
|
|
471
|
+
kind: "json",
|
|
472
|
+
status: reply.status,
|
|
473
|
+
body: reply.body
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return Object.freeze({
|
|
477
|
+
settings,
|
|
478
|
+
respond,
|
|
479
|
+
respondStdio,
|
|
480
|
+
async serve(controller, body) {
|
|
481
|
+
const context = controller.context;
|
|
482
|
+
const method = context?.method;
|
|
483
|
+
if (method === "GET" || method === "DELETE") return write(controller, settings.enabled ? METHOD_NOT_ALLOWED : DISABLED);
|
|
484
|
+
const request = controller.request;
|
|
485
|
+
const headers = request?.headers ?? {};
|
|
486
|
+
return write(controller, await respond({
|
|
487
|
+
body,
|
|
488
|
+
rawBody: () => request && "data" in request ? request.data.toString(request.charset) : void 0,
|
|
489
|
+
origin: firstHeader(headers.origin),
|
|
490
|
+
protocolVersion: firstHeader(headers["mcp-protocol-version"]),
|
|
491
|
+
authorization: firstHeader(headers.authorization),
|
|
492
|
+
remoteAddress: context?.remoteAddress ?? void 0,
|
|
493
|
+
relayed: isRelayedRequest(headers),
|
|
494
|
+
signal: context?.signal,
|
|
495
|
+
onAccount: gateProtected && context ? (identifier) => {
|
|
496
|
+
context.userIdentifier = identifier;
|
|
497
|
+
} : void 0,
|
|
498
|
+
openStream: controller.acceptsSse() ? () => controller.renderSse({ maxEventBytes: MCP_STREAM_MAX_EVENT_BYTES }) : void 0
|
|
499
|
+
}, {
|
|
500
|
+
modules: fixedModules === null ? context?.kernel?.modules ?? Nodefony.getKernel()?.modules : void 0,
|
|
501
|
+
verifier: gateProtected ? controller.get(ACCESS_TOKEN_VERIFIER) ?? void 0 : void 0,
|
|
502
|
+
accountResolver: gateProtected ? controller.get(ACCESS_ACCOUNT_RESOLVER) ?? void 0 : void 0,
|
|
503
|
+
log: (message, severity) => controller.log(message, severity)
|
|
504
|
+
}));
|
|
505
|
+
},
|
|
506
|
+
protectedResources(modules) {
|
|
507
|
+
const authz = settings.authorization;
|
|
508
|
+
if (!settings.enabled || !gateProtected) return EMPTY_PROTECTED_RESOURCES;
|
|
509
|
+
return [{
|
|
510
|
+
resource: authz.resource,
|
|
511
|
+
authorizationServers: authz.authorizationServers,
|
|
512
|
+
scopesSupported: mcpDeclaredScopes({ modules: fixedModules ?? modules }),
|
|
513
|
+
resourceName: authz.resourceName,
|
|
514
|
+
resourceDocumentation: authz.resourceDocumentation
|
|
515
|
+
}];
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** Écrit une réponse décrite sur un controller. */
|
|
520
|
+
function write(controller, response) {
|
|
521
|
+
switch (response.kind) {
|
|
522
|
+
case "stream": return response.stream.close();
|
|
523
|
+
case "empty": return controller.renderResponse("", void 0, response.status, response.headers);
|
|
524
|
+
case "json": return controller.renderJson(response.body, response.status, response.headers);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Message UNIFORME d'un compte inutilisable — la cause (introuvable,
|
|
529
|
+
* verrouillé, sujet externe sans rattachement) part au journal, jamais à
|
|
530
|
+
* l'agent : un refus détaillé dirait quels comptes existent.
|
|
531
|
+
*/
|
|
532
|
+
const ACCOUNT_UNAVAILABLE = "le compte désigné par le jeton n'est pas utilisable : l'outil ne s'exécute pas";
|
|
533
|
+
/** Plus long délai qu'un minuteur accepte (2³¹ − 1 ms, ~24,8 jours). */
|
|
534
|
+
const MAX_TIMER_MS = 2147483647;
|
|
535
|
+
/**
|
|
536
|
+
* Les outils servis, ceux qui agissent au nom du compte enveloppés : leur
|
|
537
|
+
* handler s'exécute dans une bulle de requête où le compte est posé.
|
|
538
|
+
*
|
|
539
|
+
* Aucune allocation tant qu'aucun outil ne le déclare : la liste servie est
|
|
540
|
+
* rendue telle quelle.
|
|
541
|
+
*/
|
|
542
|
+
function actingTools(tools, acting) {
|
|
543
|
+
let wrapped = null;
|
|
544
|
+
for (const [index, tool] of tools.entries()) {
|
|
545
|
+
if (tool.actsForUser !== true) continue;
|
|
546
|
+
wrapped ??= tools.slice();
|
|
547
|
+
wrapped[index] = {
|
|
548
|
+
...tool,
|
|
549
|
+
handler: (args, caller, run) => runAsAccount(tool, args, caller, run, acting)
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
return wrapped ?? tools;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Exécute un outil au nom du compte que désigne le jeton.
|
|
556
|
+
*
|
|
557
|
+
* Le compte est retrouvé AVANT le handler : les lecteurs de l'identité
|
|
558
|
+
* (`RequestContext.getAuthenticatedUser()`, le voteur de rôles) sont
|
|
559
|
+
* synchrones, une résolution paresseuse ne les atteindrait jamais. La bulle est
|
|
560
|
+
* un NOUVEL objet — elle hérite de la corrélation et du scope de la requête,
|
|
561
|
+
* deux appels concurrents ne se voient pas — vidée en `finally`. Le handler
|
|
562
|
+
* meurt avec le jeton : son signal s'abat à l'expiration.
|
|
563
|
+
*
|
|
564
|
+
* Fail-closed : sans résolveur (CRITIC) ou sans compte utilisable, l'appel rend
|
|
565
|
+
* `isError` — jamais une exécution en anonyme. Une PANNE du résolveur lève, et
|
|
566
|
+
* la porte la traite comme l'exception d'un outil (journal, `-32603` opaque).
|
|
567
|
+
*/
|
|
568
|
+
async function runAsAccount(tool, args, caller, run, acting) {
|
|
569
|
+
const { principal, resolver, log } = acting;
|
|
570
|
+
if (resolver === void 0) {
|
|
571
|
+
log?.(`MCP — l'outil « ${tool.name} » agit au nom d'un compte, mais aucun résolveur de compte n'est posé (\`${ACCESS_ACCOUNT_RESOLVER}\`, module \`@nodefony/security\`) : l'appel est refusé plutôt que servi en anonyme.`, "CRITIC");
|
|
572
|
+
return mcpText(ACCOUNT_UNAVAILABLE, true);
|
|
573
|
+
}
|
|
574
|
+
const account = await resolver(principal);
|
|
575
|
+
if (account === null) {
|
|
576
|
+
log?.(`MCP — l'outil « ${tool.name} » : aucun compte utilisable pour le sujet du jeton (émetteur ${principal.issuer}) — introuvable, inactif, verrouillé ou sans rattachement. Appel refusé.`, "WARNING");
|
|
577
|
+
return mcpText(ACCOUNT_UNAVAILABLE, true);
|
|
578
|
+
}
|
|
579
|
+
const remainingMs = principal.expiresAt === void 0 ? null : principal.expiresAt * 1e3 - Date.now();
|
|
580
|
+
if (remainingMs !== null && remainingMs <= 0) return mcpText("le jeton a expiré : l'outil ne s'exécute pas", true);
|
|
581
|
+
acting.onAccount?.(account.user.identifier);
|
|
582
|
+
const store = {
|
|
583
|
+
...RequestContext.get() ?? { requestId: "" },
|
|
584
|
+
token: account.token,
|
|
585
|
+
user: account.user
|
|
586
|
+
};
|
|
587
|
+
const bounded = boundedRun(run, remainingMs);
|
|
588
|
+
try {
|
|
589
|
+
return await RequestContext.run(store, () => tool.handler(args, caller, bounded.run));
|
|
590
|
+
} finally {
|
|
591
|
+
bounded.dispose();
|
|
592
|
+
RequestContext.release(store);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** Rien à faire : ni minuteur à couper, ni écouteur à retirer, ni progression à porter. */
|
|
596
|
+
const NOOP = () => {};
|
|
597
|
+
/**
|
|
598
|
+
* L'exécution prêtée au handler, bornée par l'expiration du jeton : son signal
|
|
599
|
+
* s'abat quand l'agent abandonne OU quand le jeton expire.
|
|
600
|
+
*
|
|
601
|
+
* Minuteur `unref` et écouteur retirés par `dispose` — rien ne survit à
|
|
602
|
+
* l'appel. Sans expiration connue (ou au-delà de ce qu'un minuteur accepte),
|
|
603
|
+
* l'exécution d'origine est rendue telle quelle.
|
|
604
|
+
*/
|
|
605
|
+
function boundedRun(run, remainingMs) {
|
|
606
|
+
if (remainingMs === null || remainingMs > MAX_TIMER_MS) return {
|
|
607
|
+
run,
|
|
608
|
+
dispose: NOOP
|
|
609
|
+
};
|
|
610
|
+
const controller = new AbortController();
|
|
611
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("jeton expiré")), remainingMs);
|
|
612
|
+
timer.unref();
|
|
613
|
+
const parent = run?.signal;
|
|
614
|
+
const onAbort = () => controller.abort(parent?.reason);
|
|
615
|
+
if (parent?.aborted) controller.abort(parent.reason);
|
|
616
|
+
else parent?.addEventListener("abort", onAbort, { once: true });
|
|
617
|
+
return {
|
|
618
|
+
run: {
|
|
619
|
+
signal: controller.signal,
|
|
620
|
+
progress: run ? run.progress.bind(run) : NOOP
|
|
621
|
+
},
|
|
622
|
+
dispose: () => {
|
|
623
|
+
clearTimeout(timer);
|
|
624
|
+
parent?.removeEventListener("abort", onAbort);
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
export { MCP_STREAM_MAX_EVENT_BYTES, defineMcpGate, mcpGateSchema };
|