@imenam/mcp-http-gateway 2.0.9 → 2.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,7 @@ GATEWAY_CONFIG_DIR=./config
52
52
  |---|---|---|
53
53
  | `GATEWAY_PORT` | `3500` | Port HTTP du gateway |
54
54
  | `GATEWAY_CONFIG_DIR` | `./config` (relatif au cwd) | Dossier contenant `mcp-servers.json` |
55
+ | `GATEWAY_BODY_LIMIT` | `32mb` | Taille maximale du corps d'une requête. Le défaut d'express (100 ko) ne suffit pas dès qu'un appel d'outil porte le contenu d'un fichier en base64 ; au-delà de cette limite, la réponse est un `413`. |
55
56
 
56
57
  ### `mcp-servers.json`
57
58
 
@@ -188,6 +189,9 @@ GATEWAY_URL=http://host.docker.internal:3500 npx @imenam/mcp-http-gateway --mcp
188
189
  |---|---|---|
189
190
  | `GATEWAY_URL` | oui | Base HTTP du gateway à relayer, sans barre finale. Le relais compose lui-même ses routes REST (`/startup-exposition`, `/mcp-definitions`, `/{route}/tools`, `/{route}/tools/{outil}`) : **ne pas** y ajouter `/mcp`. |
190
191
  | `GATEWAY_TOKEN` | non | Token porteur. S'il est défini, **tous** les appels sortants du relais portent `Authorization: Bearer <token>`. Absent, aucun en-tête n'est ajouté — le comportement est exactement celui des versions antérieures. |
192
+ | `GATEWAY_INLINE_ROOTS` | non | Dossiers depuis lesquels le relais peut lire un fichier et en transmettre le contenu (cf. [Fichiers locaux](#fichiers-locaux--lagent-nomme-le-relais-lit)). **Non renseignée, la fonction est désactivée** et un chemin part tel quel. Séparateur : `;` sous Windows, `:` ailleurs. |
193
+ | `GATEWAY_INLINE_EXTENSIONS` | non | Extensions autorisées, séparées par des virgules. Défaut : `.xlsx,.xls,.ods,.csv`. `*` autorise toutes les extensions. |
194
+ | `GATEWAY_INLINE_MAX_BYTES` | non | Taille maximale d'un fichier transmis, en octets. Défaut : `8388608` (8 Mio). |
191
195
 
192
196
  > Ces deux variables sont lues dans l'environnement du process. Le relais fait
193
197
  > `import 'dotenv/config'` : un fichier `.env` présent dans le **répertoire courant de
@@ -230,6 +234,62 @@ Le mode MCP expose 3 outils qui reflètent directement les routes du gateway :
230
234
 
231
235
  L'agent découvre d'abord les MCPs disponibles, explore les outils d'un MCP si besoin, puis appelle l'outil voulu.
232
236
 
237
+ ### Fichiers locaux : l'agent nomme, le relais lit
238
+
239
+ Le relais tourne sur la machine de l'**agent** ; les MCP, eux, tournent sur la machine du
240
+ **gateway**. Un chemin de fichier transmis tel quel ne veut donc rien dire à l'arrivée : le
241
+ MCP le cherche chez lui et répond « fichier introuvable ».
242
+
243
+ Le relais sait résoudre ça. Dès qu'un outil distant déclare une paire de paramètres
244
+ `<x>_path` / `<x>_content`, le relais **lit le fichier localement, l'encode en base64 et
245
+ envoie le contenu** dans `<x>_content` (plus le nom dans `<x>_name`, si le schéma le
246
+ prévoit). Le paramètre `file_path` reste la seule chose que l'agent fournit : les
247
+ paramètres que le relais remplit sont **retirés du schéma** publié à l'agent, par
248
+ `tools/list` comme par `list_tools`. L'agent n'a donc aucun arbitrage à faire, et aucune
249
+ occasion d'envoyer un chemin qui ne serait pas lisible à l'arrivée.
250
+
251
+ Un outil qui ne déclare pas cette paire n'est jamais concerné : un `output_path` seul reste
252
+ un chemin, interprété côté gateway comme avant.
253
+
254
+ **Le chemin vient de l'agent : il n'est pas digne de confiance.** La fonction est donc
255
+ fermée par défaut et s'ouvre dossier par dossier :
256
+
257
+ ```json
258
+ {
259
+ "mcpServers": {
260
+ "gateway": {
261
+ "command": "npx",
262
+ "args": ["@imenam/mcp-http-gateway", "--mcp"],
263
+ "env": {
264
+ "GATEWAY_URL": "https://my-gateway.fr/jskdlf-jsfklhj",
265
+ "GATEWAY_TOKEN": "…",
266
+ "GATEWAY_INLINE_ROOTS": "C:\\Users\\moi\\Documents\\tableurs;C:\\travail\\exports"
267
+ }
268
+ }
269
+ }
270
+ }
271
+ ```
272
+
273
+ - **`GATEWAY_INLINE_ROOTS` est l'interrupteur.** Non renseignée, le relais ne lit aucun
274
+ fichier et se comporte exactement comme les versions antérieures.
275
+ - Les liens symboliques sont résolus **avant** le contrôle : un lien posé dans un dossier
276
+ autorisé ne fait pas sortir un fichier situé ailleurs. Une remontée par `..` est refusée
277
+ pour la même raison.
278
+ - Seules les extensions de `GATEWAY_INLINE_EXTENSIONS` passent (défaut : tableurs).
279
+ - Au-delà de `GATEWAY_INLINE_MAX_BYTES` (8 Mio par défaut), refus explicite. Ce défaut
280
+ tient sous le plafond de 16 Mio que le hub `mcp-gateway-manager` applique quand il
281
+ journalise les corps de requête.
282
+ - Tout refus est rapporté à l'agent en clair, et **chaque fichier transmis laisse une ligne
283
+ sur `stderr`** : ce qui sort de la machine est lisible dans le journal de l'agent.
284
+
285
+ > Côté MCP, cette fonction suppose que l'outil accepte le contenu en base64. C'est le cas
286
+ > de [`@imenam/xls-reader`](https://www.npmjs.com/package/@imenam/xls-reader) ≥ 2.2. Un MCP
287
+ > qui n'expose que `file_path` ne déclare pas la paire, et rien ne change pour lui.
288
+
289
+ > **Le mode MCP distant (`/mcp`, Claude.ai) n'a pas de relais local** : rien ne tourne alors
290
+ > sur la machine de l'appelant, donc personne ne peut lire son disque. Par ce chemin, un
291
+ > agent qui veut faire lire un fichier doit en envoyer lui-même le contenu.
292
+
233
293
  ---
234
294
 
235
295
  ## Mode MCP distant (HTTPS / Claude.ai)
package/mcp-mode.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
+ import fs from 'fs';
4
+ import path from 'path';
3
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
7
  import {
@@ -69,6 +71,196 @@ function gatewayThrowText(status) {
69
71
  return isAuthStatus(status) ? authErrorText(status) : `HTTP ${status}`;
70
72
  }
71
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
+
72
264
  // --- MCP Server ---
73
265
 
74
266
  // Le gateway (mode serveur HTTP) peut être paramétré (EXPOSE_AT_AGENT_STARTUP) pour
@@ -136,7 +328,14 @@ const tools = [
136
328
  ];
137
329
 
138
330
  const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
139
- const allTools = [...tools, ...proxyTools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))];
331
+ const allTools = [
332
+ ...tools,
333
+ ...proxyTools.map(({ name, description, inputSchema }) => ({
334
+ name,
335
+ description,
336
+ inputSchema: schemaForAgent(inputSchema),
337
+ })),
338
+ ];
140
339
 
141
340
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
142
341
 
@@ -149,7 +348,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
149
348
  const res = await gatewayFetch(`${proxy.route}/tools/${proxy.toolName}`, {
150
349
  method: 'POST',
151
350
  headers: { 'Content-Type': 'application/json' },
152
- body: JSON.stringify(args ?? {}),
351
+ body: JSON.stringify(
352
+ inlineFileArgs(proxy.inputSchema, args ?? {}, `${proxy.route}/${proxy.toolName}`)
353
+ ),
153
354
  });
154
355
  if (!res.ok) {
155
356
  const text = await res.text();
@@ -172,15 +373,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
172
373
  const res = await gatewayFetch(`${args.route}/tools`);
173
374
  if (!res.ok) throw new Error(gatewayThrowText(res.status));
174
375
  const toolsList = await res.json();
175
- const text = JSON.stringify(toolsList);
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
+ );
176
382
  return { content: [{ type: 'text', text }] };
177
383
  }
178
384
 
179
385
  if (name === 'call_tool') {
386
+ const schema = await toolSchema(args.route, args.tool_name);
180
387
  const res = await gatewayFetch(`${args.route}/tools/${args.tool_name}`, {
181
388
  method: 'POST',
182
389
  headers: { 'Content-Type': 'application/json' },
183
- body: JSON.stringify(args.arguments ?? {}),
390
+ body: JSON.stringify(
391
+ inlineFileArgs(schema, args.arguments ?? {}, `${args.route}/${args.tool_name}`)
392
+ ),
184
393
  });
185
394
  if (!res.ok) {
186
395
  const text = await res.text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-http-gateway",
3
- "version": "2.0.9",
3
+ "version": "2.0.10",
4
4
  "type": "module",
5
5
  "description": "HTTP gateway that exposes MCP servers as REST routes — bridge between MCP and any HTTP-capable agent",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -114,7 +114,11 @@ async function main() {
114
114
  app.set('trust proxy', val === 'true' || val === '1' ? 1 : val);
115
115
  console.log(`[gateway] Trust proxy enabled (TRUST_PROXY=${val})`);
116
116
  }
117
- app.use(express.json({ type: '*/*' }));
117
+ // 32 Mo, et non les 100 ko par défaut d'express : un appel d'outil peut porter le
118
+ // contenu d'un fichier encodé en base64 (cf. l'inlining du relais dans mcp-mode.js),
119
+ // qui repartirait sinon en « 413 Payload Too Large » sans explication.
120
+ const BODY_LIMIT = process.env.GATEWAY_BODY_LIMIT ?? '32mb';
121
+ app.use(express.json({ type: '*/*', limit: BODY_LIMIT }));
118
122
 
119
123
  const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
120
124
  const globalMax = parseInt(process.env.RATE_LIMIT_GLOBAL_MAX ?? '300', 10);