@imenam/mcp-http-gateway 2.0.9 → 2.0.11
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 +118 -0
- package/mcp-mode.js +452 -6
- package/package.json +1 -1
- package/server.js +5 -1
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,11 @@ 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, tout appel à un outil qui déclare la paire échoue explicitement.** 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, dans les deux sens. Défaut : `8388608` (8 Mio). |
|
|
195
|
+
| `GATEWAY_INLINE_WRITE_ROOTS` | non | Dossiers dans lesquels le relais peut écrire un fichier renvoyé par un MCP (cf. [Fichiers reçus](#fichiers-reçus--le-mcp-produit-le-relais-écrit)). **Non renseignée, la fonction est désactivée** et tout outil qui la réclame échoue explicitement. Séparateur : `;` sous Windows, `:` ailleurs. |
|
|
196
|
+
| `GATEWAY_INLINE_WRITE_EXTENSIONS` | non | Extensions autorisées à l'écriture, séparées par des virgules. Défaut : `*` (toutes) — les racines restent le vrai garde-fou. |
|
|
191
197
|
|
|
192
198
|
> Ces deux variables sont lues dans l'environnement du process. Le relais fait
|
|
193
199
|
> `import 'dotenv/config'` : un fichier `.env` présent dans le **répertoire courant de
|
|
@@ -230,6 +236,118 @@ Le mode MCP expose 3 outils qui reflètent directement les routes du gateway :
|
|
|
230
236
|
|
|
231
237
|
L'agent découvre d'abord les MCPs disponibles, explore les outils d'un MCP si besoin, puis appelle l'outil voulu.
|
|
232
238
|
|
|
239
|
+
### Fichiers locaux : l'agent nomme, le relais lit
|
|
240
|
+
|
|
241
|
+
Le relais tourne sur la machine de l'**agent** ; les MCP, eux, tournent sur la machine du
|
|
242
|
+
**gateway**. Un chemin de fichier transmis tel quel ne veut donc rien dire à l'arrivée : le
|
|
243
|
+
MCP le cherche chez lui et répond « fichier introuvable ».
|
|
244
|
+
|
|
245
|
+
Le relais sait résoudre ça. Dès qu'un outil distant déclare une paire de paramètres
|
|
246
|
+
`<x>_path` / `<x>_content`, le relais **lit le fichier localement, l'encode en base64 et
|
|
247
|
+
envoie le contenu** dans `<x>_content` (plus le nom dans `<x>_name`, si le schéma le
|
|
248
|
+
prévoit). Le paramètre `file_path` reste la seule chose que l'agent fournit : les
|
|
249
|
+
paramètres que le relais remplit sont **retirés du schéma** publié à l'agent, par
|
|
250
|
+
`tools/list` comme par `list_tools`. L'agent n'a donc aucun arbitrage à faire, et aucune
|
|
251
|
+
occasion d'envoyer un chemin qui ne serait pas lisible à l'arrivée.
|
|
252
|
+
|
|
253
|
+
Un outil qui ne déclare pas cette paire n'est jamais concerné : un `output_path` seul reste
|
|
254
|
+
un chemin, interprété côté gateway comme avant.
|
|
255
|
+
|
|
256
|
+
**Le chemin vient de l'agent : il n'est pas digne de confiance.** La fonction est donc
|
|
257
|
+
fermée par défaut et s'ouvre dossier par dossier :
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"mcpServers": {
|
|
262
|
+
"gateway": {
|
|
263
|
+
"command": "npx",
|
|
264
|
+
"args": ["@imenam/mcp-http-gateway", "--mcp"],
|
|
265
|
+
"env": {
|
|
266
|
+
"GATEWAY_URL": "https://my-gateway.fr/jskdlf-jsfklhj",
|
|
267
|
+
"GATEWAY_TOKEN": "…",
|
|
268
|
+
"GATEWAY_INLINE_ROOTS": "C:\\Users\\moi\\Documents\\tableurs;C:\\travail\\exports"
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- **`GATEWAY_INLINE_ROOTS` est l'interrupteur.** Non renseignée, le relais ne lit aucun
|
|
276
|
+
fichier et **l'appel échoue** en nommant la variable à définir. Un outil qui déclare la
|
|
277
|
+
paire attend le contenu : laisser filer le chemin ferait chercher le fichier au MCP sur
|
|
278
|
+
la machine du gateway, où il ne le trouverait pas — ou pire, où il traiterait un homonyme
|
|
279
|
+
sans rien signaler. Pas de repli, pas de dégradation silencieuse.
|
|
280
|
+
|
|
281
|
+
> **Changement de comportement.** Les versions antérieures laissaient passer le chemin tel
|
|
282
|
+
> quel dans ce cas. Si un déploiement existant s'appuyait là-dessus, renseignez
|
|
283
|
+
> `GATEWAY_INLINE_ROOTS` — le message d'erreur le dit explicitement.
|
|
284
|
+
- Les liens symboliques sont résolus **avant** le contrôle : un lien posé dans un dossier
|
|
285
|
+
autorisé ne fait pas sortir un fichier situé ailleurs. Une remontée par `..` est refusée
|
|
286
|
+
pour la même raison.
|
|
287
|
+
- Seules les extensions de `GATEWAY_INLINE_EXTENSIONS` passent (défaut : tableurs).
|
|
288
|
+
- Au-delà de `GATEWAY_INLINE_MAX_BYTES` (8 Mio par défaut), refus explicite. Ce défaut
|
|
289
|
+
tient sous le plafond de 16 Mio que le hub `mcp-gateway-manager` applique quand il
|
|
290
|
+
journalise les corps de requête.
|
|
291
|
+
- Tout refus est rapporté à l'agent en clair, et **chaque fichier transmis laisse une ligne
|
|
292
|
+
sur `stderr`** : ce qui sort de la machine est lisible dans le journal de l'agent.
|
|
293
|
+
|
|
294
|
+
> Côté MCP, cette fonction suppose que l'outil accepte le contenu en base64. C'est le cas
|
|
295
|
+
> de [`@imenam/xls-reader`](https://www.npmjs.com/package/@imenam/xls-reader) ≥ 2.2. Un MCP
|
|
296
|
+
> qui n'expose que `file_path` ne déclare pas la paire, et rien ne change pour lui.
|
|
297
|
+
|
|
298
|
+
> **Le mode MCP distant (`/mcp`, Claude.ai) n'a pas de relais local** : rien ne tourne alors
|
|
299
|
+
> sur la machine de l'appelant, donc personne ne peut lire son disque. Par ce chemin, un
|
|
300
|
+
> agent qui veut faire lire un fichier doit en envoyer lui-même le contenu.
|
|
301
|
+
|
|
302
|
+
### Fichiers reçus : le MCP produit, le relais écrit
|
|
303
|
+
|
|
304
|
+
Le sens retour, symétrique du précédent. Un MCP qui *fabrique* un fichier — un export, une
|
|
305
|
+
pièce jointe, un téléchargement — ne peut pas le déposer sur la machine de l'agent : le
|
|
306
|
+
protocole n'offre au serveur aucune primitive pour y écrire. Un chemin de destination
|
|
307
|
+
transmis tel quel ferait écrire le MCP sur **son** disque, sans que personne s'en aperçoive.
|
|
308
|
+
|
|
309
|
+
Dès qu'un outil distant déclare la paire `<x>_path` / `<x>_relay`, le relais :
|
|
310
|
+
|
|
311
|
+
1. retire `<x>_relay` du schéma publié à l'agent, qui ne voit qu'un dossier à nommer ;
|
|
312
|
+
2. retient `<x>_path`, ne le transmet pas, et pose `<x>_relay` à `true` ;
|
|
313
|
+
3. reçoit le contenu du fichier dans la réponse du MCP, l'écrit **ici**, et le retire du
|
|
314
|
+
résultat avant de le rendre à l'agent.
|
|
315
|
+
|
|
316
|
+
```json
|
|
317
|
+
{
|
|
318
|
+
"mcpServers": {
|
|
319
|
+
"gateway": {
|
|
320
|
+
"command": "npx",
|
|
321
|
+
"args": ["@imenam/mcp-http-gateway", "--mcp"],
|
|
322
|
+
"env": {
|
|
323
|
+
"GATEWAY_URL": "https://my-gateway.fr/jskdlf-jsfklhj",
|
|
324
|
+
"GATEWAY_TOKEN": "…",
|
|
325
|
+
"GATEWAY_INLINE_WRITE_ROOTS": "C:\\Users\\moi\\Téléchargements"
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- **`GATEWAY_INLINE_WRITE_ROOTS` est l'interrupteur**, distinct de celui de la lecture :
|
|
333
|
+
écrire est plus dangereux que lire, et n'hérite donc pas des dossiers autorisés en lecture.
|
|
334
|
+
- **Non renseignée, l'appel échoue** en nommant la variable à définir. Pas de repli, pas de
|
|
335
|
+
dégradation silencieuse : mieux vaut un fichier perdu qu'un fichier écrit au mauvais endroit.
|
|
336
|
+
- `<x>_path` est toujours un **dossier**, créé si besoin ; le fichier garde le nom que lui
|
|
337
|
+
donne le MCP. Le relais ne renomme jamais.
|
|
338
|
+
- Si l'outil déclare aussi `<x>_overwrite`, le relais l'honore ; sinon il refuse d'écraser.
|
|
339
|
+
- Les liens symboliques sont résolus avant contrôle, y compris sur le plus proche ancêtre
|
|
340
|
+
existant quand la destination n'existe pas encore.
|
|
341
|
+
- **Le contenu est retiré du résultat dans tous les cas**, y compris en cas d'échec
|
|
342
|
+
d'écriture. Un fichier de 8 Mio encodé en base64 n'a rien à faire dans le contexte d'un
|
|
343
|
+
agent : ça coûte cher, et un agent qui le recopierait à la main le corromprait.
|
|
344
|
+
- Chaque fichier écrit laisse une ligne sur `stderr`.
|
|
345
|
+
|
|
346
|
+
> Côté MCP, cette fonction suppose que l'outil sache renvoyer son fichier en bloc
|
|
347
|
+
> `resource` (avec `blob` et `name`) quand `<x>_relay` est posé. C'est le cas de
|
|
348
|
+
> [`file-share-mcp`](https://www.npmjs.com/package/file-share-mcp). Un MCP qui ne déclare
|
|
349
|
+
> pas la paire n'est pas concerné, et son résultat repart intact.
|
|
350
|
+
|
|
233
351
|
---
|
|
234
352
|
|
|
235
353
|
## 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,433 @@ 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 (!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
|
+
|
|
72
501
|
// --- MCP Server ---
|
|
73
502
|
|
|
74
503
|
// Le gateway (mode serveur HTTP) peut être paramétré (EXPOSE_AT_AGENT_STARTUP) pour
|
|
@@ -136,7 +565,14 @@ const tools = [
|
|
|
136
565
|
];
|
|
137
566
|
|
|
138
567
|
const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
|
|
139
|
-
const allTools = [
|
|
568
|
+
const allTools = [
|
|
569
|
+
...tools,
|
|
570
|
+
...proxyTools.map(({ name, description, inputSchema }) => ({
|
|
571
|
+
name,
|
|
572
|
+
description,
|
|
573
|
+
inputSchema: schemaForAgent(inputSchema),
|
|
574
|
+
})),
|
|
575
|
+
];
|
|
140
576
|
|
|
141
577
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
|
|
142
578
|
|
|
@@ -146,16 +582,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
146
582
|
try {
|
|
147
583
|
const proxy = proxyIndex.get(name);
|
|
148
584
|
if (proxy) {
|
|
585
|
+
const label = `${proxy.route}/${proxy.toolName}`;
|
|
586
|
+
const { args: outArgs, deliveries } = prepareDeliveries(proxy.inputSchema, args ?? {});
|
|
149
587
|
const res = await gatewayFetch(`${proxy.route}/tools/${proxy.toolName}`, {
|
|
150
588
|
method: 'POST',
|
|
151
589
|
headers: { 'Content-Type': 'application/json' },
|
|
152
|
-
body: JSON.stringify(
|
|
590
|
+
body: JSON.stringify(inlineFileArgs(proxy.inputSchema, outArgs, label)),
|
|
153
591
|
});
|
|
154
592
|
if (!res.ok) {
|
|
155
593
|
const text = await res.text();
|
|
156
594
|
return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
|
|
157
595
|
}
|
|
158
|
-
return await res.json();
|
|
596
|
+
return applyDeliveries(await res.json(), deliveries, label);
|
|
159
597
|
}
|
|
160
598
|
|
|
161
599
|
if (name === 'list_mcps') {
|
|
@@ -172,21 +610,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
172
610
|
const res = await gatewayFetch(`${args.route}/tools`);
|
|
173
611
|
if (!res.ok) throw new Error(gatewayThrowText(res.status));
|
|
174
612
|
const toolsList = await res.json();
|
|
175
|
-
|
|
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
|
+
);
|
|
176
619
|
return { content: [{ type: 'text', text }] };
|
|
177
620
|
}
|
|
178
621
|
|
|
179
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 ?? {});
|
|
180
626
|
const res = await gatewayFetch(`${args.route}/tools/${args.tool_name}`, {
|
|
181
627
|
method: 'POST',
|
|
182
628
|
headers: { 'Content-Type': 'application/json' },
|
|
183
|
-
body: JSON.stringify(
|
|
629
|
+
body: JSON.stringify(inlineFileArgs(schema, outArgs, label)),
|
|
184
630
|
});
|
|
185
631
|
if (!res.ok) {
|
|
186
632
|
const text = await res.text();
|
|
187
633
|
return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
|
|
188
634
|
}
|
|
189
|
-
return await res.json();
|
|
635
|
+
return applyDeliveries(await res.json(), deliveries, label);
|
|
190
636
|
}
|
|
191
637
|
|
|
192
638
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
package/package.json
CHANGED
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
|
-
|
|
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);
|