@imenam/mcp-http-gateway 2.0.10 → 2.0.12

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