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