@imenam/mcp-http-gateway 2.0.11 → 2.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +508 -504
  2. package/mcp-mode.js +655 -645
  3. package/package.json +1 -1
package/mcp-mode.js CHANGED
@@ -1,645 +1,655 @@
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 se comporte
93
- // exactement comme avant (le chemin part tel quel).
94
- // Séparateur : « ; » sous Windows, « : » ailleurs.
95
- // GATEWAY_INLINE_EXTENSIONS Extensions autorisées (défaut : tableurs). « * » pour toutes.
96
- // GATEWAY_INLINE_MAX_BYTES Taille maximale d'un fichier transmis (défaut : 8 Mio).
97
- //
98
- // Chaque fichier réellement transmis laisse une ligne sur stderr : ce que l'agent fait
99
- // sortir de la machine doit être lisible dans le journal de l'agent.
100
-
101
- /** Les racines autorisées, résolues une fois pour toutes au démarrage. */
102
- const INLINE_ROOTS = (process.env.GATEWAY_INLINE_ROOTS ?? '')
103
- .split(path.delimiter)
104
- .map((entry) => entry.trim())
105
- .filter(Boolean)
106
- .map((entry) => {
107
- const resolved = path.resolve(entry);
108
- try {
109
- return fs.realpathSync(resolved);
110
- } catch {
111
- console.error(`[mcp-mode] GATEWAY_INLINE_ROOTS : dossier introuvable, ignoré — ${resolved}`);
112
- return null;
113
- }
114
- })
115
- .filter(Boolean);
116
-
117
- const INLINE_EXTENSIONS = (process.env.GATEWAY_INLINE_EXTENSIONS ?? '.xlsx,.xls,.ods,.csv')
118
- .split(',')
119
- .map((entry) => entry.trim().toLowerCase())
120
- .filter(Boolean);
121
-
122
- const INLINE_ANY_EXTENSION = INLINE_EXTENSIONS.includes('*');
123
-
124
- // 8 Mio : encodé en base64 le corps pèse ~10,7 Mio, sous le plafond de 16 Mio que le hub
125
- // applique quand il journalise les corps de requête.
126
- const INLINE_MAX_BYTES =
127
- Number.parseInt(process.env.GATEWAY_INLINE_MAX_BYTES ?? '', 10) || 8 * 1024 * 1024;
128
-
129
- const INLINE_FILES = INLINE_ROOTS.length > 0;
130
-
131
- if (INLINE_FILES) {
132
- console.error(
133
- `[mcp-mode] Transmission du contenu des fichiers activée depuis : ${INLINE_ROOTS.join(', ')} ` +
134
- `(extensions : ${INLINE_ANY_EXTENSION ? 'toutes' : INLINE_EXTENSIONS.join(' ')})`
135
- );
136
- }
137
-
138
- /** Le fichier est-il à l'intérieur d'une racine autorisée ? Liens symboliques résolus. */
139
- function insideAllowedRoot(realPath) {
140
- return INLINE_ROOTS.some((root) => {
141
- const rel = path.relative(root, realPath);
142
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
143
- });
144
- }
145
-
146
- /** Les paires `<x>_path` / `<x>_content` déclarées par le schéma d'un outil. */
147
- function inlinablePairs(inputSchema) {
148
- const props = inputSchema?.properties;
149
- if (!props) return [];
150
- return Object.keys(props)
151
- .filter((key) => key.endsWith('_content') && `${key.slice(0, -'_content'.length)}_path` in props)
152
- .map((contentProp) => {
153
- const stem = contentProp.slice(0, -'_content'.length);
154
- return {
155
- pathProp: `${stem}_path`,
156
- contentProp,
157
- nameProp: `${stem}_name` in props ? `${stem}_name` : null,
158
- };
159
- });
160
- }
161
-
162
- /** Le schéma tel que l'agent doit le voir : sans les paramètres que le relais remplit. */
163
- function schemaForAgent(inputSchema) {
164
- const pairs = [...inlinablePairs(inputSchema), ...deliverablePairs(inputSchema)];
165
- if (pairs.length === 0) return inputSchema;
166
-
167
- const hidden = new Set(
168
- pairs.flatMap((p) => [p.contentProp, p.nameProp, p.relayProp].filter(Boolean))
169
- );
170
- const properties = Object.fromEntries(
171
- Object.entries(inputSchema.properties).filter(([key]) => !hidden.has(key))
172
- );
173
-
174
- // Le chemin devient la seule façon de désigner le fichier : il n'est plus optionnel.
175
- const required = [
176
- ...new Set([
177
- ...(inputSchema.required ?? []).filter((key) => !hidden.has(key)),
178
- ...pairs.map((p) => p.pathProp),
179
- ]),
180
- ];
181
-
182
- return { ...inputSchema, properties, required };
183
- }
184
-
185
- /**
186
- * Remplace chaque `<x>_path` par le contenu du fichier lu sur la machine de l'agent.
187
- *
188
- * Tout refus est explicite : l'agent doit savoir pourquoi son fichier ne part pas,
189
- * sans avoir à deviner s'il s'agit d'une absence, d'un type ou d'une interdiction.
190
- */
191
- function inlineFileArgs(inputSchema, args = {}, label = '') {
192
- const pairs = inlinablePairs(inputSchema);
193
- if (pairs.length === 0) return args;
194
-
195
- const out = { ...args };
196
- for (const { pathProp, contentProp, nameProp } of pairs) {
197
- const filePath = out[pathProp];
198
- if (typeof filePath !== 'string' || filePath === '') continue;
199
-
200
- // Un outil qui déclare la paire attend le CONTENU : il tourne sur la machine du
201
- // gateway, où le chemin de l'agent ne désigne rien — ou pire, désigne un homonyme
202
- // qu'il traiterait sans broncher. Laisser filer le chemin serait une dégradation
203
- // silencieuse ; on refuse en nommant la variable à renseigner.
204
- if (!INLINE_FILES) {
205
- throw new Error(
206
- `Transmission de fichier impossible : aucun dossier de lecture autorisé. L'outil ` +
207
- `attend le contenu du fichier (paramètre ${pathProp}), mais il tourne sur la machine ` +
208
- `du gateway et ne peut pas lire la vôtre. Renseignez GATEWAY_INLINE_ROOTS dans le ` +
209
- `bloc « env » de la configuration de ce serveur MCP.`
210
- );
211
- }
212
-
213
- const resolved = path.resolve(filePath);
214
-
215
- // realpath avant tout contrôle : sans ça, un lien symbolique placé dans une racine
216
- // autorisée ferait sortir n'importe quel fichier de la machine.
217
- let real;
218
- let stat;
219
- try {
220
- real = fs.realpathSync(resolved);
221
- stat = fs.statSync(real);
222
- } catch {
223
- throw new Error(
224
- `Fichier introuvable sur la machine de l'agent : ${resolved} (paramètre ${pathProp}).`
225
- );
226
- }
227
- if (!stat.isFile()) {
228
- throw new Error(`${resolved} n'est pas un fichier (paramètre ${pathProp}).`);
229
- }
230
- if (!insideAllowedRoot(real)) {
231
- throw new Error(
232
- `Lecture refusée : ${real} est hors des dossiers autorisés (${INLINE_ROOTS.join(', ')}). ` +
233
- `Déplacez le fichier dans l'un d'eux, ou ajoutez son dossier à GATEWAY_INLINE_ROOTS ` +
234
- `dans la configuration de ce serveur MCP.`
235
- );
236
- }
237
- const ext = path.extname(real).toLowerCase();
238
- if (!INLINE_ANY_EXTENSION && !INLINE_EXTENSIONS.includes(ext)) {
239
- throw new Error(
240
- `Lecture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
241
- `(autorisées : ${INLINE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_EXTENSIONS).`
242
- );
243
- }
244
- if (stat.size > INLINE_MAX_BYTES) {
245
- throw new Error(
246
- `Fichier trop volumineux à transmettre : ${real} pèse ${stat.size} octets, ` +
247
- `la limite est de ${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES).`
248
- );
249
- }
250
-
251
- out[contentProp] = fs.readFileSync(real).toString('base64');
252
- if (nameProp) out[nameProp] = path.basename(real);
253
- delete out[pathProp];
254
-
255
- console.error(
256
- `[mcp-mode] Fichier transmis${label ? ` à ${label}` : ''} : ${real} (${stat.size} octets)`
257
- );
258
- }
259
- return out;
260
- }
261
-
262
- // --- Fichiers : le MCP produit, le relais écrit ---
263
- //
264
- // Le sens retour, symétrique du précédent. `inlineFileArgs` ne réécrit que les arguments
265
- // à l'aller ; un fichier produit par le MCP, lui, n'a aucun moyen d'atteindre la machine
266
- // de l'agent : le protocole n'offre au serveur aucune primitive pour y écrire.
267
- //
268
- // La règle : dès qu'un outil distant déclare une paire `<x>_path` / `<x>_relay`, le relais
269
- // retient le chemin, annonce sa présence au MCP en posant `<x>_relay` à true, et ne
270
- // transmet PAS `<x>_path` — qui ne désignerait rien à l'arrivée. Le MCP répond alors avec
271
- // le contenu du fichier, que le relais écrit ICI, à l'emplacement retenu.
272
- //
273
- // Le contenu ne ressort JAMAIS vers l'agent : le relais le retire du résultat, qu'il ait
274
- // réussi ou non à écrire. Un fichier de 8 Mio encodé en base64 n'a rien à faire dans le
275
- // contexte d'un agent, et rien ne justifie de l'y déverser « au cas où » : l'appel échoue,
276
- // le fichier est perdu, l'agent recommence.
277
- //
278
- // Sans relais dans la chaîne — MCP lancé en stdio à côté de l'agent — `<x>_relay` reste
279
- // absent : les deux machines n'en font qu'une, et le MCP écrit lui-même à `<x>_path`.
280
- //
281
- // Écrire est plus dangereux que lire : la fonction a donc sa propre racine autorisée,
282
- // et n'hérite pas de celle de la lecture.
283
- //
284
- // GATEWAY_INLINE_WRITE_ROOTS Dossiers où un fichier peut être déposé. C'est
285
- // l'interrupteur : non renseigné, aucune livraison
286
- // n'a lieu et l'appel échoue en le disant.
287
- // GATEWAY_INLINE_WRITE_EXTENSIONS Extensions autorisées (défaut : toutes). Les racines
288
- // restent le vrai garde-fou.
289
- // GATEWAY_INLINE_MAX_BYTES Plafond de taille, partagé avec la lecture.
290
-
291
- const WRITE_ROOTS = (process.env.GATEWAY_INLINE_WRITE_ROOTS ?? '')
292
- .split(path.delimiter)
293
- .map((entry) => entry.trim())
294
- .filter(Boolean)
295
- .map((entry) => {
296
- const resolved = path.resolve(entry);
297
- try {
298
- return fs.realpathSync(resolved);
299
- } catch {
300
- console.error(`[mcp-mode] GATEWAY_INLINE_WRITE_ROOTS : dossier introuvable, ignoré — ${resolved}`);
301
- return null;
302
- }
303
- })
304
- .filter(Boolean);
305
-
306
- const WRITE_EXTENSIONS = (process.env.GATEWAY_INLINE_WRITE_EXTENSIONS ?? '*')
307
- .split(',')
308
- .map((entry) => entry.trim().toLowerCase())
309
- .filter(Boolean);
310
-
311
- const WRITE_ANY_EXTENSION = WRITE_EXTENSIONS.includes('*');
312
-
313
- if (WRITE_ROOTS.length > 0) {
314
- console.error(
315
- `[mcp-mode] Écriture des fichiers reçus activée vers : ${WRITE_ROOTS.join(', ')} ` +
316
- `(extensions : ${WRITE_ANY_EXTENSION ? 'toutes' : WRITE_EXTENSIONS.join(' ')})`
317
- );
318
- }
319
-
320
- /** Les paires `<x>_path` / `<x>_relay` déclarées par le schéma d'un outil. */
321
- function deliverablePairs(inputSchema) {
322
- const props = inputSchema?.properties;
323
- if (!props) return [];
324
- return Object.keys(props)
325
- .filter((key) => key.endsWith('_relay') && `${key.slice(0, -'_relay'.length)}_path` in props)
326
- .map((relayProp) => {
327
- const stem = relayProp.slice(0, -'_relay'.length);
328
- return {
329
- pathProp: `${stem}_path`,
330
- relayProp,
331
- overwriteProp: `${stem}_overwrite` in props ? `${stem}_overwrite` : null,
332
- };
333
- });
334
- }
335
-
336
- /**
337
- * Le chemin de destination, résolu et vérifié contre les racines autorisées.
338
- *
339
- * `destDir` est toujours un dossier et `fileName` le nom que le MCP donne au fichier :
340
- * le relais ne renomme jamais. Passer `fileName` vide ne vérifie que le dossier — ce
341
- * qu'on fait à l'aller, pour échouer avant d'avoir fait voyager les octets pour rien.
342
- *
343
- * Le fichier n'existe pas encore : impossible d'appeler `realpath` dessus. On résout
344
- * donc les liens du plus proche ancêtre existant, sans quoi un lien symbolique déposé
345
- * dans une racine autorisée permettrait d'écrire n'importe où sur la machine.
346
- */
347
- function resolveWriteTarget(destDir, fileName = '') {
348
- const resolved = fileName
349
- ? path.resolve(destDir, path.basename(fileName))
350
- : path.resolve(destDir);
351
-
352
- let existing = path.dirname(resolved);
353
- const trailing = [path.basename(resolved)];
354
- while (!fs.existsSync(existing)) {
355
- const parent = path.dirname(existing);
356
- if (parent === existing) {
357
- throw new Error(`Destination inatteignable sur la machine de l'agent : ${resolved}.`);
358
- }
359
- trailing.unshift(path.basename(existing));
360
- existing = parent;
361
- }
362
-
363
- const real = path.join(fs.realpathSync(existing), ...trailing);
364
-
365
- const inside = WRITE_ROOTS.some((root) => {
366
- const rel = path.relative(root, real);
367
- // Un dossier de destination PEUT être la racine elle-même ; un fichier, non.
368
- if (rel === '') return fileName === '';
369
- return !rel.startsWith('..') && !path.isAbsolute(rel);
370
- });
371
- if (!inside) {
372
- throw new Error(
373
- `Écriture refusée : ${real} est hors des dossiers autorisés (${WRITE_ROOTS.join(', ')}). ` +
374
- `Choisissez une destination dans l'un d'eux, ou ajoutez son dossier à ` +
375
- `GATEWAY_INLINE_WRITE_ROOTS dans la configuration de ce serveur MCP.`
376
- );
377
- }
378
-
379
- const ext = path.extname(real).toLowerCase();
380
- if (fileName && !WRITE_ANY_EXTENSION && !WRITE_EXTENSIONS.includes(ext)) {
381
- throw new Error(
382
- `Écriture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
383
- `(autorisées : ${WRITE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_WRITE_EXTENSIONS).`
384
- );
385
- }
386
-
387
- return real;
388
- }
389
-
390
- /**
391
- * Retire `<x>_path` des arguments et pose `<x>_relay`, pour que le MCP renvoie le
392
- * contenu au lieu de tenter une écriture qui atterrirait sur SA machine.
393
- */
394
- function prepareDeliveries(inputSchema, args = {}) {
395
- const pairs = deliverablePairs(inputSchema);
396
- if (pairs.length === 0) return { args, deliveries: [] };
397
-
398
- const out = { ...args };
399
- const deliveries = [];
400
- for (const { pathProp, relayProp, overwriteProp } of pairs) {
401
- const destPath = out[pathProp];
402
- if (typeof destPath !== 'string' || destPath === '') continue;
403
-
404
- if (WRITE_ROOTS.length === 0) {
405
- throw new Error(
406
- `Livraison de fichier impossible : aucun dossier d'écriture autorisé. Le MCP tourne ` +
407
- `sur la machine du gateway et ne peut pas écrire sur la vôtre. Renseignez ` +
408
- `GATEWAY_INLINE_WRITE_ROOTS dans le bloc « env » de la configuration de ce ` +
409
- `serveur MCP avec le ou les dossiers où déposer les fichiers reçus.`
410
- );
411
- }
412
-
413
- // Vérification du dossier seul : le nom du fichier n'est connu qu'au retour.
414
- resolveWriteTarget(destPath);
415
- deliveries.push({
416
- destDir: destPath,
417
- overwrite: overwriteProp ? out[overwriteProp] === true : false,
418
- });
419
- out[relayProp] = true;
420
- delete out[pathProp];
421
- }
422
- return { args: out, deliveries };
423
- }
424
-
425
- /**
426
- * Écrit les fichiers renvoyés par le MCP et purge le résultat de leur contenu.
427
- *
428
- * Le contenu est retiré dans tous les cas, y compris en cas d'échec d'écriture : c'est
429
- * la seule garantie qu'un base64 ne finira jamais dans le contexte de l'agent.
430
- */
431
- function applyDeliveries(result, deliveries, label = '') {
432
- if (deliveries.length === 0) return result;
433
-
434
- const blocks = Array.isArray(result?.content) ? result.content : [];
435
- const blobs = blocks.filter((b) => b?.type === 'resource' && typeof b.resource?.blob === 'string');
436
- const kept = blocks.filter((b) => !blobs.includes(b));
437
-
438
- if (result?.isError) return { ...result, content: kept.length > 0 ? kept : blocks };
439
-
440
- const notes = [];
441
- deliveries.forEach((delivery, i) => {
442
- const blob = blobs[i];
443
- if (!blob) {
444
- notes.push(`Aucun contenu renvoyé par le MCP pour ${delivery.destDir} : rien n'a été écrit.`);
445
- return;
446
- }
447
- let target = delivery.destDir;
448
- try {
449
- const fileName = blob.resource.name || path.basename(new URL(blob.resource.uri).pathname);
450
- if (!fileName) throw new Error("le MCP n'a pas nommé le fichier renvoyé");
451
- target = resolveWriteTarget(delivery.destDir, fileName);
452
-
453
- const data = Buffer.from(blob.resource.blob, 'base64');
454
- if (data.length > INLINE_MAX_BYTES) {
455
- throw new Error(
456
- `fichier trop volumineux : ${data.length} octets, la limite est de ` +
457
- `${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES)`
458
- );
459
- }
460
- if (!delivery.overwrite && fs.existsSync(target)) {
461
- throw new Error('la destination existe déjà');
462
- }
463
- fs.mkdirSync(path.dirname(target), { recursive: true });
464
- fs.writeFileSync(target, data);
465
- notes.push(`Fichier écrit : ${target} (${data.length} octets).`);
466
- console.error(
467
- `[mcp-mode] Fichier reçu${label ? ` de ${label}` : ''} : ${target} (${data.length} octets)`
468
- );
469
- } catch (err) {
470
- notes.push(`Échec de l'écriture dans ${target} : ${err.message}.`);
471
- }
472
- });
473
-
474
- const failed = notes.some((note) => note.startsWith('Échec') || note.startsWith('Aucun'));
475
- return {
476
- ...result,
477
- content: [...kept, { type: 'text', text: notes.join('\n') }],
478
- ...(failed ? { isError: true } : {}),
479
- };
480
- }
481
-
482
- /** Schémas des outils d'une route, mis en cache — nécessaires pour `call_tool`. */
483
- const routeSchemas = new Map();
484
-
485
- async function toolSchema(route, toolName) {
486
- // Toujours récupéré, même si l'inlining en lecture est désactivé : c'est le schéma qui
487
- // révèle une paire `<x>_path` / `<x>_relay`, et la manquer ferait repartir un chemin
488
- // de destination vers le MCP distant, qui écrirait alors sur SA machine sans le dire.
489
- if (!routeSchemas.has(route)) {
490
- try {
491
- const res = await gatewayFetch(`${route}/tools`);
492
- const list = res.ok ? await res.json() : [];
493
- routeSchemas.set(route, new Map(list.map((t) => [t.name, t.inputSchema])));
494
- } catch {
495
- routeSchemas.set(route, new Map());
496
- }
497
- }
498
- return routeSchemas.get(route).get(toolName) ?? null;
499
- }
500
-
501
- // --- MCP Server ---
502
-
503
- // Le gateway (mode serveur HTTP) peut être paramétré (EXPOSE_AT_AGENT_STARTUP) pour
504
- // pré-exposer les outils de certains MCP directement à l'agent. La config vit uniquement
505
- // sur le serveur : ce bridge se contente de récupérer ces outils et de les publier, de
506
- // façon transparente. En cas d'échec (ancien gateway sans la route, gateway protégé par
507
- // OAuth, etc.), on continue sans pré-exposition.
508
- async function fetchProxyTools() {
509
- try {
510
- const res = await gatewayFetch('/startup-exposition');
511
- if (!res.ok) {
512
- // La pré-exposition reste optionnelle : on n'interrompt pas le démarrage.
513
- // Mais un 401/403 ici annonce que TOUS les appels seront refusés — le dire
514
- // maintenant évite de chercher plus tard. Sans le token, évidemment.
515
- if (isAuthStatus(res.status)) console.error(`[mcp-mode] ${authErrorText(res.status)}`);
516
- return [];
517
- }
518
- const { proxyTools } = await res.json();
519
- return proxyTools ?? [];
520
- } catch {
521
- return [];
522
- }
523
- }
524
-
525
- const proxyTools = await fetchProxyTools();
526
- if (proxyTools.length) {
527
- console.error(`[mcp-mode] ${proxyTools.length} proxy tool(s) exposed directly from gateway`);
528
- }
529
-
530
- const server = new Server(
531
- { name: 'mcp-http-gateway-mcp', version: '1.0.0' },
532
- { capabilities: { tools: {} } }
533
- );
534
-
535
- const tools = [
536
- {
537
- name: 'list_mcps',
538
- description: 'List all MCPs available on the gateway with their route and description.',
539
- inputSchema: { type: 'object', properties: {} },
540
- },
541
- {
542
- name: 'list_tools',
543
- description: 'List all tools available for a given MCP route.',
544
- inputSchema: {
545
- type: 'object',
546
- properties: {
547
- route: { type: 'string', description: 'MCP route as returned by list_mcps (e.g. "/documentor")' },
548
- },
549
- required: ['route'],
550
- },
551
- },
552
- {
553
- name: 'call_tool',
554
- description: 'Call a tool on a given MCP route.',
555
- inputSchema: {
556
- type: 'object',
557
- properties: {
558
- route: { type: 'string', description: 'MCP route (e.g. "/documentor")' },
559
- tool_name: { type: 'string', description: 'Tool name to call' },
560
- arguments: { type: 'object', description: 'Arguments to pass to the tool' },
561
- },
562
- required: ['route', 'tool_name'],
563
- },
564
- },
565
- ];
566
-
567
- const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
568
- const allTools = [
569
- ...tools,
570
- ...proxyTools.map(({ name, description, inputSchema }) => ({
571
- name,
572
- description,
573
- inputSchema: schemaForAgent(inputSchema),
574
- })),
575
- ];
576
-
577
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
578
-
579
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
580
- const { name, arguments: args = {} } = request.params;
581
-
582
- try {
583
- const proxy = proxyIndex.get(name);
584
- if (proxy) {
585
- const label = `${proxy.route}/${proxy.toolName}`;
586
- const { args: outArgs, deliveries } = prepareDeliveries(proxy.inputSchema, args ?? {});
587
- const res = await gatewayFetch(`${proxy.route}/tools/${proxy.toolName}`, {
588
- method: 'POST',
589
- headers: { 'Content-Type': 'application/json' },
590
- body: JSON.stringify(inlineFileArgs(proxy.inputSchema, outArgs, label)),
591
- });
592
- if (!res.ok) {
593
- const text = await res.text();
594
- return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
595
- }
596
- return applyDeliveries(await res.json(), deliveries, label);
597
- }
598
-
599
- if (name === 'list_mcps') {
600
- const res = await gatewayFetch('/mcp-definitions');
601
- if (!res.ok) throw new Error(gatewayThrowText(res.status));
602
- const definitions = await res.json();
603
- const text = definitions
604
- .map(m => `- ${m.id} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
605
- .join('\n');
606
- return { content: [{ type: 'text', text }] };
607
- }
608
-
609
- if (name === 'list_tools') {
610
- const res = await gatewayFetch(`${args.route}/tools`);
611
- if (!res.ok) throw new Error(gatewayThrowText(res.status));
612
- const toolsList = await res.json();
613
- routeSchemas.set(args.route, new Map(toolsList.map((t) => [t.name, t.inputSchema])));
614
- // Même règle que pour les outils pré-exposés : l'agent ne doit pas découvrir
615
- // les paramètres de contenu, c'est le relais qui les remplit.
616
- const text = JSON.stringify(
617
- toolsList.map((t) => (t.inputSchema ? { ...t, inputSchema: schemaForAgent(t.inputSchema) } : t))
618
- );
619
- return { content: [{ type: 'text', text }] };
620
- }
621
-
622
- if (name === 'call_tool') {
623
- const schema = await toolSchema(args.route, args.tool_name);
624
- const label = `${args.route}/${args.tool_name}`;
625
- const { args: outArgs, deliveries } = prepareDeliveries(schema, args.arguments ?? {});
626
- const res = await gatewayFetch(`${args.route}/tools/${args.tool_name}`, {
627
- method: 'POST',
628
- headers: { 'Content-Type': 'application/json' },
629
- body: JSON.stringify(inlineFileArgs(schema, outArgs, label)),
630
- });
631
- if (!res.ok) {
632
- const text = await res.text();
633
- return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
634
- }
635
- return applyDeliveries(await res.json(), deliveries, label);
636
- }
637
-
638
- return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
639
- } catch (err) {
640
- return { content: [{ type: 'text', text: `Request failed: ${err.message}` }], isError: true };
641
- }
642
- });
643
-
644
- const transport = new StdioServerTransport();
645
- 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
+
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
+ // À l'aller, le chemin devient la seule façon de désigner le fichier — `<x>_content`
179
+ // est caché — donc il n'est plus optionnel.
180
+ //
181
+ // Au retour, non : une destination que le MCP a rendue optionnelle veut dire quelque
182
+ // chose (« renvoie-moi le contenu au lieu de l'écrire »), et sans elle `prepareDeliveries`
183
+ // ne fait déjà rien. La rendre obligatoire ici forcerait une destination sur chaque
184
+ // appel, y compris ceux qui n'attendent aucun fichier.
185
+ const required = [
186
+ ...new Set([
187
+ ...(inputSchema.required ?? []).filter((key) => !hidden.has(key)),
188
+ ...inbound.map((p) => p.pathProp),
189
+ ]),
190
+ ];
191
+
192
+ return { ...inputSchema, properties, required };
193
+ }
194
+
195
+ /**
196
+ * Remplace chaque `<x>_path` par le contenu du fichier lu sur la machine de l'agent.
197
+ *
198
+ * Tout refus est explicite : l'agent doit savoir pourquoi son fichier ne part pas,
199
+ * sans avoir à deviner s'il s'agit d'une absence, d'un type ou d'une interdiction.
200
+ */
201
+ function inlineFileArgs(inputSchema, args = {}, label = '') {
202
+ const pairs = inlinablePairs(inputSchema);
203
+ if (pairs.length === 0) return args;
204
+
205
+ const out = { ...args };
206
+ for (const { pathProp, contentProp, nameProp } of pairs) {
207
+ const filePath = out[pathProp];
208
+ if (typeof filePath !== 'string' || filePath === '') continue;
209
+
210
+ // Un outil qui déclare la paire attend le CONTENU : il tourne sur la machine du
211
+ // gateway, où le chemin de l'agent ne désigne rien — ou pire, désigne un homonyme
212
+ // qu'il traiterait sans broncher. Laisser filer le chemin serait une dégradation
213
+ // silencieuse ; on refuse en nommant la variable à renseigner.
214
+ if (!INLINE_FILES) {
215
+ throw new Error(
216
+ `Transmission de fichier impossible : aucun dossier de lecture autorisé. L'outil ` +
217
+ `attend le contenu du fichier (paramètre ${pathProp}), mais il tourne sur la machine ` +
218
+ `du gateway et ne peut pas lire la vôtre. Renseignez GATEWAY_INLINE_ROOTS dans le ` +
219
+ `bloc « env » de la configuration de ce serveur MCP.`
220
+ );
221
+ }
222
+
223
+ const resolved = path.resolve(filePath);
224
+
225
+ // realpath avant tout contrôle : sans ça, un lien symbolique placé dans une racine
226
+ // autorisée ferait sortir n'importe quel fichier de la machine.
227
+ let real;
228
+ let stat;
229
+ try {
230
+ real = fs.realpathSync(resolved);
231
+ stat = fs.statSync(real);
232
+ } catch {
233
+ throw new Error(
234
+ `Fichier introuvable sur la machine de l'agent : ${resolved} (paramètre ${pathProp}).`
235
+ );
236
+ }
237
+ if (!stat.isFile()) {
238
+ throw new Error(`${resolved} n'est pas un fichier (paramètre ${pathProp}).`);
239
+ }
240
+ if (!insideAllowedRoot(real)) {
241
+ throw new Error(
242
+ `Lecture refusée : ${real} est hors des dossiers autorisés (${INLINE_ROOTS.join(', ')}). ` +
243
+ `Déplacez le fichier dans l'un d'eux, ou ajoutez son dossier à GATEWAY_INLINE_ROOTS ` +
244
+ `dans la configuration de ce serveur MCP.`
245
+ );
246
+ }
247
+ const ext = path.extname(real).toLowerCase();
248
+ if (!INLINE_ANY_EXTENSION && !INLINE_EXTENSIONS.includes(ext)) {
249
+ throw new Error(
250
+ `Lecture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
251
+ `(autorisées : ${INLINE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_EXTENSIONS).`
252
+ );
253
+ }
254
+ if (stat.size > INLINE_MAX_BYTES) {
255
+ throw new Error(
256
+ `Fichier trop volumineux à transmettre : ${real} pèse ${stat.size} octets, ` +
257
+ `la limite est de ${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES).`
258
+ );
259
+ }
260
+
261
+ out[contentProp] = fs.readFileSync(real).toString('base64');
262
+ if (nameProp) out[nameProp] = path.basename(real);
263
+ delete out[pathProp];
264
+
265
+ console.error(
266
+ `[mcp-mode] Fichier transmis${label ? ` à ${label}` : ''} : ${real} (${stat.size} octets)`
267
+ );
268
+ }
269
+ return out;
270
+ }
271
+
272
+ // --- Fichiers : le MCP produit, le relais écrit ---
273
+ //
274
+ // Le sens retour, symétrique du précédent. `inlineFileArgs` ne réécrit que les arguments
275
+ // à l'aller ; un fichier produit par le MCP, lui, n'a aucun moyen d'atteindre la machine
276
+ // de l'agent : le protocole n'offre au serveur aucune primitive pour y écrire.
277
+ //
278
+ // La règle : dès qu'un outil distant déclare une paire `<x>_path` / `<x>_relay`, le relais
279
+ // retient le chemin, annonce sa présence au MCP en posant `<x>_relay` à true, et ne
280
+ // transmet PAS `<x>_path` — qui ne désignerait rien à l'arrivée. Le MCP répond alors avec
281
+ // le contenu du fichier, que le relais écrit ICI, à l'emplacement retenu.
282
+ //
283
+ // Le contenu ne ressort JAMAIS vers l'agent : le relais le retire du résultat, qu'il ait
284
+ // réussi ou non à écrire. Un fichier de 8 Mio encodé en base64 n'a rien à faire dans le
285
+ // contexte d'un agent, et rien ne justifie de l'y déverser « au cas où » : l'appel échoue,
286
+ // le fichier est perdu, l'agent recommence.
287
+ //
288
+ // Sans relais dans la chaîne — MCP lancé en stdio à côté de l'agent — `<x>_relay` reste
289
+ // absent : les deux machines n'en font qu'une, et le MCP écrit lui-même à `<x>_path`.
290
+ //
291
+ // Écrire est plus dangereux que lire : la fonction a donc sa propre racine autorisée,
292
+ // et n'hérite pas de celle de la lecture.
293
+ //
294
+ // GATEWAY_INLINE_WRITE_ROOTS Dossiers où un fichier peut être déposé. C'est
295
+ // l'interrupteur : non renseigné, aucune livraison
296
+ // n'a lieu et l'appel échoue en le disant.
297
+ // GATEWAY_INLINE_WRITE_EXTENSIONS Extensions autorisées (défaut : toutes). Les racines
298
+ // restent le vrai garde-fou.
299
+ // GATEWAY_INLINE_MAX_BYTES Plafond de taille, partagé avec la lecture.
300
+
301
+ const WRITE_ROOTS = (process.env.GATEWAY_INLINE_WRITE_ROOTS ?? '')
302
+ .split(path.delimiter)
303
+ .map((entry) => entry.trim())
304
+ .filter(Boolean)
305
+ .map((entry) => {
306
+ const resolved = path.resolve(entry);
307
+ try {
308
+ return fs.realpathSync(resolved);
309
+ } catch {
310
+ console.error(`[mcp-mode] GATEWAY_INLINE_WRITE_ROOTS : dossier introuvable, ignoré — ${resolved}`);
311
+ return null;
312
+ }
313
+ })
314
+ .filter(Boolean);
315
+
316
+ const WRITE_EXTENSIONS = (process.env.GATEWAY_INLINE_WRITE_EXTENSIONS ?? '*')
317
+ .split(',')
318
+ .map((entry) => entry.trim().toLowerCase())
319
+ .filter(Boolean);
320
+
321
+ const WRITE_ANY_EXTENSION = WRITE_EXTENSIONS.includes('*');
322
+
323
+ if (WRITE_ROOTS.length > 0) {
324
+ console.error(
325
+ `[mcp-mode] Écriture des fichiers reçus activée vers : ${WRITE_ROOTS.join(', ')} ` +
326
+ `(extensions : ${WRITE_ANY_EXTENSION ? 'toutes' : WRITE_EXTENSIONS.join(' ')})`
327
+ );
328
+ }
329
+
330
+ /** Les paires `<x>_path` / `<x>_relay` déclarées par le schéma d'un outil. */
331
+ function deliverablePairs(inputSchema) {
332
+ const props = inputSchema?.properties;
333
+ if (!props) return [];
334
+ return Object.keys(props)
335
+ .filter((key) => key.endsWith('_relay') && `${key.slice(0, -'_relay'.length)}_path` in props)
336
+ .map((relayProp) => {
337
+ const stem = relayProp.slice(0, -'_relay'.length);
338
+ return {
339
+ pathProp: `${stem}_path`,
340
+ relayProp,
341
+ overwriteProp: `${stem}_overwrite` in props ? `${stem}_overwrite` : null,
342
+ };
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Le chemin de destination, résolu et vérifié contre les racines autorisées.
348
+ *
349
+ * `destDir` est toujours un dossier et `fileName` le nom que le MCP donne au fichier :
350
+ * le relais ne renomme jamais. Passer `fileName` vide ne vérifie que le dossier — ce
351
+ * qu'on fait à l'aller, pour échouer avant d'avoir fait voyager les octets pour rien.
352
+ *
353
+ * Le fichier n'existe pas encore : impossible d'appeler `realpath` dessus. On résout
354
+ * donc les liens du plus proche ancêtre existant, sans quoi un lien symbolique déposé
355
+ * dans une racine autorisée permettrait d'écrire n'importe où sur la machine.
356
+ */
357
+ function resolveWriteTarget(destDir, fileName = '') {
358
+ const resolved = fileName
359
+ ? path.resolve(destDir, path.basename(fileName))
360
+ : path.resolve(destDir);
361
+
362
+ let existing = path.dirname(resolved);
363
+ const trailing = [path.basename(resolved)];
364
+ while (!fs.existsSync(existing)) {
365
+ const parent = path.dirname(existing);
366
+ if (parent === existing) {
367
+ throw new Error(`Destination inatteignable sur la machine de l'agent : ${resolved}.`);
368
+ }
369
+ trailing.unshift(path.basename(existing));
370
+ existing = parent;
371
+ }
372
+
373
+ const real = path.join(fs.realpathSync(existing), ...trailing);
374
+
375
+ const inside = WRITE_ROOTS.some((root) => {
376
+ const rel = path.relative(root, real);
377
+ // Un dossier de destination PEUT être la racine elle-même ; un fichier, non.
378
+ if (rel === '') return fileName === '';
379
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
380
+ });
381
+ if (!inside) {
382
+ throw new Error(
383
+ `Écriture refusée : ${real} est hors des dossiers autorisés (${WRITE_ROOTS.join(', ')}). ` +
384
+ `Choisissez une destination dans l'un d'eux, ou ajoutez son dossier à ` +
385
+ `GATEWAY_INLINE_WRITE_ROOTS dans la configuration de ce serveur MCP.`
386
+ );
387
+ }
388
+
389
+ const ext = path.extname(real).toLowerCase();
390
+ if (fileName && !WRITE_ANY_EXTENSION && !WRITE_EXTENSIONS.includes(ext)) {
391
+ throw new Error(
392
+ `Écriture refusée : l'extension « ${ext || '(aucune)'} » n'est pas autorisée ` +
393
+ `(autorisées : ${WRITE_EXTENSIONS.join(' ')}, via GATEWAY_INLINE_WRITE_EXTENSIONS).`
394
+ );
395
+ }
396
+
397
+ return real;
398
+ }
399
+
400
+ /**
401
+ * Retire `<x>_path` des arguments et pose `<x>_relay`, pour que le MCP renvoie le
402
+ * contenu au lieu de tenter une écriture qui atterrirait sur SA machine.
403
+ */
404
+ function prepareDeliveries(inputSchema, args = {}) {
405
+ const pairs = deliverablePairs(inputSchema);
406
+ if (pairs.length === 0) return { args, deliveries: [] };
407
+
408
+ const out = { ...args };
409
+ const deliveries = [];
410
+ for (const { pathProp, relayProp, overwriteProp } of pairs) {
411
+ const destPath = out[pathProp];
412
+ if (typeof destPath !== 'string' || destPath === '') continue;
413
+
414
+ if (WRITE_ROOTS.length === 0) {
415
+ throw new Error(
416
+ `Livraison de fichier impossible : aucun dossier d'écriture autorisé. Le MCP tourne ` +
417
+ `sur la machine du gateway et ne peut pas écrire sur la vôtre. Renseignez ` +
418
+ `GATEWAY_INLINE_WRITE_ROOTS dans le bloc « env » de la configuration de ce ` +
419
+ `serveur MCP avec le ou les dossiers où déposer les fichiers reçus.`
420
+ );
421
+ }
422
+
423
+ // Vérification du dossier seul : le nom du fichier n'est connu qu'au retour.
424
+ resolveWriteTarget(destPath);
425
+ deliveries.push({
426
+ destDir: destPath,
427
+ overwrite: overwriteProp ? out[overwriteProp] === true : false,
428
+ });
429
+ out[relayProp] = true;
430
+ delete out[pathProp];
431
+ }
432
+ return { args: out, deliveries };
433
+ }
434
+
435
+ /**
436
+ * Écrit les fichiers renvoyés par le MCP et purge le résultat de leur contenu.
437
+ *
438
+ * Le contenu est retiré dans tous les cas, y compris en cas d'échec d'écriture : c'est
439
+ * la seule garantie qu'un base64 ne finira jamais dans le contexte de l'agent.
440
+ */
441
+ function applyDeliveries(result, deliveries, label = '') {
442
+ if (deliveries.length === 0) return result;
443
+
444
+ const blocks = Array.isArray(result?.content) ? result.content : [];
445
+ const blobs = blocks.filter((b) => b?.type === 'resource' && typeof b.resource?.blob === 'string');
446
+ const kept = blocks.filter((b) => !blobs.includes(b));
447
+
448
+ if (result?.isError) return { ...result, content: kept.length > 0 ? kept : blocks };
449
+
450
+ const notes = [];
451
+ deliveries.forEach((delivery, i) => {
452
+ const blob = blobs[i];
453
+ if (!blob) {
454
+ notes.push(`Aucun contenu renvoyé par le MCP pour ${delivery.destDir} : rien n'a été écrit.`);
455
+ return;
456
+ }
457
+ let target = delivery.destDir;
458
+ try {
459
+ const fileName = blob.resource.name || path.basename(new URL(blob.resource.uri).pathname);
460
+ if (!fileName) throw new Error("le MCP n'a pas nommé le fichier renvoyé");
461
+ target = resolveWriteTarget(delivery.destDir, fileName);
462
+
463
+ const data = Buffer.from(blob.resource.blob, 'base64');
464
+ if (data.length > INLINE_MAX_BYTES) {
465
+ throw new Error(
466
+ `fichier trop volumineux : ${data.length} octets, la limite est de ` +
467
+ `${INLINE_MAX_BYTES} (GATEWAY_INLINE_MAX_BYTES)`
468
+ );
469
+ }
470
+ if (!delivery.overwrite && fs.existsSync(target)) {
471
+ throw new Error('la destination existe déjà');
472
+ }
473
+ fs.mkdirSync(path.dirname(target), { recursive: true });
474
+ fs.writeFileSync(target, data);
475
+ notes.push(`Fichier écrit : ${target} (${data.length} octets).`);
476
+ console.error(
477
+ `[mcp-mode] Fichier reçu${label ? ` de ${label}` : ''} : ${target} (${data.length} octets)`
478
+ );
479
+ } catch (err) {
480
+ notes.push(`Échec de l'écriture dans ${target} : ${err.message}.`);
481
+ }
482
+ });
483
+
484
+ const failed = notes.some((note) => note.startsWith('Échec') || note.startsWith('Aucun'));
485
+ return {
486
+ ...result,
487
+ content: [...kept, { type: 'text', text: notes.join('\n') }],
488
+ ...(failed ? { isError: true } : {}),
489
+ };
490
+ }
491
+
492
+ /** Schémas des outils d'une route, mis en cache — nécessaires pour `call_tool`. */
493
+ const routeSchemas = new Map();
494
+
495
+ async function toolSchema(route, toolName) {
496
+ // Toujours récupéré, même si l'inlining en lecture est désactivé : c'est le schéma qui
497
+ // révèle une paire `<x>_path` / `<x>_relay`, et la manquer ferait repartir un chemin
498
+ // de destination vers le MCP distant, qui écrirait alors sur SA machine sans le dire.
499
+ if (!routeSchemas.has(route)) {
500
+ try {
501
+ const res = await gatewayFetch(`${route}/tools`);
502
+ const list = res.ok ? await res.json() : [];
503
+ routeSchemas.set(route, new Map(list.map((t) => [t.name, t.inputSchema])));
504
+ } catch {
505
+ routeSchemas.set(route, new Map());
506
+ }
507
+ }
508
+ return routeSchemas.get(route).get(toolName) ?? null;
509
+ }
510
+
511
+ // --- MCP Server ---
512
+
513
+ // Le gateway (mode serveur HTTP) peut être paramétré (EXPOSE_AT_AGENT_STARTUP) pour
514
+ // pré-exposer les outils de certains MCP directement à l'agent. La config vit uniquement
515
+ // sur le serveur : ce bridge se contente de récupérer ces outils et de les publier, de
516
+ // façon transparente. En cas d'échec (ancien gateway sans la route, gateway protégé par
517
+ // OAuth, etc.), on continue sans pré-exposition.
518
+ async function fetchProxyTools() {
519
+ try {
520
+ const res = await gatewayFetch('/startup-exposition');
521
+ if (!res.ok) {
522
+ // La pré-exposition reste optionnelle : on n'interrompt pas le démarrage.
523
+ // Mais un 401/403 ici annonce que TOUS les appels seront refusés — le dire
524
+ // maintenant évite de chercher plus tard. Sans le token, évidemment.
525
+ if (isAuthStatus(res.status)) console.error(`[mcp-mode] ${authErrorText(res.status)}`);
526
+ return [];
527
+ }
528
+ const { proxyTools } = await res.json();
529
+ return proxyTools ?? [];
530
+ } catch {
531
+ return [];
532
+ }
533
+ }
534
+
535
+ const proxyTools = await fetchProxyTools();
536
+ if (proxyTools.length) {
537
+ console.error(`[mcp-mode] ${proxyTools.length} proxy tool(s) exposed directly from gateway`);
538
+ }
539
+
540
+ const server = new Server(
541
+ { name: 'mcp-http-gateway-mcp', version: '1.0.0' },
542
+ { capabilities: { tools: {} } }
543
+ );
544
+
545
+ const tools = [
546
+ {
547
+ name: 'list_mcps',
548
+ description: 'List all MCPs available on the gateway with their route and description.',
549
+ inputSchema: { type: 'object', properties: {} },
550
+ },
551
+ {
552
+ name: 'list_tools',
553
+ description: 'List all tools available for a given MCP route.',
554
+ inputSchema: {
555
+ type: 'object',
556
+ properties: {
557
+ route: { type: 'string', description: 'MCP route as returned by list_mcps (e.g. "/documentor")' },
558
+ },
559
+ required: ['route'],
560
+ },
561
+ },
562
+ {
563
+ name: 'call_tool',
564
+ description: 'Call a tool on a given MCP route.',
565
+ inputSchema: {
566
+ type: 'object',
567
+ properties: {
568
+ route: { type: 'string', description: 'MCP route (e.g. "/documentor")' },
569
+ tool_name: { type: 'string', description: 'Tool name to call' },
570
+ arguments: { type: 'object', description: 'Arguments to pass to the tool' },
571
+ },
572
+ required: ['route', 'tool_name'],
573
+ },
574
+ },
575
+ ];
576
+
577
+ const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
578
+ const allTools = [
579
+ ...tools,
580
+ ...proxyTools.map(({ name, description, inputSchema }) => ({
581
+ name,
582
+ description,
583
+ inputSchema: schemaForAgent(inputSchema),
584
+ })),
585
+ ];
586
+
587
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
588
+
589
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
590
+ const { name, arguments: args = {} } = request.params;
591
+
592
+ try {
593
+ const proxy = proxyIndex.get(name);
594
+ if (proxy) {
595
+ const label = `${proxy.route}/${proxy.toolName}`;
596
+ const { args: outArgs, deliveries } = prepareDeliveries(proxy.inputSchema, args ?? {});
597
+ const res = await gatewayFetch(`${proxy.route}/tools/${proxy.toolName}`, {
598
+ method: 'POST',
599
+ headers: { 'Content-Type': 'application/json' },
600
+ body: JSON.stringify(inlineFileArgs(proxy.inputSchema, outArgs, label)),
601
+ });
602
+ if (!res.ok) {
603
+ const text = await res.text();
604
+ return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
605
+ }
606
+ return applyDeliveries(await res.json(), deliveries, label);
607
+ }
608
+
609
+ if (name === 'list_mcps') {
610
+ const res = await gatewayFetch('/mcp-definitions');
611
+ if (!res.ok) throw new Error(gatewayThrowText(res.status));
612
+ const definitions = await res.json();
613
+ const text = definitions
614
+ .map(m => `- ${m.id} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
615
+ .join('\n');
616
+ return { content: [{ type: 'text', text }] };
617
+ }
618
+
619
+ if (name === 'list_tools') {
620
+ const res = await gatewayFetch(`${args.route}/tools`);
621
+ if (!res.ok) throw new Error(gatewayThrowText(res.status));
622
+ const toolsList = await res.json();
623
+ routeSchemas.set(args.route, new Map(toolsList.map((t) => [t.name, t.inputSchema])));
624
+ // Même règle que pour les outils pré-exposés : l'agent ne doit pas découvrir
625
+ // les paramètres de contenu, c'est le relais qui les remplit.
626
+ const text = JSON.stringify(
627
+ toolsList.map((t) => (t.inputSchema ? { ...t, inputSchema: schemaForAgent(t.inputSchema) } : t))
628
+ );
629
+ return { content: [{ type: 'text', text }] };
630
+ }
631
+
632
+ if (name === 'call_tool') {
633
+ const schema = await toolSchema(args.route, args.tool_name);
634
+ const label = `${args.route}/${args.tool_name}`;
635
+ const { args: outArgs, deliveries } = prepareDeliveries(schema, args.arguments ?? {});
636
+ const res = await gatewayFetch(`${args.route}/tools/${args.tool_name}`, {
637
+ method: 'POST',
638
+ headers: { 'Content-Type': 'application/json' },
639
+ body: JSON.stringify(inlineFileArgs(schema, outArgs, label)),
640
+ });
641
+ if (!res.ok) {
642
+ const text = await res.text();
643
+ return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
644
+ }
645
+ return applyDeliveries(await res.json(), deliveries, label);
646
+ }
647
+
648
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
649
+ } catch (err) {
650
+ return { content: [{ type: 'text', text: `Request failed: ${err.message}` }], isError: true };
651
+ }
652
+ });
653
+
654
+ const transport = new StdioServerTransport();
655
+ await server.connect(transport);