@imenam/mcp-http-gateway 2.0.14 → 2.0.15

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/mcp-mode.js CHANGED
@@ -1,647 +1,722 @@
1
- #!/usr/bin/env node
2
- import 'dotenv/config';
3
- import fs from 'fs';
4
- import path from 'path';
5
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import {
8
- ListToolsRequestSchema,
9
- CallToolRequestSchema,
10
- } from '@modelcontextprotocol/sdk/types.js';
11
-
12
- const GATEWAY_URL = process.env.GATEWAY_URL?.replace(/\/$/, '');
13
- if (!GATEWAY_URL) {
14
- console.error('[mcp-mode] GATEWAY_URL environment variable is required');
15
- process.exit(1);
16
- }
17
-
18
- // Token porteur optionnel. Utile quand le gateway n'est pas joint en direct mais à
19
- // travers un frontal qui authentifie (hub mcp-gateway-manager, reverse-proxy…).
20
- //
21
- // ATTENTION — `import 'dotenv/config'` ci-dessus charge le `.env` du **répertoire
22
- // courant de l'agent**, pas celui du gateway. dotenv n'écrase jamais une variable
23
- // déjà présente dans l'environnement : le bloc `env` de la config MCP l'emporte
24
- // donc toujours. En revanche, si le bloc `env` ne définit PAS GATEWAY_TOKEN, un
25
- // `.env` traînant dans le projet de l'agent peut en fournir un. C'est le
26
- // comportement historique (GATEWAY_URL est dans le même cas), on ne le change pas.
27
- //
28
- // Le token n'est JAMAIS journalisé, même tronqué.
29
- const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN?.trim() || undefined;
30
-
31
- /**
32
- * En-têtes d'un appel sortant du relais.
33
- *
34
- * Sans GATEWAY_TOKEN, rien n'est ajouté et l'appel part exactement comme avant —
35
- * la rétrocompatibilité avec les déploiements existants est stricte.
36
- */
37
- function gatewayHeaders(base) {
38
- const headers = { ...base };
39
- if (GATEWAY_TOKEN) headers.Authorization = `Bearer ${GATEWAY_TOKEN}`;
40
- return Object.keys(headers).length > 0 ? headers : undefined;
41
- }
42
-
43
- /** Le seul point de sortie HTTP du relais : tout appel au gateway passe par ici. */
44
- function gatewayFetch(path, init = {}) {
45
- const headers = gatewayHeaders(init.headers);
46
- return fetch(`${GATEWAY_URL}${path}`, headers ? { ...init, headers } : init);
47
- }
48
-
49
- /** Un refus d'authentification mérite mieux qu'un `HTTP 401` nu. */
50
- function isAuthStatus(status) {
51
- return status === 401 || status === 403;
52
- }
53
-
54
- function authErrorText(status) {
55
- return GATEWAY_TOKEN
56
- ? `Accès refusé par le gateway (HTTP ${status}) : le GATEWAY_TOKEN fourni est invalide, ` +
57
- `révoqué, ou ne correspond pas à cette gateway. Régénérez-le puis mettez à jour la ` +
58
- `variable GATEWAY_TOKEN dans la configuration de ce serveur MCP.`
59
- : `Accès refusé par le gateway (HTTP ${status}) : aucun GATEWAY_TOKEN n'est défini alors ` +
60
- `que ${GATEWAY_URL} en exige un. Ajoutez GATEWAY_TOKEN dans le bloc « env » de la ` +
61
- `configuration de ce serveur MCP.`;
62
- }
63
-
64
- /** Message d'erreur d'un appel outil — inchangé hors 401/403. */
65
- function gatewayErrorText(status, body) {
66
- return isAuthStatus(status) ? authErrorText(status) : `Gateway error ${status}: ${body}`;
67
- }
68
-
69
- /** Message d'une erreur relancée — inchangé hors 401/403. */
70
- function gatewayThrowText(status) {
71
- return isAuthStatus(status) ? authErrorText(status) : `HTTP ${status}`;
72
- }
73
-
74
- // --- Fichiers : le relais lit, l'agent ne fait que nommer ---
75
- //
76
- // Le relais tourne sur la machine de l'AGENT ; le MCP, lui, tourne sur la machine du
77
- // gateway. Un chemin de fichier transmis tel quel ne veut donc rien dire à l'arrivée :
78
- // le MCP cherche le fichier chez lui et ne le trouve pas.
79
- //
80
- // La règle : dès qu'un outil distant déclare une paire `<x>_path` / `<x>_content`, le
81
- // relais lit le fichier ICI, l'encode en base64 et envoie le CONTENU. Le paramètre
82
- // `<x>_content` (et `<x>_name`, s'il existe) est retiré du schéma publié à l'agent :
83
- // l'agent ne fournit qu'un chemin, et n'a aucun arbitrage à faire.
84
- //
85
- // Rien ne se déclenche sur un outil qui n'expose pas cette paire : un `output_path`
86
- // seul reste un chemin, interprété côté gateway comme avant.
87
- //
88
- // Le chemin vient de l'agent : il n'est donc PAS digne de confiance. Trois garde-fous,
89
- // tous configurables dans le bloc « env » de ce serveur MCP :
90
- //
91
- // GATEWAY_INLINE_ROOTS Dossiers dont un fichier peut sortir. C'est l'interrupteur :
92
- // non renseigné, le relais ne lit aucun fichier et l'appel
93
- // ÉCHOUE — laisser filer le chemin ferait chercher le fichier
94
- // au MCP sur la machine du gateway, où il trouverait un
95
- // homonyme ou rien du tout, sans rien signaler.
96
- // Séparateur : « ; » sous Windows, « : » ailleurs.
97
- // GATEWAY_INLINE_EXTENSIONS Extensions autorisées (défaut : toutes). Les racines restent
98
- // le vrai garde-fou.
99
- // GATEWAY_INLINE_MAX_BYTES Taille maximale d'un fichier transmis (défaut : 8 Mio).
100
- //
101
- // Chaque fichier réellement transmis laisse une ligne sur stderr : ce que l'agent fait
102
- // sortir de la machine doit être lisible dans le journal de l'agent.
103
-
104
- /** Les racines autorisées, résolues une fois pour toutes au démarrage. */
105
- const INLINE_ROOTS = (process.env.GATEWAY_INLINE_ROOTS ?? '')
106
- .split(path.delimiter)
107
- .map((entry) => entry.trim())
108
- .filter(Boolean)
109
- .map((entry) => {
110
- const resolved = path.resolve(entry);
111
- try {
112
- return fs.realpathSync(resolved);
113
- } catch {
114
- console.error(`[mcp-mode] GATEWAY_INLINE_ROOTS : dossier introuvable, ignoré — ${resolved}`);
115
- return null;
116
- }
117
- })
118
- .filter(Boolean);
119
-
120
- const INLINE_EXTENSIONS = (process.env.GATEWAY_INLINE_EXTENSIONS ?? '*')
121
- .split(',')
122
- .map((entry) => entry.trim().toLowerCase())
123
- .filter(Boolean);
124
-
125
- const INLINE_ANY_EXTENSION = INLINE_EXTENSIONS.includes('*');
126
-
127
- // 8 Mio : encodé en base64 le corps pèse ~10,7 Mio, sous le plafond de 16 Mio que le hub
128
- // applique quand il journalise les corps de requête.
129
- const INLINE_MAX_BYTES =
130
- Number.parseInt(process.env.GATEWAY_INLINE_MAX_BYTES ?? '', 10) || 8 * 1024 * 1024;
131
-
132
- const INLINE_FILES = INLINE_ROOTS.length > 0;
133
-
134
- if (INLINE_FILES) {
135
- console.error(
136
- `[mcp-mode] Transmission du contenu des fichiers activée depuis : ${INLINE_ROOTS.join(', ')} ` +
137
- `(extensions : ${INLINE_ANY_EXTENSION ? 'toutes' : INLINE_EXTENSIONS.join(' ')})`
138
- );
139
- }
140
-
141
- /** Le fichier est-il à l'intérieur d'une racine autorisée ? Liens symboliques résolus. */
142
- function insideAllowedRoot(realPath) {
143
- return INLINE_ROOTS.some((root) => {
144
- const rel = path.relative(root, realPath);
145
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
146
- });
147
- }
148
-
149
- /** Les paires `<x>_path` / `<x>_content` déclarées par le schéma d'un outil. */
150
- function inlinablePairs(inputSchema) {
151
- const props = inputSchema?.properties;
152
- if (!props) return [];
153
- return Object.keys(props)
154
- .filter((key) => key.endsWith('_content') && `${key.slice(0, -'_content'.length)}_path` in props)
155
- .map((contentProp) => {
156
- const stem = contentProp.slice(0, -'_content'.length);
157
- return {
158
- pathProp: `${stem}_path`,
159
- contentProp,
160
- nameProp: `${stem}_name` in props ? `${stem}_name` : null,
161
- };
162
- });
163
- }
164
-
165
- /** Le schéma tel que l'agent doit le voir : sans les paramètres que le relais remplit. */
166
- function schemaForAgent(inputSchema) {
167
- const inbound = inlinablePairs(inputSchema);
168
- const pairs = [...inbound, ...deliverablePairs(inputSchema)];
169
- if (pairs.length === 0) return inputSchema;
170
-
171
- const hidden = new Set(
172
- pairs.flatMap((p) => [p.contentProp, p.nameProp, p.relayProp].filter(Boolean))
173
- );
174
- const properties = Object.fromEntries(
175
- Object.entries(inputSchema.properties).filter(([key]) => !hidden.has(key))
176
- );
177
-
178
- // Le caractère obligatoire d'un `<x>_path` est celui que le MCP déclare, dans les deux
179
- // sens. Un chemin optionnel veut dire quelque chose : à l'aller, que l'outil accepte
180
- // aussi sa donnée autrement (un texte passé en ligne, par exemple) ; au retour, qu'il
181
- // sait quoi faire sans destination. Sans `<x>_path`, le relais ne fait rien.
182
- const required = (inputSchema.required ?? []).filter((key) => !hidden.has(key));
183
-
184
- return { ...inputSchema, properties, required };
185
- }
186
-
187
- /**
188
- * Remplace chaque `<x>_path` par le contenu du fichier lu sur la machine de l'agent.
189
- *
190
- * Tout refus est explicite : l'agent doit savoir pourquoi son fichier ne part pas,
191
- * sans avoir à deviner s'il s'agit d'une absence, d'un type ou d'une interdiction.
192
- */
193
- function inlineFileArgs(inputSchema, args = {}, label = '') {
194
- const pairs = inlinablePairs(inputSchema);
195
- if (pairs.length === 0) return args;
196
-
197
- const out = { ...args };
198
- for (const { pathProp, contentProp, nameProp } of pairs) {
199
- const filePath = out[pathProp];
200
- if (typeof filePath !== 'string' || filePath === '') continue;
201
-
202
- // Un outil qui déclare la paire attend le CONTENU : il tourne sur la machine du
203
- // gateway, où le chemin de l'agent ne désigne rien — ou pire, désigne un homonyme
204
- // qu'il traiterait sans broncher. Laisser filer le chemin serait une dégradation
205
- // silencieuse ; on refuse en nommant la variable à renseigner.
206
- if (!INLINE_FILES) {
207
- throw new Error(
208
- `Transmission de fichier impossible : aucun dossier de lecture autorisé. L'outil ` +
209
- `attend le contenu du fichier (paramètre ${pathProp}), mais il tourne sur la machine ` +
210
- `du gateway et ne peut pas lire la vôtre. Renseignez GATEWAY_INLINE_ROOTS dans le ` +
211
- `bloc « env » de la configuration de ce serveur MCP.`
212
- );
213
- }
214
-
215
- const resolved = path.resolve(filePath);
216
-
217
- // realpath avant tout contrôle : sans ça, un lien symbolique placé dans une racine
218
- // autorisée ferait sortir n'importe quel fichier de la machine.
219
- let real;
220
- let stat;
221
- try {
222
- real = fs.realpathSync(resolved);
223
- stat = fs.statSync(real);
224
- } catch {
225
- throw new Error(
226
- `Fichier introuvable sur la machine de l'agent : ${resolved} (paramètre ${pathProp}).`
227
- );
228
- }
229
- if (!stat.isFile()) {
230
- throw new Error(`${resolved} n'est pas un fichier (paramètre ${pathProp}).`);
231
- }
232
- if (!insideAllowedRoot(real)) {
233
- throw new Error(
234
- `Lecture refusée : ${real} est hors des dossiers autorisés (${INLINE_ROOTS.join(', ')}). ` +
235
- `Déplacez le fichier dans l'un d'eux, ou ajoutez son dossier à GATEWAY_INLINE_ROOTS ` +
236
- `dans la configuration de ce serveur MCP.`
237
- );
238
- }
239
- const ext = path.extname(real).toLowerCase();
240
- if (!INLINE_ANY_EXTENSION && !INLINE_EXTENSIONS.includes(ext)) {
241
- throw new Error(
242
- `Lecture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
243
- `(autorisées : ${INLINE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_EXTENSIONS).`
244
- );
245
- }
246
- if (stat.size > INLINE_MAX_BYTES) {
247
- throw new Error(
248
- `Fichier trop volumineux à transmettre : ${real} pèse ${stat.size} octets, ` +
249
- `la limite est de ${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES).`
250
- );
251
- }
252
-
253
- out[contentProp] = fs.readFileSync(real).toString('base64');
254
- if (nameProp) out[nameProp] = path.basename(real);
255
- delete out[pathProp];
256
-
257
- console.error(
258
- `[mcp-mode] Fichier transmis${label ? ` à ${label}` : ''} : ${real} (${stat.size} octets)`
259
- );
260
- }
261
- return out;
262
- }
263
-
264
- // --- Fichiers : le MCP produit, le relais écrit ---
265
- //
266
- // Le sens retour, symétrique du précédent. `inlineFileArgs` ne réécrit que les arguments
267
- // à l'aller ; un fichier produit par le MCP, lui, n'a aucun moyen d'atteindre la machine
268
- // de l'agent : le protocole n'offre au serveur aucune primitive pour y écrire.
269
- //
270
- // La règle : dès qu'un outil distant déclare une paire `<x>_path` / `<x>_relay`, le relais
271
- // retient le chemin, annonce sa présence au MCP en posant `<x>_relay` à true, et ne
272
- // transmet PAS `<x>_path` — qui ne désignerait rien à l'arrivée. Le MCP répond alors avec
273
- // le contenu du fichier, que le relais écrit ICI, à l'emplacement retenu.
274
- //
275
- // Le contenu ne ressort JAMAIS vers l'agent : le relais le retire du résultat, qu'il ait
276
- // réussi ou non à écrire. Un fichier de 8 Mio encodé en base64 n'a rien à faire dans le
277
- // contexte d'un agent, et rien ne justifie de l'y déverser « au cas où » : l'appel échoue,
278
- // le fichier est perdu, l'agent recommence.
279
- //
280
- // Sans relais dans la chaîne — MCP lancé en stdio à côté de l'agent — `<x>_relay` reste
281
- // absent : les deux machines n'en font qu'une, et le MCP écrit lui-même à `<x>_path`.
282
- //
283
- // Écrire est plus dangereux que lire : la fonction a donc sa propre racine autorisée,
284
- // et n'hérite pas de celle de la lecture.
285
- //
286
- // GATEWAY_INLINE_WRITE_ROOTS Dossiers où un fichier peut être déposé. C'est
287
- // l'interrupteur : non renseigné, aucune livraison
288
- // n'a lieu et l'appel échoue en le disant.
289
- // GATEWAY_INLINE_WRITE_EXTENSIONS Extensions autorisées (défaut : toutes). Les racines
290
- // restent le vrai garde-fou.
291
- // GATEWAY_INLINE_MAX_BYTES Plafond de taille, partagé avec la lecture.
292
-
293
- const WRITE_ROOTS = (process.env.GATEWAY_INLINE_WRITE_ROOTS ?? '')
294
- .split(path.delimiter)
295
- .map((entry) => entry.trim())
296
- .filter(Boolean)
297
- .map((entry) => {
298
- const resolved = path.resolve(entry);
299
- try {
300
- return fs.realpathSync(resolved);
301
- } catch {
302
- console.error(`[mcp-mode] GATEWAY_INLINE_WRITE_ROOTS : dossier introuvable, ignoré — ${resolved}`);
303
- return null;
304
- }
305
- })
306
- .filter(Boolean);
307
-
308
- const WRITE_EXTENSIONS = (process.env.GATEWAY_INLINE_WRITE_EXTENSIONS ?? '*')
309
- .split(',')
310
- .map((entry) => entry.trim().toLowerCase())
311
- .filter(Boolean);
312
-
313
- const WRITE_ANY_EXTENSION = WRITE_EXTENSIONS.includes('*');
314
-
315
- if (WRITE_ROOTS.length > 0) {
316
- console.error(
317
- `[mcp-mode] Écriture des fichiers reçus activée vers : ${WRITE_ROOTS.join(', ')} ` +
318
- `(extensions : ${WRITE_ANY_EXTENSION ? 'toutes' : WRITE_EXTENSIONS.join(' ')})`
319
- );
320
- }
321
-
322
- /** Les paires `<x>_path` / `<x>_relay` déclarées par le schéma d'un outil. */
323
- function deliverablePairs(inputSchema) {
324
- const props = inputSchema?.properties;
325
- if (!props) return [];
326
- return Object.keys(props)
327
- .filter((key) => key.endsWith('_relay') && `${key.slice(0, -'_relay'.length)}_path` in props)
328
- .map((relayProp) => {
329
- const stem = relayProp.slice(0, -'_relay'.length);
330
- return {
331
- pathProp: `${stem}_path`,
332
- relayProp,
333
- overwriteProp: `${stem}_overwrite` in props ? `${stem}_overwrite` : null,
334
- };
335
- });
336
- }
337
-
338
- /**
339
- * Le chemin de destination, résolu et vérifié contre les racines autorisées.
340
- *
341
- * `destDir` est toujours un dossier et `fileName` le nom que le MCP donne au fichier :
342
- * le relais ne renomme jamais. Passer `fileName` vide ne vérifie que le dossier — ce
343
- * qu'on fait à l'aller, pour échouer avant d'avoir fait voyager les octets pour rien.
344
- *
345
- * Le fichier n'existe pas encore : impossible d'appeler `realpath` dessus. On résout
346
- * donc les liens du plus proche ancêtre existant, sans quoi un lien symbolique déposé
347
- * dans une racine autorisée permettrait d'écrire n'importe où sur la machine.
348
- */
349
- function resolveWriteTarget(destDir, fileName = '') {
350
- const resolved = fileName
351
- ? path.resolve(destDir, path.basename(fileName))
352
- : path.resolve(destDir);
353
-
354
- let existing = path.dirname(resolved);
355
- const trailing = [path.basename(resolved)];
356
- while (!fs.existsSync(existing)) {
357
- const parent = path.dirname(existing);
358
- if (parent === existing) {
359
- throw new Error(`Destination inatteignable sur la machine de l'agent : ${resolved}.`);
360
- }
361
- trailing.unshift(path.basename(existing));
362
- existing = parent;
363
- }
364
-
365
- const real = path.join(fs.realpathSync(existing), ...trailing);
366
-
367
- const inside = WRITE_ROOTS.some((root) => {
368
- const rel = path.relative(root, real);
369
- // Un dossier de destination PEUT être la racine elle-même ; un fichier, non.
370
- if (rel === '') return fileName === '';
371
- return !rel.startsWith('..') && !path.isAbsolute(rel);
372
- });
373
- if (!inside) {
374
- throw new Error(
375
- `Écriture refusée : ${real} est hors des dossiers autorisés (${WRITE_ROOTS.join(', ')}). ` +
376
- `Choisissez une destination dans l'un d'eux, ou ajoutez son dossier à ` +
377
- `GATEWAY_INLINE_WRITE_ROOTS dans la configuration de ce serveur MCP.`
378
- );
379
- }
380
-
381
- const ext = path.extname(real).toLowerCase();
382
- if (fileName && !WRITE_ANY_EXTENSION && !WRITE_EXTENSIONS.includes(ext)) {
383
- throw new Error(
384
- `Écriture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
385
- `(autorisées : ${WRITE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_WRITE_EXTENSIONS).`
386
- );
387
- }
388
-
389
- return real;
390
- }
391
-
392
- /**
393
- * Retire `<x>_path` des arguments et pose `<x>_relay`, pour que le MCP renvoie le
394
- * contenu au lieu de tenter une écriture qui atterrirait sur SA machine.
395
- */
396
- function prepareDeliveries(inputSchema, args = {}) {
397
- const pairs = deliverablePairs(inputSchema);
398
- if (pairs.length === 0) return { args, deliveries: [] };
399
-
400
- const out = { ...args };
401
- const deliveries = [];
402
- for (const { pathProp, relayProp, overwriteProp } of pairs) {
403
- const destPath = out[pathProp];
404
- if (typeof destPath !== 'string' || destPath === '') continue;
405
-
406
- if (WRITE_ROOTS.length === 0) {
407
- throw new Error(
408
- `Livraison de fichier impossible : aucun dossier d'écriture autorisé. Le MCP tourne ` +
409
- `sur la machine du gateway et ne peut pas écrire sur la vôtre. Renseignez ` +
410
- `GATEWAY_INLINE_WRITE_ROOTS dans le bloc « env » de la configuration de ce ` +
411
- `serveur MCP avec le ou les dossiers où déposer les fichiers reçus.`
412
- );
413
- }
414
-
415
- // Vérification du dossier seul : le nom du fichier n'est connu qu'au retour.
416
- resolveWriteTarget(destPath);
417
- deliveries.push({
418
- destDir: destPath,
419
- overwrite: overwriteProp ? out[overwriteProp] === true : false,
420
- });
421
- out[relayProp] = true;
422
- delete out[pathProp];
423
- }
424
- return { args: out, deliveries };
425
- }
426
-
427
- /**
428
- * Écrit les fichiers renvoyés par le MCP et purge le résultat de leur contenu.
429
- *
430
- * Le contenu est retiré dans tous les cas, y compris en cas d'échec d'écriture : c'est
431
- * la seule garantie qu'un base64 ne finira jamais dans le contexte de l'agent.
432
- */
433
- function applyDeliveries(result, deliveries, label = '') {
434
- if (deliveries.length === 0) return result;
435
-
436
- const blocks = Array.isArray(result?.content) ? result.content : [];
437
- const blobs = blocks.filter((b) => b?.type === 'resource' && typeof b.resource?.blob === 'string');
438
- const kept = blocks.filter((b) => !blobs.includes(b));
439
-
440
- if (result?.isError) return { ...result, content: kept.length > 0 ? kept : blocks };
441
-
442
- const notes = [];
443
- deliveries.forEach((delivery, i) => {
444
- const blob = blobs[i];
445
- if (!blob) {
446
- notes.push(`Aucun contenu renvoyé par le MCP pour ${delivery.destDir} : rien n'a été écrit.`);
447
- return;
448
- }
449
- let target = delivery.destDir;
450
- try {
451
- const fileName = blob.resource.name || path.basename(new URL(blob.resource.uri).pathname);
452
- if (!fileName) throw new Error("le MCP n'a pas nommé le fichier renvoyé");
453
- target = resolveWriteTarget(delivery.destDir, fileName);
454
-
455
- const data = Buffer.from(blob.resource.blob, 'base64');
456
- if (data.length > INLINE_MAX_BYTES) {
457
- throw new Error(
458
- `fichier trop volumineux : ${data.length} octets, la limite est de ` +
459
- `${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES)`
460
- );
461
- }
462
- if (!delivery.overwrite && fs.existsSync(target)) {
463
- throw new Error('la destination existe déjà');
464
- }
465
- fs.mkdirSync(path.dirname(target), { recursive: true });
466
- fs.writeFileSync(target, data);
467
- notes.push(`Fichier écrit : ${target} (${data.length} octets).`);
468
- console.error(
469
- `[mcp-mode] Fichier reçu${label ? ` de ${label}` : ''} : ${target} (${data.length} octets)`
470
- );
471
- } catch (err) {
472
- notes.push(`Échec de l'écriture dans ${target} : ${err.message}.`);
473
- }
474
- });
475
-
476
- const failed = notes.some((note) => note.startsWith('Échec') || note.startsWith('Aucun'));
477
- return {
478
- ...result,
479
- content: [...kept, { type: 'text', text: notes.join('\n') }],
480
- ...(failed ? { isError: true } : {}),
481
- };
482
- }
483
-
484
- /** Schémas des outils d'une route, mis en cache — nécessaires pour `call_tool`. */
485
- const routeSchemas = new Map();
486
-
487
- async function toolSchema(route, toolName) {
488
- // Toujours récupéré, même si l'inlining en lecture est désactivé : c'est le schéma qui
489
- // révèle une paire `<x>_path` / `<x>_relay`, et la manquer ferait repartir un chemin
490
- // de destination vers le MCP distant, qui écrirait alors sur SA machine sans le dire.
491
- if (!routeSchemas.has(route)) {
492
- try {
493
- const res = await gatewayFetch(`${route}/tools`);
494
- const list = res.ok ? await res.json() : [];
495
- routeSchemas.set(route, new Map(list.map((t) => [t.name, t.inputSchema])));
496
- } catch {
497
- routeSchemas.set(route, new Map());
498
- }
499
- }
500
- return routeSchemas.get(route).get(toolName) ?? null;
501
- }
502
-
503
- // --- MCP Server ---
504
-
505
- // Le gateway (mode serveur HTTP) peut être paramétré (EXPOSE_AT_AGENT_STARTUP) pour
506
- // pré-exposer les outils de certains MCP directement à l'agent. La config vit uniquement
507
- // sur le serveur : ce bridge se contente de récupérer ces outils et de les publier, de
508
- // façon transparente. En cas d'échec (ancien gateway sans la route, gateway protégé par
509
- // OAuth, etc.), on continue sans pré-exposition.
510
- async function fetchProxyTools() {
511
- try {
512
- const res = await gatewayFetch('/startup-exposition');
513
- if (!res.ok) {
514
- // La pré-exposition reste optionnelle : on n'interrompt pas le démarrage.
515
- // Mais un 401/403 ici annonce que TOUS les appels seront refusés — le dire
516
- // maintenant évite de chercher plus tard. Sans le token, évidemment.
517
- if (isAuthStatus(res.status)) console.error(`[mcp-mode] ${authErrorText(res.status)}`);
518
- return [];
519
- }
520
- const { proxyTools } = await res.json();
521
- return proxyTools ?? [];
522
- } catch {
523
- return [];
524
- }
525
- }
526
-
527
- const proxyTools = await fetchProxyTools();
528
- if (proxyTools.length) {
529
- console.error(`[mcp-mode] ${proxyTools.length} proxy tool(s) exposed directly from gateway`);
530
- }
531
-
532
- const server = new Server(
533
- { name: 'mcp-http-gateway-mcp', version: '1.0.0' },
534
- { capabilities: { tools: {} } }
535
- );
536
-
537
- const tools = [
538
- {
539
- name: 'list_mcps',
540
- description: 'List all MCPs available on the gateway with their route and description.',
541
- inputSchema: { type: 'object', properties: {} },
542
- },
543
- {
544
- name: 'list_tools',
545
- description: 'List all tools available for a given MCP route.',
546
- inputSchema: {
547
- type: 'object',
548
- properties: {
549
- route: { type: 'string', description: 'MCP route as returned by list_mcps (e.g. "/documentor")' },
550
- },
551
- required: ['route'],
552
- },
553
- },
554
- {
555
- name: 'call_tool',
556
- description: 'Call a tool on a given MCP route.',
557
- inputSchema: {
558
- type: 'object',
559
- properties: {
560
- route: { type: 'string', description: 'MCP route (e.g. "/documentor")' },
561
- tool_name: { type: 'string', description: 'Tool name to call' },
562
- arguments: { type: 'object', description: 'Arguments to pass to the tool' },
563
- },
564
- required: ['route', 'tool_name'],
565
- },
566
- },
567
- ];
568
-
569
- const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
570
- const allTools = [
571
- ...tools,
572
- ...proxyTools.map(({ name, description, inputSchema }) => ({
573
- name,
574
- description,
575
- inputSchema: schemaForAgent(inputSchema),
576
- })),
577
- ];
578
-
579
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
580
-
581
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
582
- const { name, arguments: args = {} } = request.params;
583
-
584
- try {
585
- const proxy = proxyIndex.get(name);
586
- if (proxy) {
587
- const label = `${proxy.route}/${proxy.toolName}`;
588
- const { args: outArgs, deliveries } = prepareDeliveries(proxy.inputSchema, args ?? {});
589
- const res = await gatewayFetch(`${proxy.route}/tools/${proxy.toolName}`, {
590
- method: 'POST',
591
- headers: { 'Content-Type': 'application/json' },
592
- body: JSON.stringify(inlineFileArgs(proxy.inputSchema, outArgs, label)),
593
- });
594
- if (!res.ok) {
595
- const text = await res.text();
596
- return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
597
- }
598
- return applyDeliveries(await res.json(), deliveries, label);
599
- }
600
-
601
- if (name === 'list_mcps') {
602
- const res = await gatewayFetch('/mcp-definitions');
603
- if (!res.ok) throw new Error(gatewayThrowText(res.status));
604
- const definitions = await res.json();
605
- const text = definitions
606
- .map(m => `- ${m.id} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
607
- .join('\n');
608
- return { content: [{ type: 'text', text }] };
609
- }
610
-
611
- if (name === 'list_tools') {
612
- const res = await gatewayFetch(`${args.route}/tools`);
613
- if (!res.ok) throw new Error(gatewayThrowText(res.status));
614
- const toolsList = await res.json();
615
- routeSchemas.set(args.route, new Map(toolsList.map((t) => [t.name, t.inputSchema])));
616
- // Même règle que pour les outils pré-exposés : l'agent ne doit pas découvrir
617
- // les paramètres de contenu, c'est le relais qui les remplit.
618
- const text = JSON.stringify(
619
- toolsList.map((t) => (t.inputSchema ? { ...t, inputSchema: schemaForAgent(t.inputSchema) } : t))
620
- );
621
- return { content: [{ type: 'text', text }] };
622
- }
623
-
624
- if (name === 'call_tool') {
625
- const schema = await toolSchema(args.route, args.tool_name);
626
- const label = `${args.route}/${args.tool_name}`;
627
- const { args: outArgs, deliveries } = prepareDeliveries(schema, args.arguments ?? {});
628
- const res = await gatewayFetch(`${args.route}/tools/${args.tool_name}`, {
629
- method: 'POST',
630
- headers: { 'Content-Type': 'application/json' },
631
- body: JSON.stringify(inlineFileArgs(schema, outArgs, label)),
632
- });
633
- if (!res.ok) {
634
- const text = await res.text();
635
- return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
636
- }
637
- return applyDeliveries(await res.json(), deliveries, label);
638
- }
639
-
640
- return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
641
- } catch (err) {
642
- return { content: [{ type: 'text', text: `Request failed: ${err.message}` }], isError: true };
643
- }
644
- });
645
-
646
- const transport = new StdioServerTransport();
647
- await server.connect(transport);
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import {
8
+ ListToolsRequestSchema,
9
+ CallToolRequestSchema,
10
+ } from '@modelcontextprotocol/sdk/types.js';
11
+ import {
12
+ EXEC_ARG, MAX_EXEC_ROUNDS, parseExecRoutes, normalizeRoute, execAnnouncement, execRequestOf, runExecRequest,
13
+ } from './gateway-exec.js';
14
+ import {
15
+ WATCH_ARG, declaresWatch, watchAnnouncement, watchSpecOf, armWatch, removeOwnWatches, pruneOrphanWatches,
16
+ } from './gateway-watch.js';
17
+
18
+ const GATEWAY_URL = process.env.GATEWAY_URL?.replace(/\/$/, '');
19
+ if (!GATEWAY_URL) {
20
+ console.error('[mcp-mode] GATEWAY_URL environment variable is required');
21
+ process.exit(1);
22
+ }
23
+
24
+ // Token porteur optionnel. Utile quand le gateway n'est pas joint en direct mais à
25
+ // travers un frontal qui authentifie (hub mcp-gateway-manager, reverse-proxy…).
26
+ //
27
+ // ATTENTION — `import 'dotenv/config'` ci-dessus charge le `.env` du **répertoire
28
+ // courant de l'agent**, pas celui du gateway. dotenv n'écrase jamais une variable
29
+ // déjà présente dans l'environnement : le bloc `env` de la config MCP l'emporte
30
+ // donc toujours. En revanche, si le bloc `env` ne définit PAS GATEWAY_TOKEN, un
31
+ // `.env` traînant dans le projet de l'agent peut en fournir un. C'est le
32
+ // comportement historique (GATEWAY_URL est dans le même cas), on ne le change pas.
33
+ //
34
+ // Le token n'est JAMAIS journalisé, même tronqué.
35
+ const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN?.trim() || undefined;
36
+
37
+ /**
38
+ * En-têtes d'un appel sortant du relais.
39
+ *
40
+ * Sans GATEWAY_TOKEN, rien n'est ajouté et l'appel part exactement comme avant —
41
+ * la rétrocompatibilité avec les déploiements existants est stricte.
42
+ */
43
+ function gatewayHeaders(base) {
44
+ const headers = { ...base };
45
+ if (GATEWAY_TOKEN) headers.Authorization = `Bearer ${GATEWAY_TOKEN}`;
46
+ return Object.keys(headers).length > 0 ? headers : undefined;
47
+ }
48
+
49
+ /** Le seul point de sortie HTTP du relais : tout appel au gateway passe par ici. */
50
+ function gatewayFetch(path, init = {}) {
51
+ const headers = gatewayHeaders(init.headers);
52
+ return fetch(`${GATEWAY_URL}${path}`, headers ? { ...init, headers } : init);
53
+ }
54
+
55
+ /** Un refus d'authentification mérite mieux qu'un `HTTP 401` nu. */
56
+ function isAuthStatus(status) {
57
+ return status === 401 || status === 403;
58
+ }
59
+
60
+ function authErrorText(status) {
61
+ return GATEWAY_TOKEN
62
+ ? `Accès refusé par le gateway (HTTP ${status}) : le GATEWAY_TOKEN fourni est invalide, ` +
63
+ `révoqué, ou ne correspond pas à cette gateway. Régénérez-le puis mettez à jour la ` +
64
+ `variable GATEWAY_TOKEN dans la configuration de ce serveur MCP.`
65
+ : `Accès refusé par le gateway (HTTP ${status}) : aucun GATEWAY_TOKEN n'est défini alors ` +
66
+ `que ${GATEWAY_URL} en exige un. Ajoutez GATEWAY_TOKEN dans le bloc « env » de la ` +
67
+ `configuration de ce serveur MCP.`;
68
+ }
69
+
70
+ /** Message d'erreur d'un appel outil — inchangé hors 401/403. */
71
+ function gatewayErrorText(status, body) {
72
+ return isAuthStatus(status) ? authErrorText(status) : `Gateway error ${status}: ${body}`;
73
+ }
74
+
75
+ /** Message d'une erreur relancée — inchangé hors 401/403. */
76
+ function gatewayThrowText(status) {
77
+ return isAuthStatus(status) ? authErrorText(status) : `HTTP ${status}`;
78
+ }
79
+
80
+ // --- Fichiers : le relais lit, l'agent ne fait que nommer ---
81
+ //
82
+ // Le relais tourne sur la machine de l'AGENT ; le MCP, lui, tourne sur la machine du
83
+ // gateway. Un chemin de fichier transmis tel quel ne veut donc rien dire à l'arrivée :
84
+ // le MCP cherche le fichier chez lui et ne le trouve pas.
85
+ //
86
+ // La règle : dès qu'un outil distant déclare une paire `<x>_path` / `<x>_content`, le
87
+ // relais lit le fichier ICI, l'encode en base64 et envoie le CONTENU. Le paramètre
88
+ // `<x>_content` (et `<x>_name`, s'il existe) est retiré du schéma publié à l'agent :
89
+ // l'agent ne fournit qu'un chemin, et n'a aucun arbitrage à faire.
90
+ //
91
+ // Rien ne se déclenche sur un outil qui n'expose pas cette paire : un `output_path`
92
+ // seul reste un chemin, interprété côté gateway comme avant.
93
+ //
94
+ // Le chemin vient de l'agent : il n'est donc PAS digne de confiance. Trois garde-fous,
95
+ // tous configurables dans le bloc « env » de ce serveur MCP :
96
+ //
97
+ // GATEWAY_INLINE_ROOTS Dossiers dont un fichier peut sortir. C'est l'interrupteur :
98
+ // non renseigné, le relais ne lit aucun fichier et l'appel
99
+ // ÉCHOUE — laisser filer le chemin ferait chercher le fichier
100
+ // au MCP sur la machine du gateway, où il trouverait un
101
+ // homonyme ou rien du tout, sans rien signaler.
102
+ // Séparateur : « ; » sous Windows, « : » ailleurs.
103
+ // GATEWAY_INLINE_EXTENSIONS Extensions autorisées (défaut : toutes). Les racines restent
104
+ // le vrai garde-fou.
105
+ // GATEWAY_INLINE_MAX_BYTES Taille maximale d'un fichier transmis (défaut : 8 Mio).
106
+ //
107
+ // Chaque fichier réellement transmis laisse une ligne sur stderr : ce que l'agent fait
108
+ // sortir de la machine doit être lisible dans le journal de l'agent.
109
+
110
+ /** Les racines autorisées, résolues une fois pour toutes au démarrage. */
111
+ const INLINE_ROOTS = (process.env.GATEWAY_INLINE_ROOTS ?? '')
112
+ .split(path.delimiter)
113
+ .map((entry) => entry.trim())
114
+ .filter(Boolean)
115
+ .map((entry) => {
116
+ const resolved = path.resolve(entry);
117
+ try {
118
+ return fs.realpathSync(resolved);
119
+ } catch {
120
+ console.error(`[mcp-mode] GATEWAY_INLINE_ROOTS : dossier introuvable, ignoré — ${resolved}`);
121
+ return null;
122
+ }
123
+ })
124
+ .filter(Boolean);
125
+
126
+ const INLINE_EXTENSIONS = (process.env.GATEWAY_INLINE_EXTENSIONS ?? '*')
127
+ .split(',')
128
+ .map((entry) => entry.trim().toLowerCase())
129
+ .filter(Boolean);
130
+
131
+ const INLINE_ANY_EXTENSION = INLINE_EXTENSIONS.includes('*');
132
+
133
+ // 8 Mio : encodé en base64 le corps pèse ~10,7 Mio, sous le plafond de 16 Mio que le hub
134
+ // applique quand il journalise les corps de requête.
135
+ const INLINE_MAX_BYTES =
136
+ Number.parseInt(process.env.GATEWAY_INLINE_MAX_BYTES ?? '', 10) || 8 * 1024 * 1024;
137
+
138
+ const INLINE_FILES = INLINE_ROOTS.length > 0;
139
+
140
+ if (INLINE_FILES) {
141
+ console.error(
142
+ `[mcp-mode] Transmission du contenu des fichiers activée depuis : ${INLINE_ROOTS.join(', ')} ` +
143
+ `(extensions : ${INLINE_ANY_EXTENSION ? 'toutes' : INLINE_EXTENSIONS.join(' ')})`
144
+ );
145
+ }
146
+
147
+ /** Le fichier est-il à l'intérieur d'une racine autorisée ? Liens symboliques résolus. */
148
+ function insideAllowedRoot(realPath) {
149
+ return INLINE_ROOTS.some((root) => {
150
+ const rel = path.relative(root, realPath);
151
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
152
+ });
153
+ }
154
+
155
+ /** Les paires `<x>_path` / `<x>_content` déclarées par le schéma d'un outil. */
156
+ function inlinablePairs(inputSchema) {
157
+ const props = inputSchema?.properties;
158
+ if (!props) return [];
159
+ return Object.keys(props)
160
+ .filter((key) => key.endsWith('_content') && `${key.slice(0, -'_content'.length)}_path` in props)
161
+ .map((contentProp) => {
162
+ const stem = contentProp.slice(0, -'_content'.length);
163
+ return {
164
+ pathProp: `${stem}_path`,
165
+ contentProp,
166
+ nameProp: `${stem}_name` in props ? `${stem}_name` : null,
167
+ };
168
+ });
169
+ }
170
+
171
+ /** Le schéma tel que l'agent doit le voir : sans les paramètres que le relais remplit. */
172
+ function schemaForAgent(inputSchema) {
173
+ const inbound = inlinablePairs(inputSchema);
174
+ const pairs = [...inbound, ...deliverablePairs(inputSchema)];
175
+ const watching = declaresWatch(inputSchema);
176
+ if (pairs.length === 0 && !watching) return inputSchema;
177
+
178
+ // `_gateway_watch` est rempli par le relais, jamais par l'agent (voir gateway-watch.js).
179
+ const hidden = new Set([
180
+ ...pairs.flatMap((p) => [p.contentProp, p.nameProp, p.relayProp].filter(Boolean)),
181
+ ...(watching ? [WATCH_ARG] : []),
182
+ ]);
183
+ const properties = Object.fromEntries(
184
+ Object.entries(inputSchema.properties).filter(([key]) => !hidden.has(key))
185
+ );
186
+
187
+ // Le caractère obligatoire d'un `<x>_path` est celui que le MCP déclare, dans les deux
188
+ // sens. Un chemin optionnel veut dire quelque chose : à l'aller, que l'outil accepte
189
+ // aussi sa donnée autrement (un texte passé en ligne, par exemple) ; au retour, qu'il
190
+ // sait quoi faire sans destination. Sans `<x>_path`, le relais ne fait rien.
191
+ const required = (inputSchema.required ?? []).filter((key) => !hidden.has(key));
192
+
193
+ return { ...inputSchema, properties, required };
194
+ }
195
+
196
+ /**
197
+ * Remplace chaque `<x>_path` par le contenu du fichier lu sur la machine de l'agent.
198
+ *
199
+ * Tout refus est explicite : l'agent doit savoir pourquoi son fichier ne part pas,
200
+ * sans avoir à deviner s'il s'agit d'une absence, d'un type ou d'une interdiction.
201
+ */
202
+ function inlineFileArgs(inputSchema, args = {}, label = '') {
203
+ const pairs = inlinablePairs(inputSchema);
204
+ if (pairs.length === 0) return args;
205
+
206
+ const out = { ...args };
207
+ for (const { pathProp, contentProp, nameProp } of pairs) {
208
+ const filePath = out[pathProp];
209
+ if (typeof filePath !== 'string' || filePath === '') continue;
210
+
211
+ // Un outil qui déclare la paire attend le CONTENU : il tourne sur la machine du
212
+ // gateway, où le chemin de l'agent ne désigne rien — ou pire, désigne un homonyme
213
+ // qu'il traiterait sans broncher. Laisser filer le chemin serait une dégradation
214
+ // silencieuse ; on refuse en nommant la variable à renseigner.
215
+ if (!INLINE_FILES) {
216
+ throw new Error(
217
+ `Transmission de fichier impossible : aucun dossier de lecture autorisé. L'outil ` +
218
+ `attend le contenu du fichier (paramètre ${pathProp}), mais il tourne sur la machine ` +
219
+ `du gateway et ne peut pas lire la vôtre. Renseignez GATEWAY_INLINE_ROOTS dans le ` +
220
+ `bloc « env » de la configuration de ce serveur MCP.`
221
+ );
222
+ }
223
+
224
+ const resolved = path.resolve(filePath);
225
+
226
+ // realpath avant tout contrôle : sans ça, un lien symbolique placé dans une racine
227
+ // autorisée ferait sortir n'importe quel fichier de la machine.
228
+ let real;
229
+ let stat;
230
+ try {
231
+ real = fs.realpathSync(resolved);
232
+ stat = fs.statSync(real);
233
+ } catch {
234
+ throw new Error(
235
+ `Fichier introuvable sur la machine de l'agent : ${resolved} (paramètre ${pathProp}).`
236
+ );
237
+ }
238
+ if (!stat.isFile()) {
239
+ throw new Error(`${resolved} n'est pas un fichier (paramètre ${pathProp}).`);
240
+ }
241
+ if (!insideAllowedRoot(real)) {
242
+ throw new Error(
243
+ `Lecture refusée : ${real} est hors des dossiers autorisés (${INLINE_ROOTS.join(', ')}). ` +
244
+ `Déplacez le fichier dans l'un d'eux, ou ajoutez son dossier à GATEWAY_INLINE_ROOTS ` +
245
+ `dans la configuration de ce serveur MCP.`
246
+ );
247
+ }
248
+ const ext = path.extname(real).toLowerCase();
249
+ if (!INLINE_ANY_EXTENSION && !INLINE_EXTENSIONS.includes(ext)) {
250
+ throw new Error(
251
+ `Lecture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
252
+ `(autorisées : ${INLINE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_EXTENSIONS).`
253
+ );
254
+ }
255
+ if (stat.size > INLINE_MAX_BYTES) {
256
+ throw new Error(
257
+ `Fichier trop volumineux à transmettre : ${real} pèse ${stat.size} octets, ` +
258
+ `la limite est de ${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES).`
259
+ );
260
+ }
261
+
262
+ out[contentProp] = fs.readFileSync(real).toString('base64');
263
+ if (nameProp) out[nameProp] = path.basename(real);
264
+ delete out[pathProp];
265
+
266
+ console.error(
267
+ `[mcp-mode] Fichier transmis${label ? ` à ${label}` : ''} : ${real} (${stat.size} octets)`
268
+ );
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // --- Fichiers : le MCP produit, le relais écrit ---
274
+ //
275
+ // Le sens retour, symétrique du précédent. `inlineFileArgs` ne réécrit que les arguments
276
+ // à l'aller ; un fichier produit par le MCP, lui, n'a aucun moyen d'atteindre la machine
277
+ // de l'agent : le protocole n'offre au serveur aucune primitive pour y écrire.
278
+ //
279
+ // La règle : dès qu'un outil distant déclare une paire `<x>_path` / `<x>_relay`, le relais
280
+ // retient le chemin, annonce sa présence au MCP en posant `<x>_relay` à true, et ne
281
+ // transmet PAS `<x>_path` — qui ne désignerait rien à l'arrivée. Le MCP répond alors avec
282
+ // le contenu du fichier, que le relais écrit ICI, à l'emplacement retenu.
283
+ //
284
+ // Le contenu ne ressort JAMAIS vers l'agent : le relais le retire du résultat, qu'il ait
285
+ // réussi ou non à écrire. Un fichier de 8 Mio encodé en base64 n'a rien à faire dans le
286
+ // contexte d'un agent, et rien ne justifie de l'y déverser « au cas où » : l'appel échoue,
287
+ // le fichier est perdu, l'agent recommence.
288
+ //
289
+ // Sans relais dans la chaîne — MCP lancé en stdio à côté de l'agent — `<x>_relay` reste
290
+ // absent : les deux machines n'en font qu'une, et le MCP écrit lui-même à `<x>_path`.
291
+ //
292
+ // Écrire est plus dangereux que lire : la fonction a donc sa propre racine autorisée,
293
+ // et n'hérite pas de celle de la lecture.
294
+ //
295
+ // GATEWAY_INLINE_WRITE_ROOTS Dossiers où un fichier peut être déposé. C'est
296
+ // l'interrupteur : non renseigné, aucune livraison
297
+ // n'a lieu et l'appel échoue en le disant.
298
+ // GATEWAY_INLINE_WRITE_EXTENSIONS Extensions autorisées (défaut : toutes). Les racines
299
+ // restent le vrai garde-fou.
300
+ // GATEWAY_INLINE_MAX_BYTES Plafond de taille, partagé avec la lecture.
301
+
302
+ const WRITE_ROOTS = (process.env.GATEWAY_INLINE_WRITE_ROOTS ?? '')
303
+ .split(path.delimiter)
304
+ .map((entry) => entry.trim())
305
+ .filter(Boolean)
306
+ .map((entry) => {
307
+ const resolved = path.resolve(entry);
308
+ try {
309
+ return fs.realpathSync(resolved);
310
+ } catch {
311
+ console.error(`[mcp-mode] GATEWAY_INLINE_WRITE_ROOTS : dossier introuvable, ignoré — ${resolved}`);
312
+ return null;
313
+ }
314
+ })
315
+ .filter(Boolean);
316
+
317
+ const WRITE_EXTENSIONS = (process.env.GATEWAY_INLINE_WRITE_EXTENSIONS ?? '*')
318
+ .split(',')
319
+ .map((entry) => entry.trim().toLowerCase())
320
+ .filter(Boolean);
321
+
322
+ const WRITE_ANY_EXTENSION = WRITE_EXTENSIONS.includes('*');
323
+
324
+ if (WRITE_ROOTS.length > 0) {
325
+ console.error(
326
+ `[mcp-mode] Écriture des fichiers reçus activée vers : ${WRITE_ROOTS.join(', ')} ` +
327
+ `(extensions : ${WRITE_ANY_EXTENSION ? 'toutes' : WRITE_EXTENSIONS.join(' ')})`
328
+ );
329
+ }
330
+
331
+ /** Les paires `<x>_path` / `<x>_relay` déclarées par le schéma d'un outil. */
332
+ function deliverablePairs(inputSchema) {
333
+ const props = inputSchema?.properties;
334
+ if (!props) return [];
335
+ return Object.keys(props)
336
+ .filter((key) => key.endsWith('_relay') && `${key.slice(0, -'_relay'.length)}_path` in props)
337
+ .map((relayProp) => {
338
+ const stem = relayProp.slice(0, -'_relay'.length);
339
+ return {
340
+ pathProp: `${stem}_path`,
341
+ relayProp,
342
+ overwriteProp: `${stem}_overwrite` in props ? `${stem}_overwrite` : null,
343
+ };
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Le chemin de destination, résolu et vérifié contre les racines autorisées.
349
+ *
350
+ * `destDir` est toujours un dossier et `fileName` le nom que le MCP donne au fichier :
351
+ * le relais ne renomme jamais. Passer `fileName` vide ne vérifie que le dossier — ce
352
+ * qu'on fait à l'aller, pour échouer avant d'avoir fait voyager les octets pour rien.
353
+ *
354
+ * Le fichier n'existe pas encore : impossible d'appeler `realpath` dessus. On résout
355
+ * donc les liens du plus proche ancêtre existant, sans quoi un lien symbolique déposé
356
+ * dans une racine autorisée permettrait d'écrire n'importe où sur la machine.
357
+ */
358
+ function resolveWriteTarget(destDir, fileName = '') {
359
+ const resolved = fileName
360
+ ? path.resolve(destDir, path.basename(fileName))
361
+ : path.resolve(destDir);
362
+
363
+ let existing = path.dirname(resolved);
364
+ const trailing = [path.basename(resolved)];
365
+ while (!fs.existsSync(existing)) {
366
+ const parent = path.dirname(existing);
367
+ if (parent === existing) {
368
+ throw new Error(`Destination inatteignable sur la machine de l'agent : ${resolved}.`);
369
+ }
370
+ trailing.unshift(path.basename(existing));
371
+ existing = parent;
372
+ }
373
+
374
+ const real = path.join(fs.realpathSync(existing), ...trailing);
375
+
376
+ const inside = WRITE_ROOTS.some((root) => {
377
+ const rel = path.relative(root, real);
378
+ // Un dossier de destination PEUT être la racine elle-même ; un fichier, non.
379
+ if (rel === '') return fileName === '';
380
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
381
+ });
382
+ if (!inside) {
383
+ throw new Error(
384
+ `Écriture refusée : ${real} est hors des dossiers autorisés (${WRITE_ROOTS.join(', ')}). ` +
385
+ `Choisissez une destination dans l'un d'eux, ou ajoutez son dossier à ` +
386
+ `GATEWAY_INLINE_WRITE_ROOTS dans la configuration de ce serveur MCP.`
387
+ );
388
+ }
389
+
390
+ const ext = path.extname(real).toLowerCase();
391
+ if (fileName && !WRITE_ANY_EXTENSION && !WRITE_EXTENSIONS.includes(ext)) {
392
+ throw new Error(
393
+ `Écriture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
394
+ `(autorisées : ${WRITE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_WRITE_EXTENSIONS).`
395
+ );
396
+ }
397
+
398
+ return real;
399
+ }
400
+
401
+ /**
402
+ * Retire `<x>_path` des arguments et pose `<x>_relay`, pour que le MCP renvoie le
403
+ * contenu au lieu de tenter une écriture qui atterrirait sur SA machine.
404
+ */
405
+ function prepareDeliveries(inputSchema, args = {}) {
406
+ const pairs = deliverablePairs(inputSchema);
407
+ if (pairs.length === 0) return { args, deliveries: [] };
408
+
409
+ const out = { ...args };
410
+ const deliveries = [];
411
+ for (const { pathProp, relayProp, overwriteProp } of pairs) {
412
+ const destPath = out[pathProp];
413
+ if (typeof destPath !== 'string' || destPath === '') continue;
414
+
415
+ if (WRITE_ROOTS.length === 0) {
416
+ throw new Error(
417
+ `Livraison de fichier impossible : aucun dossier d'écriture autorisé. Le MCP tourne ` +
418
+ `sur la machine du gateway et ne peut pas écrire sur la vôtre. Renseignez ` +
419
+ `GATEWAY_INLINE_WRITE_ROOTS dans le bloc « env » de la configuration de ce ` +
420
+ `serveur MCP avec le ou les dossiers où déposer les fichiers reçus.`
421
+ );
422
+ }
423
+
424
+ // Vérification du dossier seul : le nom du fichier n'est connu qu'au retour.
425
+ resolveWriteTarget(destPath);
426
+ deliveries.push({
427
+ destDir: destPath,
428
+ overwrite: overwriteProp ? out[overwriteProp] === true : false,
429
+ });
430
+ out[relayProp] = true;
431
+ delete out[pathProp];
432
+ }
433
+ return { args: out, deliveries };
434
+ }
435
+
436
+ /**
437
+ * Écrit les fichiers renvoyés par le MCP et purge le résultat de leur contenu.
438
+ *
439
+ * Le contenu est retiré dans tous les cas, y compris en cas d'échec d'écriture : c'est
440
+ * la seule garantie qu'un base64 ne finira jamais dans le contexte de l'agent.
441
+ */
442
+ function applyDeliveries(result, deliveries, label = '') {
443
+ if (deliveries.length === 0) return result;
444
+
445
+ const blocks = Array.isArray(result?.content) ? result.content : [];
446
+ const blobs = blocks.filter((b) => b?.type === 'resource' && typeof b.resource?.blob === 'string');
447
+ const kept = blocks.filter((b) => !blobs.includes(b));
448
+
449
+ if (result?.isError) return { ...result, content: kept.length > 0 ? kept : blocks };
450
+
451
+ const notes = [];
452
+ deliveries.forEach((delivery, i) => {
453
+ const blob = blobs[i];
454
+ if (!blob) {
455
+ notes.push(`Aucun contenu renvoyé par le MCP pour ${delivery.destDir} : rien n'a été écrit.`);
456
+ return;
457
+ }
458
+ let target = delivery.destDir;
459
+ try {
460
+ const fileName = blob.resource.name || path.basename(new URL(blob.resource.uri).pathname);
461
+ if (!fileName) throw new Error("le MCP n'a pas nommé le fichier renvoyé");
462
+ target = resolveWriteTarget(delivery.destDir, fileName);
463
+
464
+ const data = Buffer.from(blob.resource.blob, 'base64');
465
+ if (data.length > INLINE_MAX_BYTES) {
466
+ throw new Error(
467
+ `fichier trop volumineux : ${data.length} octets, la limite est de ` +
468
+ `${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES)`
469
+ );
470
+ }
471
+ if (!delivery.overwrite && fs.existsSync(target)) {
472
+ throw new Error('la destination existe déjà');
473
+ }
474
+ fs.mkdirSync(path.dirname(target), { recursive: true });
475
+ fs.writeFileSync(target, data);
476
+ notes.push(`Fichier écrit : ${target} (${data.length} octets).`);
477
+ console.error(
478
+ `[mcp-mode] Fichier reçu${label ? ` de ${label}` : ''} : ${target} (${data.length} octets)`
479
+ );
480
+ } catch (err) {
481
+ notes.push(`Échec de l'écriture dans ${target} : ${err.message}.`);
482
+ }
483
+ });
484
+
485
+ const failed = notes.some((note) => note.startsWith('Échec') || note.startsWith('Aucun'));
486
+ return {
487
+ ...result,
488
+ content: [...kept, { type: 'text', text: notes.join('\n') }],
489
+ ...(failed ? { isError: true } : {}),
490
+ };
491
+ }
492
+
493
+ // --- Exécution sur la machine de l'agent (voir gateway-exec.js) ---
494
+
495
+ const { routes: EXEC_ROUTES, invalid: EXEC_INVALID_ROUTES } = parseExecRoutes(process.env.GATEWAY_EXEC_ROUTES);
496
+ if (EXEC_INVALID_ROUTES.length > 0) {
497
+ console.error(
498
+ `[mcp-mode] GATEWAY_EXEC_ROUTES : route(s) invalide(s) ${EXEC_INVALID_ROUTES.map((r) => `"${r}"`).join(', ')} ` +
499
+ `(attendu : des routes du gateway séparées par des virgules, ex. /github)`
500
+ );
501
+ process.exit(1);
502
+ }
503
+
504
+ /** Dossier de lancement du relais, fixé par l'agent qui l'a démarré. */
505
+ const EXEC_ROOT = process.cwd();
506
+
507
+ if (EXEC_ROUTES.size > 0) {
508
+ console.error(
509
+ `[mcp-mode] Exécution de commandes activée pour les MCP ${[...EXEC_ROUTES].join(', ')}, depuis : ${EXEC_ROOT}`
510
+ );
511
+ }
512
+
513
+ /**
514
+ * Appelle un outil du gateway et traite ses demandes d'exécution jusqu'au résultat final.
515
+ * Rend le résultat de l'outil, ou un résultat d'erreur si le gateway refuse l'appel.
516
+ */
517
+ async function callRemoteTool(route, toolName, body, label) {
518
+ const execAllowed = EXEC_ROUTES.has(normalizeRoute(route));
519
+ const announcement = execAllowed
520
+ ? execAnnouncement({ cwd: EXEC_ROOT, gatewayToken: GATEWAY_TOKEN })
521
+ : null;
522
+ let payload = announcement ? { ...body, [EXEC_ARG]: announcement } : body;
523
+
524
+ for (let round = 0; ; round++) {
525
+ const res = await gatewayFetch(`${route}/tools/${toolName}`, {
526
+ method: 'POST',
527
+ headers: { 'Content-Type': 'application/json' },
528
+ body: JSON.stringify(payload),
529
+ });
530
+ if (!res.ok) {
531
+ const text = await res.text();
532
+ return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
533
+ }
534
+ const result = await res.json();
535
+ const request = execRequestOf(result);
536
+ if (!request) return result;
537
+
538
+ if (!announcement) {
539
+ throw new Error(
540
+ `${label} demande une exécution sur cette machine, mais la route ${normalizeRoute(route)} n'est pas ` +
541
+ `dans GATEWAY_EXEC_ROUTES : rien n'a été exécuté.`
542
+ );
543
+ }
544
+ if (round >= MAX_EXEC_ROUNDS) {
545
+ throw new Error(`${label} a dépassé ${MAX_EXEC_ROUNDS} demandes d'exécution pour un même appel.`);
546
+ }
547
+ const results = await runExecRequest(request, {
548
+ root: EXEC_ROOT,
549
+ gatewayUrl: GATEWAY_URL,
550
+ gatewayToken: GATEWAY_TOKEN,
551
+ label,
552
+ });
553
+ payload = { [EXEC_ARG]: { ...announcement, continuation: { id: request.id, results } } };
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Appelle un outil distant avec les conventions du relais : fichiers transmis dans les deux
559
+ * sens, et veille armée quand l'outil l'a déclarée et répond par une spécification.
560
+ */
561
+ async function relayToolCall(route, toolName, schema, args, label) {
562
+ const { args: outArgs, deliveries } = prepareDeliveries(schema, args);
563
+ const inlined = inlineFileArgs(schema, outArgs, label);
564
+ const watching = declaresWatch(schema);
565
+ const body = watching ? { ...inlined, [WATCH_ARG]: watchAnnouncement() } : inlined;
566
+ const result = await callRemoteTool(route, toolName, body, label);
567
+ const spec = watching && !result.isError ? watchSpecOf(result) : null;
568
+ if (spec) return armWatch(spec, { route, gatewayUrl: GATEWAY_URL, gatewayToken: GATEWAY_TOKEN });
569
+ return applyDeliveries(result, deliveries, label);
570
+ }
571
+
572
+ /** Schémas des outils d'une route, mis en cache — nécessaires pour `call_tool`. */
573
+ const routeSchemas = new Map();
574
+
575
+ async function toolSchema(route, toolName) {
576
+ // Toujours récupéré, même si l'inlining en lecture est désactivé : c'est le schéma qui
577
+ // révèle une paire `<x>_path` / `<x>_relay`, et la manquer ferait repartir un chemin
578
+ // de destination vers le MCP distant, qui écrirait alors sur SA machine sans le dire.
579
+ if (!routeSchemas.has(route)) {
580
+ try {
581
+ const res = await gatewayFetch(`${route}/tools`);
582
+ const list = res.ok ? await res.json() : [];
583
+ routeSchemas.set(route, new Map(list.map((t) => [t.name, t.inputSchema])));
584
+ } catch {
585
+ routeSchemas.set(route, new Map());
586
+ }
587
+ }
588
+ return routeSchemas.get(route).get(toolName) ?? null;
589
+ }
590
+
591
+ // --- MCP Server ---
592
+
593
+ // Le gateway (mode serveur HTTP) peut être paramétré (EXPOSE_AT_AGENT_STARTUP) pour
594
+ // pré-exposer les outils de certains MCP directement à l'agent. La config vit uniquement
595
+ // sur le serveur : ce bridge se contente de récupérer ces outils et de les publier, de
596
+ // façon transparente. En cas d'échec (ancien gateway sans la route, gateway protégé par
597
+ // OAuth, etc.), on continue sans pré-exposition.
598
+ async function fetchProxyTools() {
599
+ try {
600
+ const res = await gatewayFetch('/startup-exposition');
601
+ if (!res.ok) {
602
+ // La pré-exposition reste optionnelle : on n'interrompt pas le démarrage.
603
+ // Mais un 401/403 ici annonce que TOUS les appels seront refusés — le dire
604
+ // maintenant évite de chercher plus tard. Sans le token, évidemment.
605
+ if (isAuthStatus(res.status)) console.error(`[mcp-mode] ${authErrorText(res.status)}`);
606
+ return [];
607
+ }
608
+ const { proxyTools } = await res.json();
609
+ return proxyTools ?? [];
610
+ } catch {
611
+ return [];
612
+ }
613
+ }
614
+
615
+ const proxyTools = await fetchProxyTools();
616
+ if (proxyTools.length) {
617
+ console.error(`[mcp-mode] ${proxyTools.length} proxy tool(s) exposed directly from gateway`);
618
+ }
619
+
620
+ const server = new Server(
621
+ { name: 'mcp-http-gateway-mcp', version: '1.0.0' },
622
+ { capabilities: { tools: {} } }
623
+ );
624
+
625
+ const tools = [
626
+ {
627
+ name: 'list_mcps',
628
+ description: 'List all MCPs available on the gateway with their route and description.',
629
+ inputSchema: { type: 'object', properties: {} },
630
+ },
631
+ {
632
+ name: 'list_tools',
633
+ description: 'List all tools available for a given MCP route.',
634
+ inputSchema: {
635
+ type: 'object',
636
+ properties: {
637
+ route: { type: 'string', description: 'MCP route as returned by list_mcps (e.g. "/documentor")' },
638
+ },
639
+ required: ['route'],
640
+ },
641
+ },
642
+ {
643
+ name: 'call_tool',
644
+ description: 'Call a tool on a given MCP route.',
645
+ inputSchema: {
646
+ type: 'object',
647
+ properties: {
648
+ route: { type: 'string', description: 'MCP route (e.g. "/documentor")' },
649
+ tool_name: { type: 'string', description: 'Tool name to call' },
650
+ arguments: { type: 'object', description: 'Arguments to pass to the tool' },
651
+ },
652
+ required: ['route', 'tool_name'],
653
+ },
654
+ },
655
+ ];
656
+
657
+ const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
658
+ const allTools = [
659
+ ...tools,
660
+ ...proxyTools.map(({ name, description, inputSchema }) => ({
661
+ name,
662
+ description,
663
+ inputSchema: schemaForAgent(inputSchema),
664
+ })),
665
+ ];
666
+
667
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
668
+
669
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
670
+ const { name, arguments: args = {} } = request.params;
671
+
672
+ try {
673
+ const proxy = proxyIndex.get(name);
674
+ if (proxy) {
675
+ const label = `${proxy.route}/${proxy.toolName}`;
676
+ return await relayToolCall(proxy.route, proxy.toolName, proxy.inputSchema, args ?? {}, label);
677
+ }
678
+
679
+ if (name === 'list_mcps') {
680
+ const res = await gatewayFetch('/mcp-definitions');
681
+ if (!res.ok) throw new Error(gatewayThrowText(res.status));
682
+ const definitions = await res.json();
683
+ const text = definitions
684
+ .map(m => `- ${m.id} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
685
+ .join('\n');
686
+ return { content: [{ type: 'text', text }] };
687
+ }
688
+
689
+ if (name === 'list_tools') {
690
+ const res = await gatewayFetch(`${args.route}/tools`);
691
+ if (!res.ok) throw new Error(gatewayThrowText(res.status));
692
+ const toolsList = await res.json();
693
+ routeSchemas.set(args.route, new Map(toolsList.map((t) => [t.name, t.inputSchema])));
694
+ // Même règle que pour les outils pré-exposés : l'agent ne doit pas découvrir
695
+ // les paramètres de contenu, c'est le relais qui les remplit.
696
+ const text = JSON.stringify(
697
+ toolsList.map((t) => (t.inputSchema ? { ...t, inputSchema: schemaForAgent(t.inputSchema) } : t))
698
+ );
699
+ return { content: [{ type: 'text', text }] };
700
+ }
701
+
702
+ if (name === 'call_tool') {
703
+ const schema = await toolSchema(args.route, args.tool_name);
704
+ const label = `${args.route}/${args.tool_name}`;
705
+ return await relayToolCall(args.route, args.tool_name, schema, args.arguments ?? {}, label);
706
+ }
707
+
708
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
709
+ } catch (err) {
710
+ return { content: [{ type: 'text', text: `Request failed: ${err.message}` }], isError: true };
711
+ }
712
+ });
713
+
714
+ // L'agent arrête le relais en fermant son entrée standard : la sortie retire les fichiers
715
+ // de veille, qui contiennent le jeton. Ceux d'un relais tué sont retirés au démarrage suivant.
716
+ pruneOrphanWatches();
717
+ process.on('exit', removeOwnWatches);
718
+ process.stdin.on('end', () => process.exit(0));
719
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => process.exit(0));
720
+
721
+ const transport = new StdioServerTransport();
722
+ await server.connect(transport);