toolfunnel 0.7.0 → 0.8.0
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 +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +16 -5
package/src/mcp/aggregator.js
CHANGED
|
@@ -1,1674 +1,2248 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* aggregator.js - the upstream-MCP connection + curated-expose layer.
|
|
5
|
-
*
|
|
6
|
-
* The aggregator is the bridge between the persisted MCP config (src/mcp/expose-store.js)
|
|
7
|
-
* and the live upstream MCP servers (src/mcp/mcp-client.js). It:
|
|
8
|
-
*
|
|
9
|
-
* 1. CONNECTS one McpClient per ENABLED upstream in the ExposeStore (connectAll),
|
|
10
|
-
* caching the connected client + its discovered tools.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
// vars
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
this
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
* upstream
|
|
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
|
-
|
|
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
|
-
|
|
648
|
-
return
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* The
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
*
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
*
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
return
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
*
|
|
915
|
-
* upstream
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
if (!
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
*
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
/**
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
const
|
|
1081
|
-
if (
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*/
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
*
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
*
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
if (!
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
this.
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
if (
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aggregator.js - the upstream-MCP connection + curated-expose layer.
|
|
5
|
+
*
|
|
6
|
+
* The aggregator is the bridge between the persisted MCP config (src/mcp/expose-store.js)
|
|
7
|
+
* and the live upstream MCP servers (src/mcp/mcp-client.js). It:
|
|
8
|
+
*
|
|
9
|
+
* 1. CONNECTS one McpClient per ENABLED upstream in the ExposeStore (connectAll),
|
|
10
|
+
* caching the connected client + its discovered tools. Upstreams connect CONCURRENTLY,
|
|
11
|
+
* so startup costs the SLOWEST upstream rather than the SUM of all of them. A failed
|
|
12
|
+
* upstream never sinks the others - failures are collected, not thrown, and each one
|
|
13
|
+
* arms the same background reconnect a mid-session death gets.
|
|
14
|
+
* 2. DISCOVERS an upstream's tool surface on demand (discover) - what the MCP
|
|
15
|
+
* Manager UI's 'discover' button calls.
|
|
16
|
+
* 3. Computes the CURATED-DIRECT tool definitions to advertise downstream
|
|
17
|
+
* (exposedToolDefinitions) - only for ENABLED expose[] entries whose upstream
|
|
18
|
+
* is connected, enabled, AND actually advertises the named tool. Each definition is
|
|
19
|
+
* renamed to its downstream `as` name (what the CLI / PreToolUse matchers see).
|
|
20
|
+
* 4. RESOLVES a downstream call back to its upstream client + real tool name
|
|
21
|
+
* (resolveExposedExecution) - the seam the MCP server's curated-direct call
|
|
22
|
+
* path uses to actually execute, AFTER the hook gate has run.
|
|
23
|
+
*
|
|
24
|
+
* Decoupling note: downstream the CLI sees the renamed `as` tool; upstream we call
|
|
25
|
+
* the REAL tool name. resolveExposedExecution carries BOTH so the gate (PreToolUse)
|
|
26
|
+
* matches on the downstream name while execution hits the real upstream.
|
|
27
|
+
*
|
|
28
|
+
* SAFETY CONTRACT (mirrors expose-store.js / mcp-client.js):
|
|
29
|
+
* - connectAll / discover / closeAll NEVER throw. connectAll collects per-upstream
|
|
30
|
+
* failures into failed[]; a dead/misconfigured upstream cannot crash the server
|
|
31
|
+
* start. closeAll is idempotent and tears down every cached client.
|
|
32
|
+
* - The DEFAULT clientFactory enforces the HARD ISOLATION rule: a vendored upstream
|
|
33
|
+
* may only reference CODE/FILES inside the gateway root. The
|
|
34
|
+
* guard is applied to the ARGS (the script path, db files, ... that the interpreter
|
|
35
|
+
* is pointed at): any arg that is an absolute path, or that looks like a file path
|
|
36
|
+
* (contains a path separator), MUST resolve inside gatewayRoot - otherwise the factory
|
|
37
|
+
* throws and connectAll records the upstream in failed[]. The COMMAND slot is the
|
|
38
|
+
* interpreter/executable (node, npx, or an absolute path to a node binary that by
|
|
39
|
+
* definition lives OUTSIDE the project, e.g. process.execPath / a system node.exe)
|
|
40
|
+
* and is therefore NOT subject to the in-sandbox requirement - bare commands AND an
|
|
41
|
+
* absolute interpreter path are both allowed. Isolation is about WHAT CODE runs in
|
|
42
|
+
* the spawned process, not which interpreter binary runs it; the args are that code.
|
|
43
|
+
* This is what stops a config from pointing at a live `.mcp.json` server's script
|
|
44
|
+
* outside the sandbox while still letting the system node interpreter spawn it.
|
|
45
|
+
*
|
|
46
|
+
* CommonJS only. Node built-ins only. No new npm dependency. No @modelcontextprotocol/sdk.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const path = require('node:path');
|
|
50
|
+
|
|
51
|
+
const { McpClient, PROTOCOL_VERSION: LEGACY_UPSTREAM_VERSION } = require('./mcp-client');
|
|
52
|
+
const logger = require('../core/logger');
|
|
53
|
+
const approvals = require('./tool-approvals');
|
|
54
|
+
const crypto = require('node:crypto');
|
|
55
|
+
|
|
56
|
+
// The sandbox root = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package
|
|
57
|
+
// root - see src/core/config-home.js). Used as the isolation boundary the default clientFactory
|
|
58
|
+
// enforces: a pack's path-shaped upstream args live under the home, so the home is the boundary.
|
|
59
|
+
// A caller may override via opts.gatewayRoot for tests.
|
|
60
|
+
const { resolveConfigHome } = require('../core/config-home');
|
|
61
|
+
const DEFAULT_ROOT = resolveConfigHome();
|
|
62
|
+
|
|
63
|
+
// The aggregators open in THIS process, by writer id (a config reload replaces one).
|
|
64
|
+
const LIVE_WRITERS = new Set();
|
|
65
|
+
|
|
66
|
+
/** Is the gateway that wrote a pending entry still running? In this process: that aggregator is
|
|
67
|
+
* still open. Elsewhere: its process still exists. An entry with no writer is kept. */
|
|
68
|
+
function writerAlive(by) {
|
|
69
|
+
if (!by || typeof by.pid !== 'number') return true;
|
|
70
|
+
if (by.pid === process.pid) return LIVE_WRITERS.has(by.id);
|
|
71
|
+
try { process.kill(by.pid, 0); return true; } catch (err) { return !!err && err.code === 'EPERM'; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True for a non-empty string. */
|
|
75
|
+
function isNonEmptyString(v) {
|
|
76
|
+
return typeof v === 'string' && v.length > 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does this argv token LOOK like a file path? An absolute path (path.isAbsolute) or
|
|
81
|
+
* any token containing a path separator ('/' or '\\') is treated as a path reference
|
|
82
|
+
* and must be confined to gatewayRoot. A bare token like 'node', 'npx', 'server' or a flag
|
|
83
|
+
* like '--port' is NOT a path and is always allowed.
|
|
84
|
+
* @param {string} arg
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
function looksLikePath(arg) {
|
|
88
|
+
if (!isNonEmptyString(arg)) return false;
|
|
89
|
+
if (path.isAbsolute(arg)) return true;
|
|
90
|
+
// win32 DRIVE-RELATIVE form ('C:foo.js') - path.win32.isAbsolute() is false for it and it holds
|
|
91
|
+
// no separator, so without this it looked like a bare token and skipped the guard entirely.
|
|
92
|
+
if (/^[A-Za-z]:/.test(arg)) return true;
|
|
93
|
+
return arg.indexOf('/') !== -1 || arg.indexOf('\\') !== -1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The path-shaped candidates inside a single argv token. A runtime option in `--flag=value` form
|
|
98
|
+
* (node's --require=, --import=, --experimental-loader=, and their Deno/Bun kin) interprets only
|
|
99
|
+
* the part AFTER the first '=' as a path. The whole token '--require=../x.js' carries no '..'
|
|
100
|
+
* segment of its own, so a check of the token alone judges it inside the root while the child
|
|
101
|
+
* resolves the value and leaves it. So the value after the first '=' is returned as its own
|
|
102
|
+
* candidate, checked alongside the whole token. A non-path value ('--port=8080') simply fails
|
|
103
|
+
* looksLikePath() and is ignored; an inside value still resolves inside and passes.
|
|
104
|
+
* @param {string} arg
|
|
105
|
+
* @returns {string[]}
|
|
106
|
+
*/
|
|
107
|
+
function argPathCandidates(arg) {
|
|
108
|
+
if (!isNonEmptyString(arg)) return [];
|
|
109
|
+
const out = [arg];
|
|
110
|
+
const eq = arg.indexOf('=');
|
|
111
|
+
if (eq !== -1 && eq < arg.length - 1) out.push(arg.slice(eq + 1));
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Is this a win32 DRIVE-RELATIVE reference ('C:foo', drive letter + colon + no separator)?
|
|
117
|
+
* It resolves against the CHILD's per-drive current directory - a base the guard cannot pin -
|
|
118
|
+
* so it can never be PROVEN inside the root: path.resolve(root, 'C:x') judges it optimistically
|
|
119
|
+
* when root shares the drive. There is no legitimate in-sandbox use (an inside path can always
|
|
120
|
+
* be written absolutely or plainly relative), so it is refused outright wherever paths are
|
|
121
|
+
* guarded. Checked on every platform: a config is portable and gets run somewhere else.
|
|
122
|
+
* @param {string} t
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
function isDriveRelative(t) {
|
|
126
|
+
return /^[A-Za-z]:(?![\\/])/.test(t);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Environment variables that make a child load code, whatever its args say. Two classes with two rules. All platforms' vars are checked on all platforms because
|
|
130
|
+
// a config is portable and gets run somewhere else, and keys are compared CASE-INSENSITIVELY
|
|
131
|
+
// everywhere because Windows env lookup is case-insensitive - `node_options` reaches the child
|
|
132
|
+
// exactly as `NODE_OPTIONS` does, so an exact-case allowlist would be a one-character bypass there.
|
|
133
|
+
//
|
|
134
|
+
// OPTION-STRING vars are a mini command line in the runtime's OWN grammar: quoting, `=` forms,
|
|
135
|
+
// `file:`/`data:` URLs, inspector flags. A path-shaped guard cannot verify them (quoted
|
|
136
|
+
// values, URL specifiers and a bare `--inspect` all defeat any tokeniser written here),
|
|
137
|
+
// and a guard that cannot verify must refuse, not guess. Refused outright in funnel mode; the
|
|
138
|
+
// operator's deliberate opt-out is `allowCodeLoadingEnv: true` on the upstream entry (the
|
|
139
|
+
// legacyPin pattern - off by default, visible in config, warned when it excuses something).
|
|
140
|
+
const CODE_OPT_ENV = [
|
|
141
|
+
'NODE_OPTIONS',
|
|
142
|
+
'JAVA_TOOL_OPTIONS',
|
|
143
|
+
'_JAVA_OPTIONS',
|
|
144
|
+
'PERL5OPT',
|
|
145
|
+
'RUBYOPT',
|
|
146
|
+
];
|
|
147
|
+
// PATH / PATH-LIST vars name locations directly and ARE checkable: extract pieces, judge each
|
|
148
|
+
// against the root. This list is a FLOOR, not a boundary - runtimes keep minting code-loading
|
|
149
|
+
// vars, and the `command` slot is arbitrary by design (see SECURITY.md: the guard is a tripwire
|
|
150
|
+
// for out-of-root references in an auditable config home, not a sandbox against an untrusted
|
|
151
|
+
// pack author).
|
|
152
|
+
const CODE_PATH_ENV = [
|
|
153
|
+
'NODE_PATH',
|
|
154
|
+
'NODE_REPL_EXTERNAL_MODULE',
|
|
155
|
+
'LD_PRELOAD',
|
|
156
|
+
'LD_LIBRARY_PATH',
|
|
157
|
+
'LD_AUDIT',
|
|
158
|
+
'DYLD_INSERT_LIBRARIES',
|
|
159
|
+
'DYLD_LIBRARY_PATH',
|
|
160
|
+
'DYLD_FRAMEWORK_PATH',
|
|
161
|
+
'PYTHONPATH',
|
|
162
|
+
'PYTHONSTARTUP',
|
|
163
|
+
'PYTHONHOME',
|
|
164
|
+
'PYTHONEXECUTABLE',
|
|
165
|
+
'PERL5LIB',
|
|
166
|
+
'RUBYLIB',
|
|
167
|
+
'GEM_PATH',
|
|
168
|
+
'CLASSPATH',
|
|
169
|
+
'BASH_ENV',
|
|
170
|
+
'ENV',
|
|
171
|
+
'DOTNET_STARTUP_HOOKS',
|
|
172
|
+
'CORECLR_PROFILER_PATH',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Is `target` inside `root` (or equal to it)? Compares fully-resolved, normalised
|
|
177
|
+
* absolute paths. A relative `target` is resolved AGAINST root (so 'vendor/x/server.js'
|
|
178
|
+
* checks the in-sandbox location). Returns false for anything that escapes root via
|
|
179
|
+
* '..' or an absolute path elsewhere on disk.
|
|
180
|
+
* @param {string} root absolute sandbox root
|
|
181
|
+
* @param {string} target path token (absolute or relative)
|
|
182
|
+
* @returns {boolean}
|
|
183
|
+
*/
|
|
184
|
+
function isInside(root, target) {
|
|
185
|
+
const resolvedRoot = path.resolve(root);
|
|
186
|
+
// An absolute target is checked as-is; a relative one is resolved within the root so
|
|
187
|
+
// a vendored relative path ('vendor/foo/server.js') is judged at its in-sandbox spot.
|
|
188
|
+
const resolvedTarget = path.isAbsolute(target)
|
|
189
|
+
? path.resolve(target)
|
|
190
|
+
: path.resolve(resolvedRoot, target);
|
|
191
|
+
const rel = path.relative(resolvedRoot, resolvedTarget);
|
|
192
|
+
// Inside iff the relative path neither starts with '..' nor is itself absolute
|
|
193
|
+
// (an absolute `rel` happens on win32 across drive letters). '' (equal) counts as inside.
|
|
194
|
+
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The DEFAULT clientFactory. Constructs a McpClient for an upstream entry AFTER
|
|
199
|
+
* enforcing the isolation guard on the ARGS: every arg that looks like a file path
|
|
200
|
+
* (an absolute path, or any token containing a path separator) must resolve inside
|
|
201
|
+
* `gatewayRoot`. Throws a clear 'isolation: ...' error otherwise - connectAll catches that
|
|
202
|
+
* into failed[].
|
|
203
|
+
*
|
|
204
|
+
* The COMMAND slot is intentionally NOT guarded: it names the interpreter/executable
|
|
205
|
+
* (e.g. 'node', 'npx', or an absolute path to a node binary such as process.execPath /
|
|
206
|
+
* a system node.exe) which by definition lives OUTSIDE the project. Isolation is about
|
|
207
|
+
* WHAT CODE the spawned process runs - that is the args (the server script + any data
|
|
208
|
+
* files it loads) - not which interpreter binary launches it. Guarding the command too
|
|
209
|
+
* would wrongly reject every vendored upstream the moment it is run by the system node.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} upstream a normalised ExposeStore upstream { id, command, args, env, ... }
|
|
212
|
+
* @param {string} gatewayRoot the sandbox root the upstream's path-shaped ARGS must stay inside
|
|
213
|
+
* @returns {McpClient}
|
|
214
|
+
*/
|
|
215
|
+
// ── permanent connect failures ───────────────────────────────────────────────────────────────
|
|
216
|
+
//
|
|
217
|
+
// Every failed connect arms the background reconnect (1s,2s,4s,8s,16s, then a 30 s keepalive).
|
|
218
|
+
// That is right for a slow-booting server and wrong for one that can NEVER start: a PERMANENT
|
|
219
|
+
// failure runs the same ladder for PERMANENT_RETRY_LIMIT retries, then stops with one stderr line
|
|
220
|
+
// and is listed by stoppedUpstreams() (and /health) until a config reload builds a fresh aggregator
|
|
221
|
+
// or an operator reconnects it. Classified by the error CODE stamped where it is raised - never
|
|
222
|
+
// by message text:
|
|
223
|
+
// ENOENT the command does not exist (the client keeps the spawn error's code, and
|
|
224
|
+
// on Windows names a command the cmd.exe launch shim hid - commandMissing)
|
|
225
|
+
// TF_ISOLATION_REFUSED the isolation guard refused the config (deterministic for a given config)
|
|
226
|
+
// Not permanent: a modernOnly refusal - a slow modern server can miss the discover window and
|
|
227
|
+
// look legacy once, so it keeps retrying. Store validation needs no class here: a wrong-typed
|
|
228
|
+
// config is refused at load (fail-loud config) and never reaches a connect.
|
|
229
|
+
const ISOLATION_REFUSED = 'TF_ISOLATION_REFUSED';
|
|
230
|
+
const PERMANENT_ERROR_CODES = new Set(['ENOENT', ISOLATION_REFUSED]);
|
|
231
|
+
// Retries after the failed first attempt: 1+2+4+8+16+30 s, then 8 keepalives at 30 s = 301 s, so
|
|
232
|
+
// about five minutes and 15 attempts in all. Consecutive: a transient failure in between restarts
|
|
233
|
+
// the count (the environment is changing), and any successful connect clears it.
|
|
234
|
+
const PERMANENT_RETRY_LIMIT = 14;
|
|
235
|
+
// The stop line and stoppedUpstreams()[].error keep at most this many characters of the cause.
|
|
236
|
+
const STOPPED_ERROR_MAX = 400;
|
|
237
|
+
|
|
238
|
+
// ── tool schema validation ──────────────────────────────────────────────────────────────────────
|
|
239
|
+
//
|
|
240
|
+
// One broken tool descriptor must not take its upstream down: validate each listed tool BEFORE it
|
|
241
|
+
// is served and drop only that tool, loudly - degraded, not dead. It runs first at the listing
|
|
242
|
+
// chokepoint, ahead of descriptor approval, so a pathological schema (nesting deep enough to exhaust
|
|
243
|
+
// the stack of every recursive walk after it) is dropped as one tool instead of failing the approval
|
|
244
|
+
// hash for the whole upstream. Structural checks only (zero-dep; not a JSON Schema meta-validator).
|
|
245
|
+
// A MISSING (or null) field stays tolerated - a missing inputSchema is served as { type:"object" },
|
|
246
|
+
// as before; real servers omit it and it is harmless. Present-but-wrong is dropped.
|
|
247
|
+
const MAX_SCHEMA_DEPTH = 64;
|
|
248
|
+
|
|
249
|
+
/** Does `value` nest deeper than `limit` levels? ITERATIVE on purpose: a hostile schema must not be
|
|
250
|
+
* able to exhaust the stack of the very check meant to stop it. */
|
|
251
|
+
function nestingExceeds(value, limit) {
|
|
252
|
+
const stack = [[value, 1]];
|
|
253
|
+
while (stack.length) {
|
|
254
|
+
const [v, depth] = stack.pop();
|
|
255
|
+
if (v === null || typeof v !== 'object') continue;
|
|
256
|
+
if (depth > limit) return true;
|
|
257
|
+
for (const k of Object.keys(v)) stack.push([v[k], depth + 1]);
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @returns {string|null} the first structural defect of a listed tool, or null when it may be served. */
|
|
263
|
+
function toolSchemaProblem(t) {
|
|
264
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
265
|
+
const given = (v) => v !== undefined && v !== null;
|
|
266
|
+
if (given(t.description) && typeof t.description !== 'string') return '"description" is not a string';
|
|
267
|
+
if (given(t.title) && typeof t.title !== 'string') return '"title" is not a string';
|
|
268
|
+
if (given(t.annotations) && !isObj(t.annotations)) return '"annotations" is not an object';
|
|
269
|
+
for (const f of ['inputSchema', 'outputSchema']) {
|
|
270
|
+
const s = t[f];
|
|
271
|
+
if (!given(s)) continue;
|
|
272
|
+
if (!isObj(s)) return `${f} is not a JSON Schema object`;
|
|
273
|
+
if (s.type !== undefined && s.type !== 'object') return `${f} "type" must be "object", got ${String(JSON.stringify(s.type)).slice(0, 40)}`;
|
|
274
|
+
if (s.properties !== undefined && !isObj(s.properties)) return `${f} "properties" is not an object`;
|
|
275
|
+
if (s.required !== undefined && !(Array.isArray(s.required) && s.required.every((r) => typeof r === 'string'))) {
|
|
276
|
+
return `${f} "required" is not an array of strings`;
|
|
277
|
+
}
|
|
278
|
+
if (nestingExceeds(s, MAX_SCHEMA_DEPTH)) return `${f} is nested deeper than ${MAX_SCHEMA_DEPTH} levels`;
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isPermanentConnectError(err) {
|
|
284
|
+
return !!(err && typeof err.code === 'string' && PERMANENT_ERROR_CODES.has(err.code));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths, clientInfo, eraHint) {
|
|
288
|
+
const id = isNonEmptyString(upstream && upstream.id) ? upstream.id : '(unknown)';
|
|
289
|
+
const args = Array.isArray(upstream && upstream.args) ? upstream.args : [];
|
|
290
|
+
// The caller passes TRUE for the wrapped upstream; the upstream may also claim the exemption for
|
|
291
|
+
// itself in config. Either way it is decided HERE, per upstream, from THIS upstream's own entry -
|
|
292
|
+
// there is no shared or latched state, so one server's opt-out can never relax another's guard.
|
|
293
|
+
const outsideAllowed = allowOutsidePaths === true || (upstream && upstream.allowOutsidePaths === true);
|
|
294
|
+
const byConfig = allowOutsidePaths !== true && outsideAllowed;
|
|
295
|
+
|
|
296
|
+
// Guard every field that decides WHAT CODE the spawned process runs: path-shaped args, the cwd
|
|
297
|
+
// they resolve against, and the env vars that load code irrespective of args. The command
|
|
298
|
+
// (interpreter) is NOT guarded - see the function doc: it is the executable, not the code.
|
|
299
|
+
// allowOutsidePaths: TRUE only for the currently-WRAPPED upstream (transparent-wrapper mode -
|
|
300
|
+
// the operator explicitly made this server the entire surface, e.g. server-filesystem serving
|
|
301
|
+
// a documents folder). Warn instead of refuse; funnel-mode upstreams keep the hard guard.
|
|
302
|
+
// ONE notice per CLASS, not per upstream: `warnedPath` covers the path class (args + cwd); the
|
|
303
|
+
// env section below keeps its OWN flag. A single shared flag let the first path finding consume
|
|
304
|
+
// the only slot, so an env finding - the class that loads code - was excused in total silence,
|
|
305
|
+
// precisely for the operator who opted out and most needs the audit trail.
|
|
306
|
+
let warnedPath = false;
|
|
307
|
+
const refuse = (what) => {
|
|
308
|
+
if (outsideAllowed === true) {
|
|
309
|
+
if (!warnedPath) {
|
|
310
|
+
warnedPath = true;
|
|
311
|
+
// Name the REAL reason. Reporting a config opt-out as transparent-wrapper mode would put a
|
|
312
|
+
// false explanation in the operator's log for the one event they most need to trace.
|
|
313
|
+
process.stderr.write(byConfig
|
|
314
|
+
? `[toolfunnel] isolation: upstream "${id}" reaches outside the gateway root (${what}) - ` +
|
|
315
|
+
`permitted because its config sets allowOutsidePaths:true. This upstream is NOT isolated ` +
|
|
316
|
+
`to the config home; every tool it serves runs with that exemption. Remove the flag to ` +
|
|
317
|
+
`restore the guard, or gate the tools with a PreToolUse hook.\n`
|
|
318
|
+
: `[toolfunnel] isolation: WRAPPED upstream "${id}" reaches outside the gateway root ` +
|
|
319
|
+
`(${what}) - permitted in transparent-wrapper mode. To restrict what it can do post-wrap, see the ` +
|
|
320
|
+
`manual's "Wrapping & security" section (PreToolUse gate, per-tool enabled:false).\n`);
|
|
321
|
+
}
|
|
322
|
+
return false; // permitted under the wrap, or by this upstream's own opt-out
|
|
323
|
+
}
|
|
324
|
+
throw Object.assign(new Error(`isolation: upstream ${id} references a path outside the gateway root`), { code: ISOLATION_REFUSED });
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
for (const arg of args) {
|
|
328
|
+
// Check the whole token AND the value after a '=' (the --flag=value form the child splits) -
|
|
329
|
+
// either escaping the root refuses the arg. The notice still names the whole token.
|
|
330
|
+
const escapes = argPathCandidates(arg).some(
|
|
331
|
+
(cand) => looksLikePath(cand) && (isDriveRelative(cand) || !isInside(gatewayRoot, cand))
|
|
332
|
+
);
|
|
333
|
+
if (escapes) {
|
|
334
|
+
if (refuse(`arg "${arg}"`) === false) break; // one notice covers the path class
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// cwd - the SPAWN BASE. Guarded because it decides where a bare, separator-free arg like
|
|
339
|
+
// 'server.js' actually resolves: looksLikePath() is false for such a token so the loop above
|
|
340
|
+
// never sees it, while the child resolves it against cwd.
|
|
341
|
+
// Drive-relative gets the same outright refusal as everywhere else: 'C:denied' resolves
|
|
342
|
+
// against the child's per-drive current directory, and isInside() judging it against the root
|
|
343
|
+
// is optimistic-and-wrong whenever the root shares the drive.
|
|
344
|
+
if (isNonEmptyString(upstream && upstream.cwd)
|
|
345
|
+
&& (isDriveRelative(upstream.cwd) || !isInside(gatewayRoot, upstream.cwd))) {
|
|
346
|
+
refuse(`cwd "${upstream.cwd}"`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// env - CODE-LOADING variables. A value in any of these loads code
|
|
350
|
+
// into the child however clean the args are (reachable in-band: tf_mcp_add forwards env
|
|
351
|
+
// verbatim). Keys are matched CASE-INSENSITIVELY on every platform - Windows env lookup is
|
|
352
|
+
// case-insensitive, so `node_options` is `NODE_OPTIONS` to the child - and every platform's
|
|
353
|
+
// variable is checked on every platform: a pack authored on Linux gets run on Windows and back.
|
|
354
|
+
//
|
|
355
|
+
// `allowCodeLoadingEnv: true` on the upstream entry is the operator's explicit opt-out for the
|
|
356
|
+
// whole class: the finding downgrades to a once-per-upstream stderr warning naming the flag.
|
|
357
|
+
const envObj = upstream && typeof upstream.env === 'object' && upstream.env ? upstream.env : null;
|
|
358
|
+
if (envObj) {
|
|
359
|
+
const envAllowed = (upstream && upstream.allowCodeLoadingEnv === true);
|
|
360
|
+
// The env class's OWN notice flag - deliberately independent of `warnedPath` (see above).
|
|
361
|
+
let warnedEnv = false;
|
|
362
|
+
const envNotice = (why) => {
|
|
363
|
+
if (warnedEnv) return;
|
|
364
|
+
warnedEnv = true;
|
|
365
|
+
process.stderr.write(`[toolfunnel] isolation: upstream "${id}" sets code-loading env ${why}`);
|
|
366
|
+
};
|
|
367
|
+
// Which flag excuses an env finding depends on its KIND:
|
|
368
|
+
// 'opt' - option-string vars (NODE_OPTIONS et al.): not path-checkable, so ONLY the class's
|
|
369
|
+
// own flag (allowCodeLoadingEnv) or the WRAP excuses them. allowOutsidePaths does
|
|
370
|
+
// NOT: it is documented (expose-store) as args/cwd/path-list scope, and letting the
|
|
371
|
+
// path flag silently disarm this separately-audited class was a cross-flag grant
|
|
372
|
+
// the operator never made.
|
|
373
|
+
// 'path' - path-list vars (NODE_PATH et al.): inside the path flag's documented scope, so
|
|
374
|
+
// wrap, allowCodeLoadingEnv or allowOutsidePaths excuses - each loudly, by name.
|
|
375
|
+
const refuseEnv = (what, kind) => {
|
|
376
|
+
if (envAllowed) {
|
|
377
|
+
envNotice(`(${what}) - permitted by allowCodeLoadingEnv:true on this upstream. Remove the ` +
|
|
378
|
+
'flag to restore the refusal.\n');
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
if (allowOutsidePaths === true) {
|
|
382
|
+
envNotice(`(${what}) - permitted in transparent-wrapper mode (this upstream IS the ` +
|
|
383
|
+
'surface). To restrict it post-wrap, see the manual\'s "Wrapping & security" section.\n');
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
if (kind === 'path' && outsideAllowed === true) {
|
|
387
|
+
envNotice(`(${what}) - permitted because its config sets allowOutsidePaths:true ` +
|
|
388
|
+
'(path-list env is within that flag\'s scope). Remove the flag to restore the guard.\n');
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
throw Object.assign(new Error(`isolation: upstream ${id} sets code-loading env (${what})`), { code: ISOLATION_REFUSED });
|
|
392
|
+
};
|
|
393
|
+
// Enumerate EXACTLY as child_process does: `for...in`, which walks own AND INHERITED keys
|
|
394
|
+
// (node's normalizeSpawnArguments: "Prototype values are intentionally included"). An
|
|
395
|
+
// Object.keys() walk was blind to a `__proto__` carrier - JSON.parse makes `__proto__` an OWN
|
|
396
|
+
// data property, spread preserves it, and the client's Object.assign merge then uses [[Set]],
|
|
397
|
+
// firing the Object.prototype.__proto__ setter and installing the payload as the merged env's
|
|
398
|
+
// PROTOTYPE: invisible to Object.keys, delivered to the child.
|
|
399
|
+
for (const key in envObj) {
|
|
400
|
+
const upper = String(key).toUpperCase();
|
|
401
|
+
// A `__proto__` key is refused OUTRIGHT, whatever it carries. It is not an env var: it is
|
|
402
|
+
// the carrier that turns into the merged env's PROTOTYPE at the client's Object.assign, and
|
|
403
|
+
// from there into real variables for the child (for..in). Nothing legitimate needs it.
|
|
404
|
+
if (key === '__proto__') {
|
|
405
|
+
refuseEnv('env "__proto__" (a prototype carrier, not an environment variable)', 'opt');
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// Judge the value as the spawn will USE it, not as it was authored. node builds each pair as
|
|
409
|
+
// `${key}=${value}`, so a one-element array becomes its element verbatim and a number becomes
|
|
410
|
+
// its digits. Testing isNonEmptyString FIRST let every non-string value skip both lists -
|
|
411
|
+
// again the guard reading a different representation than the consumer. A value that cannot
|
|
412
|
+
// even be coerced is unverifiable in a code-loading slot, so it is refused rather than
|
|
413
|
+
// skipped (the same rule the option-string class already follows).
|
|
414
|
+
const rawValue = envObj[key];
|
|
415
|
+
if (rawValue === undefined || rawValue === null) continue;
|
|
416
|
+
let raw;
|
|
417
|
+
try {
|
|
418
|
+
raw = typeof rawValue === 'string' ? rawValue : String(rawValue);
|
|
419
|
+
} catch (_e) {
|
|
420
|
+
if (CODE_OPT_ENV.includes(upper) || CODE_PATH_ENV.includes(upper)) {
|
|
421
|
+
refuseEnv(`env ${key} (value cannot be read as a string, so it cannot be verified)`,
|
|
422
|
+
CODE_OPT_ENV.includes(upper) ? 'opt' : 'path');
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (raw.length === 0) continue;
|
|
427
|
+
if (CODE_OPT_ENV.includes(upper)) {
|
|
428
|
+
// Not path-checkable (see the class doc above the lists) - the VALUE is irrelevant.
|
|
429
|
+
if (refuseEnv(`env ${key} (option-string vars are not path-checkable)`, 'opt') === false) continue;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (!CODE_PATH_ENV.includes(upper)) continue;
|
|
433
|
+
// Path / path-list value: pieces are EXTRACTED (not split) so a drive-lettered piece
|
|
434
|
+
// ('C:\x', 'C:x') survives whole on every platform - splitting 'C:relative\denied' on ':'
|
|
435
|
+
// yields fragments that resolve inside the root while the child resolves the real path
|
|
436
|
+
// outside it. Both ';' and ':' end a piece everywhere (a POSIX filename containing ';'
|
|
437
|
+
// may be shredded and falsely refused - conservative, never a false allow).
|
|
438
|
+
const parts = (raw.match(/[A-Za-z]:[^;:]*|[^;:]+/g) || []).filter((t) => t.length > 0);
|
|
439
|
+
if (parts.length === 0) continue;
|
|
440
|
+
if (parts.some((t) => isDriveRelative(t) || !isInside(gatewayRoot, t))) {
|
|
441
|
+
refuseEnv(`env ${key}="${raw}"`, 'path');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return new McpClient({
|
|
447
|
+
id: upstream.id,
|
|
448
|
+
command: upstream.command,
|
|
449
|
+
args: upstream.args,
|
|
450
|
+
env: upstream.env,
|
|
451
|
+
// Child cwd from config - the wrap era-probe reads it too; it never survived the store
|
|
452
|
+
// before. Default = the CONFIG HOME (gatewayRoot), NOT the gateway's process cwd:
|
|
453
|
+
// the isolation guard above resolves relative args against the home, so the spawn must
|
|
454
|
+
// resolve them against the SAME base or a guard-passing relative path fails to spawn
|
|
455
|
+
// whenever --config-dir differs from the launch directory (guard-base and spawn-base agree
|
|
456
|
+
// by construction).
|
|
457
|
+
cwd: isNonEmptyString(upstream && upstream.cwd) ? path.resolve(gatewayRoot, upstream.cwd) : gatewayRoot,
|
|
458
|
+
// Per-upstream `timeoutMs` config -> the client's PAYLOAD window (tools/call | prompts/get |
|
|
459
|
+
// resources/read; default 120 s).
|
|
460
|
+
toolTimeoutMs: Number.isFinite(upstream && upstream.timeoutMs) && upstream.timeoutMs > 0
|
|
461
|
+
? upstream.timeoutMs : undefined,
|
|
462
|
+
// Per-upstream `requestTimeoutMs` config -> the CONTROL window (handshake/list/discover/
|
|
463
|
+
// ping; default 10 s = the dead-upstream detector). Opt-in for slow-boot servers that
|
|
464
|
+
// cannot answer initialize inside 10 s - without it they can never be attached at all.
|
|
465
|
+
requestTimeoutMs: Number.isFinite(upstream && upstream.requestTimeoutMs) && upstream.requestTimeoutMs > 0
|
|
466
|
+
? upstream.requestTimeoutMs : undefined,
|
|
467
|
+
// legacyPin FORCES the legacy era: skip the modern server/discover probe and go straight to
|
|
468
|
+
// the initialize handshake, so a pinned upstream is genuinely spoken to as 2024-11-05 (the pin
|
|
469
|
+
// is an ENFORCED policy, not just a warning). Off by default.
|
|
470
|
+
forceLegacy: !!(upstream && upstream.legacyPin === true),
|
|
471
|
+
// modernOnly (legacyPin's mirror): refuse the legacy fallback - a non-modern upstream fails
|
|
472
|
+
// the connect with a clear error instead of negotiating down. Store validation refuses the
|
|
473
|
+
// legacyPin+modernOnly contradiction before it ever reaches here.
|
|
474
|
+
modernOnly: !!(upstream && upstream.modernOnly === true),
|
|
475
|
+
// The aggregator's era memo (last successful connect): 'legacy' lets a reconnect skip the
|
|
476
|
+
// server/discover probe. The client ignores it under modernOnly; the aggregator clears it on
|
|
477
|
+
// a failed connect and on an explicit reconnect().
|
|
478
|
+
eraHint: eraHint === 'legacy' ? 'legacy' : undefined,
|
|
479
|
+
// clientInfo: the identity presented to this upstream (identity mirroring / configured
|
|
480
|
+
// identity). null/undefined -> McpClient's built-in default, wire behaviour unchanged.
|
|
481
|
+
clientInfo: clientInfo || undefined,
|
|
482
|
+
onClose, // unexpected death -> the aggregator schedules a background reconnect
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Aggregator - owns the live upstream connections and the curated-expose computation.
|
|
488
|
+
*
|
|
489
|
+
* Lifecycle: `new Aggregator({ store, gatewayRoot, clientFactory })`
|
|
490
|
+
* -> `await connectAll()` (or `await discover(id)` lazily)
|
|
491
|
+
* -> `exposedToolDefinitions()` / `resolveExposedExecution(name, args)`
|
|
492
|
+
* -> `await closeAll()` (idempotent).
|
|
493
|
+
*/
|
|
494
|
+
class Aggregator {
|
|
495
|
+
/**
|
|
496
|
+
* @param {object} opts
|
|
497
|
+
* @param {object} opts.store an ExposeStore (the on-disk MCP config).
|
|
498
|
+
* @param {string} [opts.gatewayRoot] sandbox root for the isolation guard (default: the gateway root).
|
|
499
|
+
* @param {Function} [opts.clientFactory] (upstreamEntry, gatewayRoot) -> McpClient. Default enforces isolation.
|
|
500
|
+
* @param {Function} [opts.wrapTargetProvider] () -> the currently-WRAPPED upstream id (or null).
|
|
501
|
+
* Read fresh at each connect: the wrapped upstream is EXEMPT from the path-isolation
|
|
502
|
+
* guard (by design - a wrap is an explicit "this server IS my whole
|
|
503
|
+
* surface" declaration, so ToolFunnel is a transparent wrapper in that incidence; the
|
|
504
|
+
* operator gets a strong warning + post-wrap restriction docs instead of a refusal).
|
|
505
|
+
* Funnel-mode upstreams keep the guard unchanged.
|
|
506
|
+
* @param {Function} [opts.clientInfoProvider] (upstreamId) -> {name, version}|null. The identity
|
|
507
|
+
* to PRESENT to that upstream. Read fresh at each connect (same pattern as the wrap-target
|
|
508
|
+
* provider) so a mirror captured after boot is honoured by the next (re)connect. null ->
|
|
509
|
+
* McpClient's built-in default.
|
|
510
|
+
*/
|
|
511
|
+
constructor({ store, gatewayRoot, clientFactory, onToolsChanged, wrapTargetProvider, clientInfoProvider } = {}) {
|
|
512
|
+
if (!store || typeof store !== 'object') {
|
|
513
|
+
throw new Error('Aggregator: a `store` (ExposeStore) is required');
|
|
514
|
+
}
|
|
515
|
+
this._store = store;
|
|
516
|
+
this._gatewayRoot = isNonEmptyString(gatewayRoot) ? gatewayRoot : DEFAULT_ROOT;
|
|
517
|
+
// Tool descriptor approval (tool-approvals.js). _tools holds what may be SERVED
|
|
518
|
+
// (approved descriptors only); _rawTools the upstream's own listing, so an accept re-applies
|
|
519
|
+
// without a re-list; _pending the changes that make a call refuse.
|
|
520
|
+
this._approvalsPath = path.join(this._gatewayRoot, 'mcp', 'tool-approvals.json');
|
|
521
|
+
/** @type {Map<string, Set<string>>} pending entries ("tool@hash") THIS process wrote, per upstream */
|
|
522
|
+
this._ownPending = new Map();
|
|
523
|
+
this._writer = { pid: process.pid, id: crypto.randomBytes(6).toString('hex') };
|
|
524
|
+
LIVE_WRITERS.add(this._writer.id);
|
|
525
|
+
/** @type {Map<string, object>} a record whose save FAILED, per upstream: evaluated against until a
|
|
526
|
+
* save succeeds, so a file that cannot be written never turns each re-list into a first use */
|
|
527
|
+
this._unsaved = new Map();
|
|
528
|
+
/** @type {Set<string>} upstreams whose tools are WITHHELD (approvals unreadable or unhashable): a
|
|
529
|
+
* call resolved earlier must refuse, not find an empty pending map and run */
|
|
530
|
+
this._withheld = new Set();
|
|
531
|
+
/** @type {Map<string, Array>} upstreamId -> the RAW tools/list result */
|
|
532
|
+
this._rawTools = new Map();
|
|
533
|
+
/** @type {Map<string, Map<string, object>>} upstreamId -> toolName -> pending entry */
|
|
534
|
+
this._pending = new Map();
|
|
535
|
+
/** @type {Array<object>} recent upgrade / server-changed events for /health (bounded) */
|
|
536
|
+
this._approvalEvents = [];
|
|
537
|
+
/** @type {Set<string>} once-per-condition stderr notices already written */
|
|
538
|
+
this._approvalNoticed = new Set();
|
|
539
|
+
/** @type {Map<string, Array<{tool:string, problem:string}>>} upstreamId -> tools dropped by schema validation */
|
|
540
|
+
this._invalid = new Map();
|
|
541
|
+
// Progress scoping: gateway token -> { upstreamId, originalToken, sink }. The
|
|
542
|
+
// random prefix means a caller's own token can never be mistaken for a gateway one.
|
|
543
|
+
this._progressRoutes = new Map();
|
|
544
|
+
this._progressPrefix = 'tfp-' + crypto.randomBytes(4).toString('hex') + '-';
|
|
545
|
+
this._progressSeq = 0;
|
|
546
|
+
// Called when the LIVE tool set changes OUT OF BAND (a background reconnect recovers or finally
|
|
547
|
+
// loses an upstream) so the transport can emit notifications/tools/list_changed. Default no-op;
|
|
548
|
+
// the server wires it to emitToolsListChanged(send). Settable so reloadExpose can re-wire the
|
|
549
|
+
// new instance.
|
|
550
|
+
this.onToolsChanged = typeof onToolsChanged === 'function' ? onToolsChanged : () => {};
|
|
551
|
+
// Called with (upstreamId, notification) for each server-initiated CHANGE notification from an
|
|
552
|
+
// upstream (resources/updated, resources/list_changed, prompts/list_changed, tools/list_changed)
|
|
553
|
+
// so the transport can bridge it to a modern client's subscriptions/listen stream. Default no-op;
|
|
554
|
+
// the server/http host wires it. Settable so reloadExpose can re-wire the new instance.
|
|
555
|
+
this.onUpstreamNotification = () => {};
|
|
556
|
+
// Called with (upstreamId, requestMsg, client) for each SERVER-INITIATED REQUEST from an
|
|
557
|
+
// upstream (elicitation/create, sampling/createMessage, roots/list - the elicitation bridge). The handler
|
|
558
|
+
// owns answering via client.respondToServer(Error). Default: -32601, so an unwired build
|
|
559
|
+
// never leaves an upstream holding an open request. Settable; reloadExpose carries it over.
|
|
560
|
+
this.onUpstreamServerRequest = (uid, msg, client) => {
|
|
561
|
+
try { client.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method); } catch (_e) { /* never throw */ }
|
|
562
|
+
};
|
|
563
|
+
// When a passthrough WRAP is active, the wrapped upstream's request-scoped chatter
|
|
564
|
+
// (notifications/message, notifications/progress) also bridges - connected directly, the
|
|
565
|
+
// client would receive it. The server keeps this current with the live wrap state.
|
|
566
|
+
this.wrapChatterUpstream = null;
|
|
567
|
+
this._wrapTargetProvider = typeof wrapTargetProvider === 'function' ? wrapTargetProvider : null;
|
|
568
|
+
this._clientInfoProvider = typeof clientInfoProvider === 'function' ? clientInfoProvider : null;
|
|
569
|
+
// The factory receives (upstream, gatewayRoot, onClose). A caller-supplied factory may ignore the
|
|
570
|
+
// extra args; the default uses gatewayRoot for isolation and onClose for death-driven reconnect.
|
|
571
|
+
// The wrap-target check runs PER CONNECT (fresh provider read), so a wrap set after boot is
|
|
572
|
+
// honoured by the next connect attempt.
|
|
573
|
+
this._clientFactory =
|
|
574
|
+
typeof clientFactory === 'function'
|
|
575
|
+
? (upstream, onClose) => clientFactory(upstream, this._gatewayRoot, onClose)
|
|
576
|
+
: (upstream, onClose) => defaultClientFactory(upstream, this._gatewayRoot, onClose,
|
|
577
|
+
this._isWrapTarget(upstream && upstream.id),
|
|
578
|
+
this._readClientInfo(upstream && upstream.id),
|
|
579
|
+
this._eraMemo.get(upstream && upstream.id));
|
|
580
|
+
|
|
581
|
+
/** @type {Map<string, object>} upstreamId -> connected McpClient */
|
|
582
|
+
this._clients = new Map();
|
|
583
|
+
/** @type {Map<string, Array>} upstreamId -> discovered tool defs [{name,description,inputSchema}] */
|
|
584
|
+
this._tools = new Map();
|
|
585
|
+
/** @type {Map<string, Promise>} upstreamId -> in-flight connect promise (race guard for ensureConnected) */
|
|
586
|
+
this._connecting = new Map();
|
|
587
|
+
/** @type {Set<string>} agreed resource-subscription URIs - replayed onto every (re)connect
|
|
588
|
+
* of a subscribe-capable legacy upstream so the channel survives reconnects */
|
|
589
|
+
this._subscribedUris = new Set();
|
|
590
|
+
/** @type {Map<string, number>} uri -> LISTEN-owned refcount. subscribeResources
|
|
591
|
+
* increments; releaseResources decrements and unsubscribes upstream at zero. The wrap-forward
|
|
592
|
+
* recording (a client's own resources/subscribe) writes the Set directly and is NOT counted
|
|
593
|
+
* here - the client manages that lifetime itself. */
|
|
594
|
+
this._subscribedRefs = new Map();
|
|
595
|
+
/** @type {Map<string, any>} upstreamId -> pending background-reconnect timer (one per upstream) */
|
|
596
|
+
this._reconnectTimers = new Map();
|
|
597
|
+
/** @type {Set<string>} upstreams whose reconnect ATTEMPT is currently executing (between the timer
|
|
598
|
+
* firing - which deletes its own entry from _reconnectTimers - and the attempt settling). While an
|
|
599
|
+
* id is in here, _handleUpstreamDown must NOT seed a fresh attempt-0: the in-flight attempt's own
|
|
600
|
+
* catch owns the next backoff step. Without this, a death DURING an attempt's tools/list re-seeds
|
|
601
|
+
* the 1s floor and the one-timer guard then swallows the escalation - a permanent 1s respawn loop. */
|
|
602
|
+
this._reconnecting = new Set();
|
|
603
|
+
/** @type {Map<string, number>} upstreamId -> CONSECUTIVE permanent-class connect failures
|
|
604
|
+
* (PERMANENT_ERROR_CODES). Cleared by a transient failure or any successful connect. */
|
|
605
|
+
this._permanentFailures = new Map();
|
|
606
|
+
/** @type {Map<string, {error:string, attempts:number}>} upstreams that hit
|
|
607
|
+
* PERMANENT_RETRY_LIMIT and stopped retrying - stoppedUpstreams() / /health. */
|
|
608
|
+
this._stopped = new Map();
|
|
609
|
+
/** @type {Map<string, 'modern'|'legacy'>} upstreamId -> era negotiated at the LAST successful
|
|
610
|
+
* connect. A death-driven reconnect passes it to the client as eraHint so a legacy upstream
|
|
611
|
+
* skips the server/discover probe (worst case ~3s against a server that silently drops unknown
|
|
612
|
+
* methods) instead of re-paying it on every respawn. Lifecycle: SET on a successful connect,
|
|
613
|
+
* CLEARED when a connect fails (the hinted attempt may be why - the next retry re-negotiates
|
|
614
|
+
* in full, so an upstream upgraded across a restart self-heals) and by an explicit reconnect()
|
|
615
|
+
* (a manual kick deliberately re-negotiates). A config reload swaps in a fresh Aggregator, so
|
|
616
|
+
* a changed entry never inherits a stale memo. */
|
|
617
|
+
this._eraMemo = new Map();
|
|
618
|
+
/** Set true by closeAll(). A torn-down aggregator (e.g. swapped out by reloadExpose) must NEVER
|
|
619
|
+
* (re)connect - a captured execute thunk holding this instance would otherwise spawn an orphan
|
|
620
|
+
* upstream child on it that nothing ever closes. ensureConnected/connectAll check this. */
|
|
621
|
+
this._closed = false;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Connect every ENABLED upstream in the store, caching each connected client and its
|
|
626
|
+
* discovered tools. NEVER throws - a per-upstream failure (isolation guard, spawn
|
|
627
|
+
* failure, handshake timeout, listTools failure) is collected into failed[]. An
|
|
628
|
+
* upstream already connected (cached) is treated as connected without reconnecting.
|
|
629
|
+
*
|
|
630
|
+
* A startup failure is not a one-off failed[] entry: every failed upstream is handed to
|
|
631
|
+
* _scheduleReconnect - exponential 1s..16s, then the 30s keepalive, the same treatment a
|
|
632
|
+
* mid-session death gets; reconnect_slow is logged once when the slow phase begins, and
|
|
633
|
+
* failed[] still reports the startup outcome to the caller. A PERMANENT failure (missing
|
|
634
|
+
* command, isolation refusal) counts toward PERMANENT_RETRY_LIMIT and then stops.
|
|
635
|
+
*
|
|
636
|
+
* @returns {Promise<{ connected: string[], failed: Array<{id:string, error:string}> }>}
|
|
637
|
+
*/
|
|
638
|
+
async connectAll() {
|
|
639
|
+
const connected = [];
|
|
640
|
+
const failed = [];
|
|
641
|
+
if (this._closed) return { connected, failed }; // a closed (torn-down) aggregator never connects
|
|
642
|
+
|
|
643
|
+
let upstreams;
|
|
644
|
+
try {
|
|
645
|
+
upstreams = this._store.listUpstreams();
|
|
646
|
+
} catch (err) {
|
|
647
|
+
// listUpstreams() shouldn't throw (it clones), but never let it sink connectAll.
|
|
648
|
+
return { connected, failed: [{ id: '(store)', error: errMsg(err) }] };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// CONCURRENT connect. Per-upstream cost is dominated by process boot, remote round-trips and
|
|
652
|
+
// the dual-era probe window, so connecting SEQUENTIALLY made startup the SUM of every
|
|
653
|
+
// upstream's latency; started together it is the SLOWEST one instead. Upstreams are
|
|
654
|
+
// independent: _connectOne touches only this upstream's own entry in _clients/_tools/_eraMemo,
|
|
655
|
+
// and the store rejects duplicate ids at load, so there is no cross-upstream state to race on.
|
|
656
|
+
// Each promise carries its own cleanup, and allSettled means ONE upstream's failure can never
|
|
657
|
+
// reject the batch - the NEVER-THROWS contract above still holds.
|
|
658
|
+
const tasks = [];
|
|
659
|
+
for (const upstream of upstreams) {
|
|
660
|
+
if (!upstream || upstream.enabled !== true) continue; // only ENABLED upstreams
|
|
661
|
+
|
|
662
|
+
const id = upstream.id;
|
|
663
|
+
if (this._clients.has(id)) {
|
|
664
|
+
// Already connected this session - keep it; don't double-spawn.
|
|
665
|
+
tasks.push({ id, cached: true, promise: null });
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Discard INSIDE the catch (not in the result loop below) so a half-built client is reaped
|
|
670
|
+
// the moment its connect fails, instead of lingering for the rest of the batch. The rethrow
|
|
671
|
+
// hands the original error to allSettled; a throwing _discard must never mask it.
|
|
672
|
+
//
|
|
673
|
+
// Registered in _connecting for the life of the task, exactly like ensureConnected's own
|
|
674
|
+
// connect: _connectOne caches the client BEFORE its handshake completes, so a concurrent
|
|
675
|
+
// ensureConnected(id) that inspected the cache instead of joining would read it as stale,
|
|
676
|
+
// discard it mid-handshake and connect a second time. Joiners get the client (same shape as
|
|
677
|
+
// ensureConnected's promise); the entry clears on settle either way.
|
|
678
|
+
const promise = (async () => {
|
|
679
|
+
await this._connectOne(upstream);
|
|
680
|
+
return this._clients.get(id);
|
|
681
|
+
})().catch((err) => {
|
|
682
|
+
try { this._discard(id); } catch (_e) { /* cleanup is best-effort; report the real error */ }
|
|
683
|
+
throw err;
|
|
684
|
+
}).finally(() => { this._connecting.delete(id); });
|
|
685
|
+
this._connecting.set(id, promise);
|
|
686
|
+
tasks.push({ id, cached: false, promise });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const settled = await Promise.allSettled(tasks.map((t) => t.promise));
|
|
690
|
+
|
|
691
|
+
// Walk the tasks in STORE order so connected[]/failed[] stay stable and every enabled
|
|
692
|
+
// upstream lands in EXACTLY ONE of them - a settled promise is fulfilled or rejected, so
|
|
693
|
+
// no upstream can fall through this loop unreported.
|
|
694
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
695
|
+
const { id, cached } = tasks[i];
|
|
696
|
+
if (cached || settled[i].status === 'fulfilled') {
|
|
697
|
+
connected.push(id);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const error = errMsg(settled[i].reason);
|
|
701
|
+
logger.log({ type: 'mcp', event: 'connect_failed', upstream: id, error });
|
|
702
|
+
failed.push({ id, error });
|
|
703
|
+
if (this._countConnectFailure(id, settled[i].reason)) continue; // (a limit of 0 would stop here)
|
|
704
|
+
// A STARTUP failure is usually transient - a slow-booting upstream that missed its
|
|
705
|
+
// handshake window while eleven siblings were spawning next to it. Arm the same
|
|
706
|
+
// self-healing backoff a mid-session death gets: without it a startup failure stranded
|
|
707
|
+
// the upstream with no client, no in-flight connect and no timer for the WHOLE session,
|
|
708
|
+
// silently costing the agent that upstream's tools. The retry runs off-chain (unref'd
|
|
709
|
+
// timer) and signals tools/list_changed when the upstream comes back.
|
|
710
|
+
this._scheduleReconnect(id, 0);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return { connected, failed };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Discover one upstream's tool surface. Connects the upstream if it isn't already
|
|
718
|
+
* (lazily, honouring the isolation guard), runs tools/list, caches and returns the
|
|
719
|
+
* raw tool definitions [{name, description, inputSchema}]. This is what the MCP
|
|
720
|
+
* Manager 'discover' button calls.
|
|
721
|
+
*
|
|
722
|
+
* Unlike connectAll this MAY reject (it is a direct user action, so the UI wants the
|
|
723
|
+
* error) - but it cleans up a failed connection so no zombie child is left.
|
|
724
|
+
*
|
|
725
|
+
* @param {string} upstreamId
|
|
726
|
+
* @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
|
|
727
|
+
*/
|
|
728
|
+
async discover(upstreamId) {
|
|
729
|
+
if (!isNonEmptyString(upstreamId)) {
|
|
730
|
+
throw new Error('Aggregator.discover: upstreamId (non-empty string) is required');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Route through ensureConnected: it JOINS an in-flight connect via _connecting instead of
|
|
734
|
+
// racing it, so two concurrent discover() calls cannot double-spawn the child and leak the
|
|
735
|
+
// loser. ensureConnected also reaps its own failed connects.
|
|
736
|
+
// allowDisabled: discover is the ONE caller permitted to connect a disabled upstream, so a
|
|
737
|
+
// disabled upstream's tools can be inspected before enabling it.
|
|
738
|
+
try {
|
|
739
|
+
await this.ensureConnected(upstreamId, { allowDisabled: true });
|
|
740
|
+
} catch (err) {
|
|
741
|
+
throw new Error(`Aggregator.discover: cannot connect "${upstreamId}": ${errMsg(err)}`);
|
|
742
|
+
}
|
|
743
|
+
// Re-list even when the fast path returned a cached client - the discover button must always
|
|
744
|
+
// reflect live state (a fresh connect lists twice; cheap, and the semantics stay honest).
|
|
745
|
+
return await this._listAndCache(upstreamId);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* @returns {Object<string, Array>} a clone map of upstreamId -> cached discovered tools.
|
|
750
|
+
* Only includes upstreams that have been connected/discovered this session.
|
|
751
|
+
*/
|
|
752
|
+
toolsByUpstream() {
|
|
753
|
+
const out = {};
|
|
754
|
+
for (const [id, tools] of this._tools) {
|
|
755
|
+
out[id] = cloneTools(tools);
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The CURATED-DIRECT tool definitions to advertise downstream. For each ENABLED
|
|
762
|
+
* expose[] entry whose upstream is CONNECTED, ENABLED and ACTUALLY advertises the named tool:
|
|
763
|
+
* - name = store.exposedName(entry) (the downstream `as` name)
|
|
764
|
+
* - description = '[' + upstream + '] ' + (upstream tool description || 'tool')
|
|
765
|
+
* - inputSchema = the upstream tool's inputSchema || { type:'object' }
|
|
766
|
+
*
|
|
767
|
+
* Entries whose upstream isn't connected, is explicitly disabled (the row's own flag
|
|
768
|
+
* cannot override the upstream's - a discover-cached client doesn't re-admit it), or
|
|
769
|
+
* whose named tool the upstream doesn't advertise, are silently skipped.
|
|
770
|
+
*
|
|
771
|
+
* @returns {Array<{name:string, description:string, inputSchema:object}>}
|
|
772
|
+
*/
|
|
773
|
+
exposedToolDefinitions() {
|
|
774
|
+
const defs = [];
|
|
775
|
+
let exposed;
|
|
776
|
+
try {
|
|
777
|
+
exposed = this._store.listExposed({ enabledOnly: true });
|
|
778
|
+
} catch (_err) {
|
|
779
|
+
return defs; // never throw out of a definition build
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
for (const e of exposed) {
|
|
783
|
+
if (!e || !this._clients.has(e.upstream)) continue; // upstream not connected
|
|
784
|
+
const upstream = this._store.getUpstream(e.upstream);
|
|
785
|
+
if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
|
|
786
|
+
const upstreamTools = this._tools.get(e.upstream) || [];
|
|
787
|
+
const upstreamTool = upstreamTools.find((t) => t && t.name === e.tool);
|
|
788
|
+
if (!upstreamTool) continue; // upstream doesn't actually advertise this tool
|
|
789
|
+
|
|
790
|
+
const name = this._store.exposedName(e);
|
|
791
|
+
const desc =
|
|
792
|
+
isNonEmptyString(upstreamTool.description) ? upstreamTool.description : 'tool';
|
|
793
|
+
const inputSchema =
|
|
794
|
+
upstreamTool.inputSchema && typeof upstreamTool.inputSchema === 'object'
|
|
795
|
+
? upstreamTool.inputSchema
|
|
796
|
+
: { type: 'object' };
|
|
797
|
+
|
|
798
|
+
defs.push(withForwardedToolMeta({
|
|
799
|
+
name,
|
|
800
|
+
description: `[${e.upstream}] ${desc}`,
|
|
801
|
+
inputSchema: sanitiseXMcpHeaders(cloneJson(inputSchema)),
|
|
802
|
+
}, upstreamTool));
|
|
803
|
+
}
|
|
804
|
+
return defs;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The upstream ids in STORE order (expose.json's own sequence) - the stable advertisement
|
|
809
|
+
* order handleToolsList's hot-upstream block iterates by. Connection-map order
|
|
810
|
+
* shifts on death/recovery re-insertion; the store's order never does. NEVER throws.
|
|
811
|
+
* @returns {string[]}
|
|
812
|
+
*/
|
|
813
|
+
upstreamOrder() {
|
|
814
|
+
try {
|
|
815
|
+
return this._store.listUpstreams().map((u) => u && u.id).filter(Boolean);
|
|
816
|
+
} catch (_e) {
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Is `name` a downstream `as` name currently exposed (enabled) AND backed by a
|
|
823
|
+
* connected upstream that advertises the underlying tool? Mirrors the set
|
|
824
|
+
* exposedToolDefinitions() advertises.
|
|
825
|
+
* @param {string} name
|
|
826
|
+
* @returns {boolean}
|
|
827
|
+
*/
|
|
828
|
+
isExposed(name) {
|
|
829
|
+
return this._findExposedExecution(name) !== null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Resolve a downstream `as` name + args into an executor. Returns
|
|
834
|
+
* { execute, toolName, upstream } | null
|
|
835
|
+
* where:
|
|
836
|
+
* - execute() invokes the connected client's callTool(<real upstream tool name>, args)
|
|
837
|
+
* - toolName = the downstream `as` (what PreToolUse matchers see - the gate matches
|
|
838
|
+
* the downstream name, execution hits the real upstream tool)
|
|
839
|
+
* - upstream = the upstream id
|
|
840
|
+
* Returns null if `name` is not exposed/enabled or its upstream isn't connected.
|
|
841
|
+
*
|
|
842
|
+
* @param {string} name downstream `as` name
|
|
843
|
+
* @param {any} args structured arguments forwarded verbatim to callTool
|
|
844
|
+
* @returns {{ execute: Function, toolName: string, upstream: string } | null}
|
|
845
|
+
*/
|
|
846
|
+
resolveExposedExecution(name, args, meta, progress) {
|
|
847
|
+
const hit = this._findExposedExecution(name);
|
|
848
|
+
if (!hit) return null;
|
|
849
|
+
const { client, realTool, upstream, exposedName } = hit;
|
|
850
|
+
return {
|
|
851
|
+
// The execute thunk is the seam the server calls AFTER the PreToolUse gate allows it.
|
|
852
|
+
execute: this._forwardExecutor(client, realTool, args, meta, upstream, progress),
|
|
853
|
+
toolName: exposedName, // downstream name - the gate's matcher target
|
|
854
|
+
upstream,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── lean register forwarding ──────────────────────────────────────
|
|
859
|
+
// The LEAN path surfaces an attached upstream's tools through the 4 meta-tools (toolfunnel_list_tools
|
|
860
|
+
// / _tool_instructions / _run_tool) instead of injecting them top-level every turn. The full
|
|
861
|
+
// discovered set of every CONNECTED + enabled upstream is surfaced (attaching an MCP makes its
|
|
862
|
+
// tools appear with no per-tool curation); expose[]/hot governs only the SEPARATE top-level
|
|
863
|
+
// (curated-direct) promotion. A lean tool is surfaced under its enabled expose `as` if one exists,
|
|
864
|
+
// else the namespaced default `<upstream>_<tool>` - so the lean name equals the curated-direct name
|
|
865
|
+
// for an exposed tool and ONE PreToolUse rule gates both routes. All of this lives here in the
|
|
866
|
+
// aggregator (it owns the connections, the _clients/_tools caches, and the connect log chokepoint).
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The single forward seam: a thunk that calls the upstream's real tool. Shared by the
|
|
870
|
+
* curated-direct path (resolveExposedExecution, transparent envelope passthrough) and the lean
|
|
871
|
+
* path (resolveLeanExecution, which unwraps). Kept tiny so both routes execute identically.
|
|
872
|
+
* @param {object} client a connected McpClient
|
|
873
|
+
* @param {string} realTool the upstream's own tool name
|
|
874
|
+
* @param {any} args
|
|
875
|
+
* @returns {() => Promise<any>}
|
|
876
|
+
*/
|
|
877
|
+
_forwardExecutor(client, realTool, args, meta, upstreamId, progress) {
|
|
878
|
+
return () => {
|
|
879
|
+
// Checked when the call RUNS, not when it resolved: the tool can change while a permission
|
|
880
|
+
// prompt waits on the user.
|
|
881
|
+
const refusal = this._approvalRefusal(upstreamId || client.id, realTool, 'curated');
|
|
882
|
+
if (refusal) return Promise.resolve(refusal.envelope);
|
|
883
|
+
this._warnIfPinned(client.id, realTool);
|
|
884
|
+
const scoped = this._scopeProgress(upstreamId || client.id, meta, progress);
|
|
885
|
+
return Promise.resolve()
|
|
886
|
+
.then(() => client.callTool(realTool, args, scoped.meta))
|
|
887
|
+
.finally(scoped.release);
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Scope a caller's progressToken to this one call. Client tokens are client-chosen,
|
|
893
|
+
* so two callers can use the same one over a shared upstream - and the spec says a token "MUST be
|
|
894
|
+
* unique across all active requests" from its SENDER, which toward the upstream is the gateway.
|
|
895
|
+
* With a `progress` sink the token is rewritten to a gateway token and that token's beats are
|
|
896
|
+
* routed back to the sink with the caller's token restored (_forwardUpstreamNotification). Without
|
|
897
|
+
* a sink the meta goes through unchanged (the token still keeps the upstream's timeout alive).
|
|
898
|
+
* @returns {{ meta: object|undefined, release: () => void }} release() when the call settles
|
|
899
|
+
*/
|
|
900
|
+
_scopeProgress(upstreamId, meta, progress) {
|
|
901
|
+
const tok = meta && typeof meta === 'object' ? meta.progressToken : undefined;
|
|
902
|
+
if (typeof progress !== 'function' || !((typeof tok === 'string' && tok.length) || Number.isInteger(tok))) {
|
|
903
|
+
return { meta, release: () => {} };
|
|
904
|
+
}
|
|
905
|
+
const gatewayToken = this._progressPrefix + (++this._progressSeq);
|
|
906
|
+
this._progressRoutes.set(gatewayToken, { upstreamId, originalToken: tok, sink: progress });
|
|
907
|
+
return {
|
|
908
|
+
meta: Object.assign({}, meta, { progressToken: gatewayToken }),
|
|
909
|
+
release: () => { this._progressRoutes.delete(gatewayToken); },
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Legacy shim PER-CALL warning (the pin's contract: warn on every forwarded call, naming the
|
|
915
|
+
* upstream + version). Called from BOTH forward paths - curated-direct (_forwardExecutor) and
|
|
916
|
+
* lean (resolveLeanExecution's own closure; it cannot use _forwardExecutor because it needs
|
|
917
|
+
* ensureConnected + envelope unwrapping). Checked at CALL time so a live expose.json edit takes
|
|
918
|
+
* effect immediately; stderr + activity log, never the content. NEVER throws.
|
|
919
|
+
* @param {string} upstreamId
|
|
920
|
+
* @param {string} realTool the upstream's own tool name
|
|
921
|
+
*/
|
|
922
|
+
_warnIfPinned(upstreamId, realTool) {
|
|
923
|
+
try {
|
|
924
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
925
|
+
if (upstream && upstream.legacyPin === true) {
|
|
926
|
+
process.stderr.write(
|
|
927
|
+
`[toolfunnel] legacy shim: forwarding "${realTool}" to pinned legacy upstream ` +
|
|
928
|
+
`"${upstreamId}" (MCP ${LEGACY_UPSTREAM_VERSION})\n`
|
|
929
|
+
);
|
|
930
|
+
logger.log({ type: 'mcp', event: 'legacy_shim_call', upstream: upstreamId, tool: realTool, protocolVersion: LEGACY_UPSTREAM_VERSION });
|
|
931
|
+
}
|
|
932
|
+
} catch (_e) { /* the warning must never affect the call */ }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Legacy-shim info for a surfaced OR original call name: { upstream, protocolVersion } when the
|
|
937
|
+
* tool belongs to a PINNED (legacyPin) upstream, else null. Used by the modern era to tag
|
|
938
|
+
* results with _meta["io.toolfunnel/legacyShim"] - metadata, never content. NEVER throws.
|
|
939
|
+
* @param {string} name the name a client called (surfaced `<upstream>_<tool>`/`as`, or the
|
|
940
|
+
* ORIGINAL tool name under passthrough)
|
|
941
|
+
* @returns {{ upstream: string, protocolVersion: string } | null}
|
|
942
|
+
*/
|
|
943
|
+
legacyShimInfo(name) {
|
|
944
|
+
if (typeof name !== 'string' || name.length === 0) return null;
|
|
945
|
+
try {
|
|
946
|
+
const d = this.leanToolDefinitions().find((x) => x && (x.name === name || x.tool === name));
|
|
947
|
+
if (!d) return null;
|
|
948
|
+
const upstream = this._store.getUpstream(d.upstream);
|
|
949
|
+
if (!upstream || upstream.legacyPin !== true) return null;
|
|
950
|
+
return { upstream: d.upstream, protocolVersion: LEGACY_UPSTREAM_VERSION };
|
|
951
|
+
} catch (_e) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* The wrapped upstream's OWN identity, extracted from its captured `initialize` result - so a
|
|
958
|
+
* passthrough WRAP can present the wrapped server verbatim (indistinguishable from connecting to
|
|
959
|
+
* it directly, save for the ToolFunnel process). Returns { protocolVersion, serverInfo,
|
|
960
|
+
* capabilities, instructions } with best-effort extraction (some servers nest capabilities /
|
|
961
|
+
* instructions inside serverInfo), or null if the upstream isn't connected. NEVER throws.
|
|
962
|
+
* @param {string} upstreamId
|
|
963
|
+
* @returns {{ protocolVersion?:string, serverInfo?:object, capabilities?:object, instructions?:string } | null}
|
|
964
|
+
*/
|
|
965
|
+
wrappedIdentity(upstreamId) {
|
|
966
|
+
try {
|
|
967
|
+
const client = this._clients.get(upstreamId);
|
|
968
|
+
if (!client || typeof client.initializeResult === 'undefined') return null;
|
|
969
|
+
const init = client.initializeResult;
|
|
970
|
+
if (!init || typeof init !== 'object') return null;
|
|
971
|
+
const si = init.serverInfo && typeof init.serverInfo === 'object' ? init.serverInfo : {};
|
|
972
|
+
// serverInfo passes VERBATIM minus the two fields some servers wrongly nest inside it
|
|
973
|
+
// (capabilities/instructions - extracted separately below). A blocklist, not a whitelist,
|
|
974
|
+
// so the spec's serverInfo.title (2025-06-18) and future identity fields survive the wrap.
|
|
975
|
+
const serverInfo = {};
|
|
976
|
+
for (const k of Object.keys(si)) {
|
|
977
|
+
if (k === 'capabilities' || k === 'instructions') continue;
|
|
978
|
+
serverInfo[k] = cloneJson(si[k]);
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
protocolVersion: typeof init.protocolVersion === 'string' ? init.protocolVersion : undefined,
|
|
982
|
+
serverInfo: Object.keys(serverInfo).length ? serverInfo : undefined,
|
|
983
|
+
// capabilities / instructions: prefer top-level (standard), fall back to serverInfo-nested.
|
|
984
|
+
capabilities: (init.capabilities && typeof init.capabilities === 'object') ? init.capabilities
|
|
985
|
+
: (si.capabilities && typeof si.capabilities === 'object') ? si.capabilities : undefined,
|
|
986
|
+
instructions: typeof init.instructions === 'string' ? init.instructions
|
|
987
|
+
: typeof si.instructions === 'string' ? si.instructions : undefined,
|
|
988
|
+
};
|
|
989
|
+
} catch (_e) {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Forward a RAW JSON-RPC method to a connected upstream and return its result - the passthrough
|
|
996
|
+
* WRAP's relay for non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
|
|
997
|
+
* server behaves exactly as it would connected directly. Uses a LIVE client only (no synchronous
|
|
998
|
+
* (re)connect - same rule as the lean run path). Throws if the upstream is not connected or the
|
|
999
|
+
* call fails; the caller shapes that into a JSON-RPC error.
|
|
1000
|
+
* @param {string} upstreamId
|
|
1001
|
+
* @param {string} method
|
|
1002
|
+
* @param {any} params
|
|
1003
|
+
* @returns {Promise<any>}
|
|
1004
|
+
*/
|
|
1005
|
+
async forwardRaw(upstreamId, method, params, out) {
|
|
1006
|
+
const client = await this.ensureConnected(upstreamId, { allowConnect: false });
|
|
1007
|
+
if (!client || typeof client.request !== 'function') {
|
|
1008
|
+
throw new Error(`upstream "${upstreamId}" is not connected`);
|
|
1009
|
+
}
|
|
1010
|
+
return await client.request(method, params, out);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** The clientInfo to PRESENT to `upstreamId`, or null for the built-in. Fresh provider read per
|
|
1014
|
+
* call (per-connect, same as the wrap-target check); NEVER throws. */
|
|
1015
|
+
_readClientInfo(upstreamId) {
|
|
1016
|
+
try {
|
|
1017
|
+
const info = this._clientInfoProvider ? this._clientInfoProvider(upstreamId) : null;
|
|
1018
|
+
return info && typeof info === 'object' &&
|
|
1019
|
+
typeof info.name === 'string' && info.name.length ? info : null;
|
|
1020
|
+
} catch (_e) {
|
|
1021
|
+
return null; // a broken provider degrades to the built-in identity
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Is `upstreamId` the currently-wrapped upstream? Fresh provider read per call; NEVER throws.
|
|
1026
|
+
* Drives the per-connect isolation exemption (transparent-wrapper mode). */
|
|
1027
|
+
_isWrapTarget(upstreamId) {
|
|
1028
|
+
try {
|
|
1029
|
+
return !!(this._wrapTargetProvider && isNonEmptyString(upstreamId) &&
|
|
1030
|
+
this._wrapTargetProvider() === upstreamId);
|
|
1031
|
+
} catch (_e) {
|
|
1032
|
+
return false; // a broken provider must degrade to the SAFE default (guard applies)
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Set the wrap-chatter scope. When the passthrough TARGET CHANGES (A->B, A->null, null->A) the
|
|
1037
|
+
* recorded resource subscriptions belong to the OLD context and must not replay onto the new
|
|
1038
|
+
* one - a client of wrap B never subscribed to wrap A's URIs. A
|
|
1039
|
+
* same-value re-arm (every message does one) clears nothing. */
|
|
1040
|
+
setWrapChatterUpstream(v) {
|
|
1041
|
+
const next = v == null ? null : v;
|
|
1042
|
+
if (next !== this.wrapChatterUpstream && this._subscribedUris instanceof Set) {
|
|
1043
|
+
// Tell the OUTGOING target to drop them first - it was told resources/subscribe for every
|
|
1044
|
+
// one and, forgotten only locally, kept emitting resources/updated for URIs nothing consumed
|
|
1045
|
+
// for the life of its process. releaseResources fans out to the CURRENT wrapChatterUpstream,
|
|
1046
|
+
// so it runs BEFORE the reassignment; the refs are dropped wholesale first because a context
|
|
1047
|
+
// switch releases everything, whatever the listen refcounts said.
|
|
1048
|
+
if (this._subscribedUris.size) {
|
|
1049
|
+
const uris = [...this._subscribedUris];
|
|
1050
|
+
if (this._subscribedRefs instanceof Map) this._subscribedRefs.clear();
|
|
1051
|
+
this.releaseResources(uris);
|
|
1052
|
+
}
|
|
1053
|
+
this._subscribedUris.clear();
|
|
1054
|
+
if (this._subscribedRefs instanceof Map) this._subscribedRefs.clear(); // 5.4: refs go with the context
|
|
1055
|
+
}
|
|
1056
|
+
this.wrapChatterUpstream = next;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Relay a client NOTIFICATION to an upstream (wrap passthrough: notifications/cancelled,
|
|
1060
|
+
* notifications/progress ...). Fire-and-forget; never throws, never answers. */
|
|
1061
|
+
notifyRaw(upstreamId, method, params) {
|
|
1062
|
+
try {
|
|
1063
|
+
const client = this._clients.get(upstreamId);
|
|
1064
|
+
if (client && typeof client.notify === 'function') client.notify(method, params);
|
|
1065
|
+
} catch (_e) { /* fire-and-forget */ }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** TRUE if a connected upstream can ACTUALLY deliver per-URI resource updates. That means a
|
|
1069
|
+
* LEGACY upstream advertising resources.subscribe (we forward the subscribes; it emits
|
|
1070
|
+
* resources/updated). A MODERN upstream does NOT count: its only notification channel is a
|
|
1071
|
+
* subscriptions/listen stream this client does not yet open (28-July item), so counting it
|
|
1072
|
+
* agreed a channel nothing would ever feed. Under a wrap only the WRAPPED
|
|
1073
|
+
* upstream counts - delivery is filtered to it, so another upstream honouring the channel
|
|
1074
|
+
* would be agreed-then-dropped. The ack must never promise a dead channel. */
|
|
1075
|
+
canHonourResourceSubscriptions() {
|
|
1076
|
+
for (const [id, client] of this._clients) {
|
|
1077
|
+
if (!client) continue;
|
|
1078
|
+
if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
|
|
1079
|
+
if (client.era === 'modern') continue;
|
|
1080
|
+
const caps = client.initializeResult && client.initializeResult.capabilities;
|
|
1081
|
+
if (caps && caps.resources && caps.resources.subscribe) return true;
|
|
1082
|
+
}
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Best-effort: forward resources/subscribe for each URI to every subscribe-capable legacy
|
|
1087
|
+
* upstream in scope (the wrapped one only, under a wrap). The URIs are RECORDED
|
|
1088
|
+
* so every later (re)connect replays them: a reconnected upstream is a fresh process that
|
|
1089
|
+
* remembers no subscriptions, and without the replay the client's agreed channel went silently
|
|
1090
|
+
* dead forever. Fire-and-forget; never throws. */
|
|
1091
|
+
subscribeResources(uris) {
|
|
1092
|
+
try {
|
|
1093
|
+
const list = Array.isArray(uris) ? uris.filter((u) => typeof u === 'string' && u.length) : [];
|
|
1094
|
+
if (!list.length) return;
|
|
1095
|
+
// 5.4: refcount per LISTEN-owned uri; the upstream subscribe is sent on the 0->1 crossing
|
|
1096
|
+
// only (a re-listen agreeing the same uri must not spam idempotent subscribes).
|
|
1097
|
+
const fresh = [];
|
|
1098
|
+
for (const uri of list) {
|
|
1099
|
+
const n = this._subscribedRefs.get(uri) || 0;
|
|
1100
|
+
this._subscribedRefs.set(uri, n + 1);
|
|
1101
|
+
if (!this._subscribedUris.has(uri)) { this._subscribedUris.add(uri); fresh.push(uri); }
|
|
1102
|
+
}
|
|
1103
|
+
if (!fresh.length) return;
|
|
1104
|
+
for (const [id, client] of this._clients) {
|
|
1105
|
+
if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
|
|
1106
|
+
if (!client || client.era !== 'legacy' || typeof client.request !== 'function') continue;
|
|
1107
|
+
const caps = client.initializeResult && client.initializeResult.capabilities;
|
|
1108
|
+
if (!(caps && caps.resources && caps.resources.subscribe)) continue;
|
|
1109
|
+
for (const uri of fresh) client.request('resources/subscribe', { uri }).catch(() => {});
|
|
1110
|
+
}
|
|
1111
|
+
} catch (_e) { /* never throw */ }
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Release LISTEN-owned resource subscriptions (the refcount leak's fix). Decrements each
|
|
1116
|
+
* uri's refcount; on the 1->0 crossing the uri leaves the replay set and the upstream is told
|
|
1117
|
+
* resources/unsubscribe (same scope rules as subscribeResources). ACCEPTED EDGE, documented:
|
|
1118
|
+
* a uri subscribed BOTH by a listen agreement and a client's own wrap-forwarded
|
|
1119
|
+
* resources/subscribe shares one upstream channel - when the last listen releases, the
|
|
1120
|
+
* upstream unsubscribe fires and the recorded Set entry goes with it (the exotic overlap is
|
|
1121
|
+
* not disambiguated). NEVER throws.
|
|
1122
|
+
* @param {string[]} uris
|
|
1123
|
+
*/
|
|
1124
|
+
releaseResources(uris) {
|
|
1125
|
+
try {
|
|
1126
|
+
const list = Array.isArray(uris) ? uris.filter((u) => typeof u === 'string' && u.length) : [];
|
|
1127
|
+
if (!list.length) return;
|
|
1128
|
+
const gone = [];
|
|
1129
|
+
for (const uri of list) {
|
|
1130
|
+
const n = this._subscribedRefs.get(uri) || 0;
|
|
1131
|
+
if (n <= 1) {
|
|
1132
|
+
if (n === 1) this._subscribedRefs.delete(uri);
|
|
1133
|
+
if (this._subscribedUris.has(uri)) { this._subscribedUris.delete(uri); gone.push(uri); }
|
|
1134
|
+
} else {
|
|
1135
|
+
this._subscribedRefs.set(uri, n - 1);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (!gone.length) return;
|
|
1139
|
+
for (const [id, client] of this._clients) {
|
|
1140
|
+
if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
|
|
1141
|
+
if (!client || client.era !== 'legacy' || typeof client.request !== 'function') continue;
|
|
1142
|
+
const caps = client.initializeResult && client.initializeResult.capabilities;
|
|
1143
|
+
if (!(caps && caps.resources && caps.resources.subscribe)) continue;
|
|
1144
|
+
for (const uri of gone) client.request('resources/unsubscribe', { uri }).catch(() => {});
|
|
1145
|
+
}
|
|
1146
|
+
} catch (_e) { /* never throw */ }
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Replay the recorded resource subscriptions onto ONE freshly-connected upstream.
|
|
1150
|
+
* Called from _connectOne so every connect path - startup, lazy, background reconnect, reload -
|
|
1151
|
+
* re-arms the channel. Same scope rules as subscribeResources. Never throws. */
|
|
1152
|
+
_replaySubscriptions(upstreamId) {
|
|
1153
|
+
try {
|
|
1154
|
+
if (!this._subscribedUris || this._subscribedUris.size === 0) return;
|
|
1155
|
+
if (this.wrapChatterUpstream && upstreamId !== this.wrapChatterUpstream) return;
|
|
1156
|
+
const client = this._clients.get(upstreamId);
|
|
1157
|
+
if (!client || client.era !== 'legacy' || typeof client.request !== 'function') return;
|
|
1158
|
+
const caps = client.initializeResult && client.initializeResult.capabilities;
|
|
1159
|
+
if (!(caps && caps.resources && caps.resources.subscribe)) return;
|
|
1160
|
+
for (const uri of this._subscribedUris) client.request('resources/subscribe', { uri }).catch(() => {});
|
|
1161
|
+
} catch (_e) { /* never throw */ }
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** The change-notification methods an upstream may push that we bridge to modern clients. Anything
|
|
1165
|
+
* else from the upstream is ignored (request-scoped notifications ride their own request stream). */
|
|
1166
|
+
static get BRIDGED_NOTIFICATIONS() {
|
|
1167
|
+
return new Set([
|
|
1168
|
+
'notifications/tools/list_changed',
|
|
1169
|
+
'notifications/prompts/list_changed',
|
|
1170
|
+
'notifications/resources/list_changed',
|
|
1171
|
+
'notifications/resources/updated',
|
|
1172
|
+
]);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Forward a server-initiated CHANGE notification from an upstream to the transport hook, which
|
|
1177
|
+
* fans it to modern subscriptions/listen streams. Only the known change methods pass; the
|
|
1178
|
+
* notification is handed on verbatim (the transport tags + filters per subscription). NEVER
|
|
1179
|
+
* throws (an onNotification handler must never break the client's read loop).
|
|
1180
|
+
* @param {string} upstreamId
|
|
1181
|
+
* @param {object} notification a JSON-RPC notification { method, params? }
|
|
1182
|
+
*/
|
|
1183
|
+
_forwardUpstreamNotification(upstreamId, notification) {
|
|
1184
|
+
try {
|
|
1185
|
+
if (this._closed) return; // a swapped-out/torn-down aggregator must never fan out
|
|
1186
|
+
if (!notification || typeof notification.method !== 'string') return;
|
|
1187
|
+
// Scoped progress: a beat carrying one of OUR tokens goes to that call's sink,
|
|
1188
|
+
// caller's token restored - only from the upstream the token was issued to - and NOWHERE else:
|
|
1189
|
+
// not a settled call, not another upstream's, never on to a broadcast.
|
|
1190
|
+
if (notification.method === 'notifications/progress') {
|
|
1191
|
+
const tok = notification.params && notification.params.progressToken;
|
|
1192
|
+
if (typeof tok === 'string' && tok.startsWith(this._progressPrefix)) {
|
|
1193
|
+
const route = this._progressRoutes.get(tok);
|
|
1194
|
+
if (route && route.upstreamId === upstreamId) {
|
|
1195
|
+
try { route.sink(Object.assign({}, notification.params, { progressToken: route.originalToken })); } catch (_e) { /* a sink never breaks the read loop */ }
|
|
1196
|
+
}
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
// tasks/status rides the wrap-chatter lane too: the wrap advertises the
|
|
1201
|
+
// upstream's `tasks` capability verbatim, so a task started through the wrap owes its
|
|
1202
|
+
// status notifications exactly as a direct connection would deliver them (2025-11-25
|
|
1203
|
+
// schema TaskStatusNotification). Wrapped upstream only - same scope as message/progress.
|
|
1204
|
+
const isWrapChatter = this.wrapChatterUpstream === upstreamId &&
|
|
1205
|
+
(notification.method === 'notifications/message' || notification.method === 'notifications/progress' ||
|
|
1206
|
+
notification.method === 'notifications/tasks/status');
|
|
1207
|
+
if (!Aggregator.BRIDGED_NOTIFICATIONS.has(notification.method) && !isWrapChatter) return;
|
|
1208
|
+
// Forward a CLEAN notification only - a misbehaving upstream's stray `id: null` field would
|
|
1209
|
+
// read as a response to downstream parsers.
|
|
1210
|
+
const clean = { jsonrpc: '2.0', method: notification.method };
|
|
1211
|
+
if (notification.params !== undefined) clean.params = notification.params;
|
|
1212
|
+
const emit = () => {
|
|
1213
|
+
try {
|
|
1214
|
+
if (typeof this.onUpstreamNotification === 'function') {
|
|
1215
|
+
this.onUpstreamNotification(upstreamId, clean);
|
|
1216
|
+
}
|
|
1217
|
+
} catch (_e) { /* never let a bridged notification break the read loop */ }
|
|
1218
|
+
};
|
|
1219
|
+
if (notification.method === 'notifications/tools/list_changed') {
|
|
1220
|
+
// A relayed "tools changed" must not point the client at a STALE surface - refresh the
|
|
1221
|
+
// cache FIRST, then relay, so the re-fetch sees the new list. onToolsChanged
|
|
1222
|
+
// fires too so ToolFunnel's own funnel-mode signal stays coherent.
|
|
1223
|
+
this._listAndCache(upstreamId)
|
|
1224
|
+
.then(() => { try { if (typeof this.onToolsChanged === 'function') this.onToolsChanged(); } catch (_e) { /* ignore */ } })
|
|
1225
|
+
.catch(() => { /* refresh failed - still relay; the old cache is the best we have */ })
|
|
1226
|
+
.then(emit);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
emit();
|
|
1230
|
+
} catch (_e) { /* never let a bridged notification break the read loop */ }
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* The downstream name a discovered upstream tool is surfaced under: its ENABLED expose `as` if one
|
|
1235
|
+
* exists for (upstream, tool), else the namespaced default `<upstream>_<tool>` (matching
|
|
1236
|
+
* expose-store.exposedName). Keeps the lean list and the lean run path in agreement, and aligns an
|
|
1237
|
+
* exposed tool's lean name with its curated-direct top-level name. Never throws.
|
|
1238
|
+
* @param {string} upstreamId
|
|
1239
|
+
* @param {string} toolName
|
|
1240
|
+
* @returns {string}
|
|
1241
|
+
*/
|
|
1242
|
+
_surfacedName(upstreamId, toolName) {
|
|
1243
|
+
try {
|
|
1244
|
+
const exposed = this._store.listExposed({ enabledOnly: true });
|
|
1245
|
+
for (const e of exposed) {
|
|
1246
|
+
if (e && e.upstream === upstreamId && e.tool === toolName) return this._store.exposedName(e);
|
|
1247
|
+
}
|
|
1248
|
+
} catch (_e) {
|
|
1249
|
+
/* fall through to the default */
|
|
1250
|
+
}
|
|
1251
|
+
return `${upstreamId}_${toolName}`;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Connect an upstream ON DEMAND and return the ready McpClient. Idempotent + race-safe:
|
|
1256
|
+
* - fast path: a cached client that is still `connected` is returned as-is; a cached-but-DEAD
|
|
1257
|
+
* client (its child exited - McpClient flips `connected` but stays in the cache) is logged as a
|
|
1258
|
+
* disconnect, discarded, and reconnected - a reconnect re-lists the upstream's tools.
|
|
1259
|
+
* - concurrent callers JOIN the same in-flight connect promise (keyed in _connecting), which
|
|
1260
|
+
* resolves only AFTER _listAndCache - so no caller ever sees the half-built client that
|
|
1261
|
+
* _connectOne caches BEFORE the handshake completes (the double-spawn / mid-handshake race).
|
|
1262
|
+
* Throws on an unknown/disabled upstream or a connect/list failure (the caller surfaces it; for the
|
|
1263
|
+
* lean run path that means gatedRun returns { ok:false, error } cleanly).
|
|
1264
|
+
* @param {string} upstreamId
|
|
1265
|
+
* @returns {Promise<object>} the connected McpClient
|
|
1266
|
+
*/
|
|
1267
|
+
async ensureConnected(upstreamId, opts) {
|
|
1268
|
+
if (!isNonEmptyString(upstreamId)) throw new Error('Aggregator.ensureConnected: upstreamId (non-empty string) is required');
|
|
1269
|
+
// allowConnect:false (the RUN path) must NEVER block on a connect - not a from-scratch connect
|
|
1270
|
+
// (connect()+tools/list, ~20s, would freeze the serialized stdio chain and starve pings) AND not
|
|
1271
|
+
// by JOINING an in-flight background reconnect (same latency). It returns a live client or fails
|
|
1272
|
+
// clean; the background reconnect owns recovery and the run retries next turn. allowConnect
|
|
1273
|
+
// defaults true (the background path connects / joins).
|
|
1274
|
+
const allowConnect = !(opts && opts.allowConnect === false);
|
|
1275
|
+
// allowDisabled: ONLY the discover path sets it - inspecting a disabled upstream's tools
|
|
1276
|
+
// ("what does this have, before I enable it?") is a deliberate operator action that worked
|
|
1277
|
+
// before discover was routed through here for the race fix. Every
|
|
1278
|
+
// other caller keeps the refusal; a disabled upstream's tools stay OFF the lean surface
|
|
1279
|
+
// regardless (leanToolDefinitions checks enabled itself).
|
|
1280
|
+
const allowDisabled = !!(opts && opts.allowDisabled === true);
|
|
1281
|
+
// A torn-down aggregator (swapped out by reloadExpose, then closeAll'd) must NEVER (re)connect:
|
|
1282
|
+
// a captured execute thunk holding this instance would otherwise spawn an ORPHAN child on it.
|
|
1283
|
+
if (this._closed) throw new Error('Aggregator.ensureConnected: aggregator is closed (config reloaded) - retry on the current one');
|
|
1284
|
+
|
|
1285
|
+
// A connect already in flight: JOIN it - BEFORE inspecting the cache. _connectOne caches the
|
|
1286
|
+
// client pre-handshake, so a concurrent caller inspecting first sees `connected:false`, reads
|
|
1287
|
+
// it as stale, closes it (killing the in-flight child mid-handshake) and then joins the
|
|
1288
|
+
// connect it just doomed - both callers fail. The RUN path
|
|
1289
|
+
// (allowConnect:false) still never joins - joining would block the stdio chain.
|
|
1290
|
+
if (this._connecting.has(upstreamId)) {
|
|
1291
|
+
if (!(opts && opts.allowConnect === false)) return this._connecting.get(upstreamId);
|
|
1292
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background) - retry in a few seconds`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// STOPPED (permanent failure past the ceiling): nothing is reconnecting, so the RUN path must
|
|
1296
|
+
// not say it is - name the cause and the way back. The background path (an operator's
|
|
1297
|
+
// discover) still makes its one attempt below.
|
|
1298
|
+
const stopped = this._stopped.get(upstreamId);
|
|
1299
|
+
if (stopped && !allowConnect) {
|
|
1300
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected - stopped retrying after ` +
|
|
1301
|
+
`${stopped.attempts} attempts: ${stopped.error}. Fix the cause, then save expose.json (any change reloads it) ` +
|
|
1302
|
+
'or restart the gateway.');
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Fast path: a live cached client serves BOTH modes immediately.
|
|
1306
|
+
const existing = this._clients.get(upstreamId);
|
|
1307
|
+
if (existing) {
|
|
1308
|
+
const alive = typeof existing.connected === 'boolean' ? existing.connected : true;
|
|
1309
|
+
if (alive) return existing;
|
|
1310
|
+
// Dead cached client. The RUN path fails clean (never (re)connects); the background path drops
|
|
1311
|
+
// it and reconnects below.
|
|
1312
|
+
if (!allowConnect) {
|
|
1313
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" disconnected (reconnecting in background) - retry in a few seconds`);
|
|
1314
|
+
}
|
|
1315
|
+
logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'stale' });
|
|
1316
|
+
this._discard(upstreamId);
|
|
1317
|
+
} else if (!allowConnect) {
|
|
1318
|
+
// Not connected. The RUN path neither connects NOR joins an in-flight connect - joining would
|
|
1319
|
+
// block the stdio chain for the connect+list latency (the head-of-line freeze this mode avoids).
|
|
1320
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background) - retry in a few seconds`);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Background path only (allowConnect): connect from scratch. In-flight connects were joined
|
|
1324
|
+
// at the top of the method (before cache inspection - the mid-connect discard race); nothing
|
|
1325
|
+
// between there and here awaits, so no new entry can have appeared.
|
|
1326
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
1327
|
+
if (!upstream) throw new Error(`Aggregator.ensureConnected: unknown upstream "${upstreamId}"`);
|
|
1328
|
+
if (upstream.enabled !== true && !allowDisabled) throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is disabled`);
|
|
1329
|
+
|
|
1330
|
+
const promise = (async () => {
|
|
1331
|
+
await this._connectOne(upstream); // caches client + tools, logs the connect
|
|
1332
|
+
return this._clients.get(upstreamId);
|
|
1333
|
+
})();
|
|
1334
|
+
this._connecting.set(upstreamId, promise);
|
|
1335
|
+
try {
|
|
1336
|
+
return await promise;
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
this._discard(upstreamId); // reap the half-spawned child; leave no zombie/cache entry
|
|
1339
|
+
throw err;
|
|
1340
|
+
} finally {
|
|
1341
|
+
this._connecting.delete(upstreamId);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Force a fresh connect of `upstreamId` presenting the CURRENT provider-supplied identity -
|
|
1347
|
+
* the wrap identity-mirror's inline path. Settles any in-flight connect FIRST: discarding a
|
|
1348
|
+
* half-built client and then joining its doomed promise leaves the upstream wedged with no
|
|
1349
|
+
* background reconnect and no self-heal. Rejects
|
|
1350
|
+
* like ensureConnected on failure.
|
|
1351
|
+
*/
|
|
1352
|
+
async reconnect(upstreamId) {
|
|
1353
|
+
if (!isNonEmptyString(upstreamId)) throw new Error('Aggregator.reconnect: upstreamId (non-empty string) is required');
|
|
1354
|
+
while (this._connecting.has(upstreamId)) {
|
|
1355
|
+
try { await this._connecting.get(upstreamId); } catch (_e) { /* settled is all we need */ }
|
|
1356
|
+
}
|
|
1357
|
+
this._discard(upstreamId);
|
|
1358
|
+
// A MANUAL kick deliberately re-runs the full era negotiation - the operator may have just
|
|
1359
|
+
// upgraded the server, and "reconnect" should observe reality, not replay a memo of it.
|
|
1360
|
+
this._eraMemo.delete(upstreamId);
|
|
1361
|
+
// ...and it restarts a STOPPED upstream with a fresh retry budget: the operator is saying the
|
|
1362
|
+
// cause may be fixed (the command installed, the path moved inside the root).
|
|
1363
|
+
this._stopped.delete(upstreamId);
|
|
1364
|
+
this._permanentFailures.delete(upstreamId);
|
|
1365
|
+
try {
|
|
1366
|
+
return await this.ensureConnected(upstreamId);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
// This path DESTROYED a (possibly healthy) connection, and the deliberate close means
|
|
1369
|
+
// onClose never fires - without this, one transient connect failure leaves no client, no
|
|
1370
|
+
// in-flight connect AND no retry timer: the upstream is wedged until an external kick
|
|
1371
|
+
// while ensureConnected's "reconnecting in background" message lies. The destroyer owns the retry. A stale PENDING timer (a prior death's slow
|
|
1372
|
+
// 30s keepalive) must be cleared first - _scheduleReconnect's one-timer guard would
|
|
1373
|
+
// otherwise no-op and leave the upstream on the long window instead of the immediate
|
|
1374
|
+
// retry this path promises.
|
|
1375
|
+
const stale = this._reconnectTimers.get(upstreamId);
|
|
1376
|
+
if (stale) { clearTimeout(stale); this._reconnectTimers.delete(upstreamId); }
|
|
1377
|
+
if (!this._countConnectFailure(upstreamId, err)) this._scheduleReconnect(upstreamId, 0);
|
|
1378
|
+
throw err;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* UNEXPECTED upstream death (fired by McpClient.onClose). Log it, drop the dead client so it
|
|
1384
|
+
* leaves the lean list, tell the client the tool set shrank, then schedule a BACKGROUND reconnect.
|
|
1385
|
+
* Keeping recovery off the serialized stdio chain is what closes the head-of-line regression: a run
|
|
1386
|
+
* during the down-window fails clean (ensureConnected allowConnect:false) instead of blocking.
|
|
1387
|
+
* @param {string} upstreamId
|
|
1388
|
+
*/
|
|
1389
|
+
_handleUpstreamDown(upstreamId) {
|
|
1390
|
+
if (this._closed) return;
|
|
1391
|
+
logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'died' });
|
|
1392
|
+
this._discard(upstreamId); // remove the dead client + its tools -> drops from the lean list
|
|
1393
|
+
this._signalToolsChanged(upstreamId); // list_changed only if the upstream is on the top-level surface
|
|
1394
|
+
// If a reconnect ATTEMPT is already executing, it owns the next backoff step (its catch escalates
|
|
1395
|
+
// attempt+1). Seeding attempt-0 here would reset the delay to 1s on every mid-attempt death, and
|
|
1396
|
+
// the one-timer guard would then swallow the attempt's escalation - a permanent 1s respawn loop.
|
|
1397
|
+
// Only start a fresh cycle when no attempt is in flight (the ordinary first death of a live upstream).
|
|
1398
|
+
if (this._reconnecting.has(upstreamId)) return;
|
|
1399
|
+
this._scheduleReconnect(upstreamId, 0);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Does this upstream contribute to the TOP-LEVEL tools/list (i.e. has an ENABLED expose[] entry)?
|
|
1404
|
+
* Only then does its death/recovery change tools/list and warrant a notifications/tools/list_changed.
|
|
1405
|
+
* A lean-only upstream's tools live behind toolfunnel_list_tools (pulled per call), so notifying for
|
|
1406
|
+
* one is spurious (the client re-fetches and sees no diff). Never throws.
|
|
1407
|
+
* @param {string} upstreamId
|
|
1408
|
+
* @returns {boolean}
|
|
1409
|
+
*/
|
|
1410
|
+
_affectsTopLevel(upstreamId) {
|
|
1411
|
+
try {
|
|
1412
|
+
// Under a WRAP the wrapped upstream IS the entire top-level surface - its death/recovery
|
|
1413
|
+
// changes tools/list even with an empty expose[] (a typical wrap has no expose
|
|
1414
|
+
// entries, so death/recovery was silent and clients held a stale surface forever).
|
|
1415
|
+
// wrapChatterUpstream mirrors the live wrap state (armed at boot, per message, on reload).
|
|
1416
|
+
if (this.wrapChatterUpstream && this.wrapChatterUpstream === upstreamId) return true;
|
|
1417
|
+
// Hot-promoted upstream tools sit on the funnel's top-level surface without an expose[]
|
|
1418
|
+
// entry; the host injects this predicate (armWrapChatter) because promotion lives in the
|
|
1419
|
+
// tool-state overlay the aggregator has no business reading directly.
|
|
1420
|
+
if (typeof this.topLevelUpstreamExtra === 'function' && this.topLevelUpstreamExtra(upstreamId)) return true;
|
|
1421
|
+
return this._store.listExposed({ enabledOnly: true }).some((e) => e && e.upstream === upstreamId);
|
|
1422
|
+
} catch (_e) {
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/** Fire onToolsChanged ONLY when the upstream is actually on the top-level surface (else the
|
|
1428
|
+
* notification is spurious - its lean tools refresh on the next toolfunnel_list_tools). Never throws. */
|
|
1429
|
+
_signalToolsChanged(upstreamId) {
|
|
1430
|
+
if (!this._affectsTopLevel(upstreamId)) return;
|
|
1431
|
+
try { this.onToolsChanged(); } catch (_e) { /* never throw out of an event handler */ }
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Count one failed connect toward the permanent-failure ceiling. A PERMANENT-class error adds to
|
|
1436
|
+
* this upstream's consecutive count; any other error resets it. Past PERMANENT_RETRY_LIMIT retries
|
|
1437
|
+
* the upstream is STOPPED: one stderr line, a log event, and stoppedUpstreams() lists it. NEVER throws.
|
|
1438
|
+
* @returns {boolean} true when the upstream is now stopped (the caller must not schedule a retry)
|
|
1439
|
+
*/
|
|
1440
|
+
_countConnectFailure(upstreamId, err) {
|
|
1441
|
+
try {
|
|
1442
|
+
if (!isPermanentConnectError(err)) {
|
|
1443
|
+
this._permanentFailures.delete(upstreamId);
|
|
1444
|
+
return false;
|
|
1445
|
+
}
|
|
1446
|
+
const attempts = (this._permanentFailures.get(upstreamId) || 0) + 1;
|
|
1447
|
+
this._permanentFailures.set(upstreamId, attempts);
|
|
1448
|
+
if (attempts <= PERMANENT_RETRY_LIMIT) return false;
|
|
1449
|
+
// ONE bounded line: a real failure carries the upstream's stderr (newlines, possibly pages
|
|
1450
|
+
// of it). The client puts the cause first, so the head is the part worth keeping.
|
|
1451
|
+
const flat = errMsg(err).replace(/\s+/g, ' ').trim();
|
|
1452
|
+
const error = flat.length > STOPPED_ERROR_MAX ? flat.slice(0, STOPPED_ERROR_MAX) + '...' : flat;
|
|
1453
|
+
this._stopped.set(upstreamId, { error, attempts });
|
|
1454
|
+
logger.log({ type: 'mcp', event: 'reconnect_stopped', upstream: upstreamId, attempts, error });
|
|
1455
|
+
try {
|
|
1456
|
+
process.stderr.write(`[toolfunnel] upstream "${upstreamId}" stopped retrying after ${attempts} attempts ` +
|
|
1457
|
+
`(a permanent failure: ${error}). Fix the cause, then save expose.json (any change reloads it) or restart the gateway.\n`);
|
|
1458
|
+
} catch (_e) { /* never let the notice sink the caller */ }
|
|
1459
|
+
return true;
|
|
1460
|
+
} catch (_e) {
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** Upstreams that hit the permanent-failure ceiling and stopped retrying: [{id, error, attempts}]
|
|
1466
|
+
* (fresh copies). The /health source. Never throws. */
|
|
1467
|
+
stoppedUpstreams() {
|
|
1468
|
+
const out = [];
|
|
1469
|
+
for (const [id, s] of this._stopped) out.push({ id, error: s.error, attempts: s.attempts });
|
|
1470
|
+
return out;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Schedule ONE background reconnect attempt with exponential backoff: 1s,2s,4s,8s,16s, then capped
|
|
1475
|
+
* at 30s. After the fast phase it does NOT give up on a transient failure - it falls back to a slow
|
|
1476
|
+
* 30s keepalive so a long transient outage still self-heals; a PERMANENT failure stops at the
|
|
1477
|
+
* ceiling instead (_countConnectFailure) (a detached/disabled upstream stops it via getUpstream; closeAll
|
|
1478
|
+
* stops it via _closed). On success the tools are re-listed and a top-level change is signalled. One
|
|
1479
|
+
* timer per upstream (no pile-up); the timer is unref'd so it never keeps the process alive. NEVER throws.
|
|
1480
|
+
* @param {string} upstreamId
|
|
1481
|
+
* @param {number} attempt
|
|
1482
|
+
*/
|
|
1483
|
+
_scheduleReconnect(upstreamId, attempt) {
|
|
1484
|
+
if (this._closed) return;
|
|
1485
|
+
if (this._reconnectTimers.has(upstreamId)) return; // a reconnect is already pending for this id
|
|
1486
|
+
if (this._stopped.has(upstreamId)) return; // past the permanent-failure ceiling - see _countConnectFailure
|
|
1487
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
1488
|
+
if (!upstream || upstream.enabled !== true) return; // detached/disabled - stop trying
|
|
1489
|
+
const MAX_FAST_ATTEMPTS = 6; // exponential phase; beyond it, a slow keepalive at the 30s cap
|
|
1490
|
+
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
|
|
1491
|
+
const timer = setTimeout(async () => {
|
|
1492
|
+
this._reconnectTimers.delete(upstreamId);
|
|
1493
|
+
if (this._closed) return;
|
|
1494
|
+
const u = this._store.getUpstream(upstreamId);
|
|
1495
|
+
if (!u || u.enabled !== true) return;
|
|
1496
|
+
// Mark the attempt in flight so a death DURING ensureConnected's connect/list (onClose ->
|
|
1497
|
+
// _handleUpstreamDown) does not re-seed attempt-0: THIS attempt's catch owns the escalation.
|
|
1498
|
+
this._reconnecting.add(upstreamId);
|
|
1499
|
+
try {
|
|
1500
|
+
await this.ensureConnected(upstreamId); // allowConnect default - connects + re-lists, off-chain
|
|
1501
|
+
logger.log({ type: 'mcp', event: 'reconnect', upstream: upstreamId, attempt });
|
|
1502
|
+
this._signalToolsChanged(upstreamId);
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
logger.log({ type: 'mcp', event: 'reconnect_failed', upstream: upstreamId, attempt, error: errMsg(err) });
|
|
1505
|
+
// Log the fast->slow transition exactly ONCE - when the counter FIRST reaches the cap. The
|
|
1506
|
+
// old guard sat on the re-entry side, where the capped counter re-satisfied it every 30s
|
|
1507
|
+
// forever against a permanently-dead upstream (log spam, not information).
|
|
1508
|
+
if (attempt + 1 === MAX_FAST_ATTEMPTS) {
|
|
1509
|
+
logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: MAX_FAST_ATTEMPTS });
|
|
1510
|
+
}
|
|
1511
|
+
// A PERMANENT failure past the ceiling stops here (one stderr line; stoppedUpstreams()).
|
|
1512
|
+
if (this._countConnectFailure(upstreamId, err)) return;
|
|
1513
|
+
// Keep escalating to the cap, then keep retrying slowly (cap the counter so the delay stays
|
|
1514
|
+
// at 30s) - never permanently give up while the upstream stays enabled. The escalation is
|
|
1515
|
+
// scheduled while still in flight (the guard below checks _reconnectTimers, which is empty
|
|
1516
|
+
// because a mid-attempt death was skipped) - the finally then releases the marker.
|
|
1517
|
+
this._scheduleReconnect(upstreamId, Math.min(attempt + 1, MAX_FAST_ATTEMPTS));
|
|
1518
|
+
} finally {
|
|
1519
|
+
this._reconnecting.delete(upstreamId);
|
|
1520
|
+
}
|
|
1521
|
+
}, delay);
|
|
1522
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
1523
|
+
this._reconnectTimers.set(upstreamId, timer);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* The LEAN tool definitions: every discovered tool of every CONNECTED + enabled upstream, surfaced
|
|
1528
|
+
* for toolfunnel_list_tools. Each: { name, description, inputSchema, upstream, tool }. The FULL
|
|
1529
|
+
* discovered set (not just expose[]) - attaching an MCP makes its tools appear leanly with no
|
|
1530
|
+
* curation. Only connected upstreams contribute (we need their real schemas); a not-yet-connected
|
|
1531
|
+
* upstream's tools simply appear once it connects (the next list call reflects it - no
|
|
1532
|
+
* notification needed, the lean surface is delivered inside a per-call meta-tool). Never throws.
|
|
1533
|
+
* @returns {Array<{name:string, description:string, inputSchema:object, upstream:string, tool:string}>}
|
|
1534
|
+
*/
|
|
1535
|
+
leanToolDefinitions() {
|
|
1536
|
+
const defs = [];
|
|
1537
|
+
const seen = new Set(); // surfaced-name collision guard - the list must agree with resolve (first wins)
|
|
1538
|
+
try {
|
|
1539
|
+
for (const [upstreamId, tools] of this._tools) {
|
|
1540
|
+
if (!this._clients.has(upstreamId)) continue; // connected only
|
|
1541
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
1542
|
+
if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
|
|
1543
|
+
if (!Array.isArray(tools)) continue;
|
|
1544
|
+
for (const t of tools) {
|
|
1545
|
+
if (!t || !isNonEmptyString(t.name)) continue;
|
|
1546
|
+
const name = this._surfacedName(upstreamId, t.name);
|
|
1547
|
+
if (seen.has(name)) {
|
|
1548
|
+
// Two tools collapse to one surfaced name (underscore ambiguity, or a duplicate `as`).
|
|
1549
|
+
// resolveLeanExecution returns the FIRST match, so advertise only the first - the list
|
|
1550
|
+
// must never show a name that resolves to a different tool. Log the shadowing.
|
|
1551
|
+
logger.log({ type: 'mcp', event: 'name_collision', upstream: upstreamId, tool: t.name, surfaced: name });
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
seen.add(name);
|
|
1555
|
+
const schema = t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : { type: 'object' };
|
|
1556
|
+
defs.push(withForwardedToolMeta({
|
|
1557
|
+
name,
|
|
1558
|
+
description: isNonEmptyString(t.description) ? t.description : '',
|
|
1559
|
+
inputSchema: sanitiseXMcpHeaders(cloneJson(schema)),
|
|
1560
|
+
upstream: upstreamId,
|
|
1561
|
+
tool: t.name,
|
|
1562
|
+
}, t));
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
} catch (_e) {
|
|
1566
|
+
/* never throw out of a definition build - return what we have */
|
|
1567
|
+
}
|
|
1568
|
+
return defs;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Resolve a lean downstream name + args into a forwarding executor for toolfunnel_run_tool. Matches
|
|
1573
|
+
* by ITERATION over discovered tools (computing each tool's surfaced name) - never by parsing the
|
|
1574
|
+
* name (underscore-ambiguous). The execute thunk lazy-(re)connects via ensureConnected and UNWRAPS
|
|
1575
|
+
* the upstream MCP envelope to a clean payload, so run_tool returns the upstream's content (e.g.
|
|
1576
|
+
* "pong"), not a stringified envelope. The connect is INSIDE the thunk -> a PreToolUse deny spawns
|
|
1577
|
+
* nothing. The gate matches `toolName` (the surfaced name). Returns null if no connected + enabled
|
|
1578
|
+
* upstream advertises a tool with this surfaced name (-> protocol's clean "not runnable" error) -
|
|
1579
|
+
* the same connected/enabled predicate leanToolDefinitions lists by, so hidden means not runnable.
|
|
1580
|
+
* @param {string} name surfaced downstream name
|
|
1581
|
+
* @param {any} args
|
|
1582
|
+
* @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
|
|
1583
|
+
*/
|
|
1584
|
+
resolveLeanExecution(name, args, callOpts) {
|
|
1585
|
+
if (!isNonEmptyString(name)) return null;
|
|
1586
|
+
// callOpts: { meta, progress } - the caller's request _meta rides the lean forward as it does
|
|
1587
|
+
// every other route, and progress comes back through the sink.
|
|
1588
|
+
const leanMeta = callOpts && callOpts.meta && typeof callOpts.meta === 'object' ? callOpts.meta : undefined;
|
|
1589
|
+
const leanProgress = callOpts && typeof callOpts.progress === 'function' ? callOpts.progress : undefined;
|
|
1590
|
+
for (const [upstreamId, tools] of this._tools) {
|
|
1591
|
+
if (!this._clients.has(upstreamId)) continue;
|
|
1592
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
1593
|
+
if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
|
|
1594
|
+
if (!Array.isArray(tools)) continue;
|
|
1595
|
+
for (const t of tools) {
|
|
1596
|
+
if (!t || !isNonEmptyString(t.name)) continue;
|
|
1597
|
+
if (this._surfacedName(upstreamId, t.name) !== name) continue;
|
|
1598
|
+
const realTool = t.name;
|
|
1599
|
+
const self = this;
|
|
1600
|
+
const execute = async () => {
|
|
1601
|
+
// A changed descriptor waiting for acceptance refuses here, before any forward.
|
|
1602
|
+
const refusal = self._approvalRefusal(upstreamId, realTool, 'lean');
|
|
1603
|
+
if (refusal) throw Object.assign(new Error(refusal.text), { withheld: refusal.envelope.structuredContent });
|
|
1604
|
+
// allowConnect:false - the run path NEVER synchronously (re)connects (that would freeze the
|
|
1605
|
+
// stdio loop, starving pings). It uses a live client or joins an in-flight background
|
|
1606
|
+
// reconnect, else fails clean; the onClose-driven background reconnect owns recovery.
|
|
1607
|
+
const client = await self.ensureConnected(upstreamId, { allowConnect: false });
|
|
1608
|
+
self._warnIfPinned(upstreamId, realTool);
|
|
1609
|
+
const scoped = self._scopeProgress(upstreamId, leanMeta, leanProgress);
|
|
1610
|
+
let envelope;
|
|
1611
|
+
try {
|
|
1612
|
+
envelope = await client.callTool(realTool, args, scoped.meta);
|
|
1613
|
+
} finally {
|
|
1614
|
+
scoped.release();
|
|
1615
|
+
}
|
|
1616
|
+
// Preserve the upstream's failure signal. callTool RESOLVES even on isError:true, so a
|
|
1617
|
+
// failed upstream call must become a THROW -> gatedRun returns { ok:false } -> run_tool
|
|
1618
|
+
// surfaces isError:true. Otherwise a failed call would be reported as success - and the
|
|
1619
|
+
// lean path must agree with curated-direct's isError passthrough.
|
|
1620
|
+
const payload = unwrapEnvelope(envelope);
|
|
1621
|
+
if (envelope && typeof envelope === 'object' && envelope.isError === true) {
|
|
1622
|
+
const text = typeof payload === 'string' ? payload : safeStringify(payload);
|
|
1623
|
+
const err = new Error(text && text.length ? text : 'upstream tool reported an error');
|
|
1624
|
+
err.upstreamToolError = true;
|
|
1625
|
+
throw err;
|
|
1626
|
+
}
|
|
1627
|
+
return payload;
|
|
1628
|
+
};
|
|
1629
|
+
return { execute, toolName: name, upstream: upstreamId };
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return null;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* resolveRawExecution - the passthrough (WRAP) twin of resolveLeanExecution. Its execute returns
|
|
1637
|
+
* the upstream's tools/call result VERBATIM - multi-block content, isError, structuredContent
|
|
1638
|
+
* and any future fields untouched. A wrapped client must receive exactly what a direct client
|
|
1639
|
+
* would; the lean unwrap (single text block, throw-on-isError) destroys that. No unwrap, no
|
|
1640
|
+
* throw-on-isError: the envelope IS the product. Uses client.request() so a modern upstream
|
|
1641
|
+
* gets its per-request _meta trio.
|
|
1642
|
+
* @param {string} name surfaced downstream name
|
|
1643
|
+
* @param {any} args
|
|
1644
|
+
* @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
|
|
1645
|
+
*/
|
|
1646
|
+
resolveRawExecution(name, args, meta, out, progress) {
|
|
1647
|
+
if (!isNonEmptyString(name)) return null;
|
|
1648
|
+
for (const [upstreamId, tools] of this._tools) {
|
|
1649
|
+
if (!this._clients.has(upstreamId)) continue;
|
|
1650
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
1651
|
+
if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
|
|
1652
|
+
if (!Array.isArray(tools)) continue;
|
|
1653
|
+
for (const t of tools) {
|
|
1654
|
+
if (!t || !isNonEmptyString(t.name)) continue;
|
|
1655
|
+
if (this._surfacedName(upstreamId, t.name) !== name) continue;
|
|
1656
|
+
const realTool = t.name;
|
|
1657
|
+
const self = this;
|
|
1658
|
+
const execute = async () => {
|
|
1659
|
+
// A changed descriptor waiting for acceptance refuses here (the wrap has no meta-tools, so
|
|
1660
|
+
// the result names the UI and CLI routes).
|
|
1661
|
+
const refusal = self._approvalRefusal(upstreamId, realTool, 'raw');
|
|
1662
|
+
if (refusal) return refusal.envelope;
|
|
1663
|
+
// allowConnect:false - same freeze-avoidance rule as the lean path.
|
|
1664
|
+
const client = await self.ensureConnected(upstreamId, { allowConnect: false });
|
|
1665
|
+
self._warnIfPinned(upstreamId, realTool);
|
|
1666
|
+
const params = { name: realTool, arguments: args == null ? {} : args };
|
|
1667
|
+
// The caller's request-scoped _meta (progressToken, trace keys - already stripped of
|
|
1668
|
+
// protocol keys) rides the forward: a direct server would receive it, so the wrapped
|
|
1669
|
+
// one must too - without it the upstream never emits progress for the call. With a
|
|
1670
|
+
// progress sink the token is scoped to this call (_scopeProgress).
|
|
1671
|
+
const scoped = self._scopeProgress(upstreamId, meta, progress);
|
|
1672
|
+
if (scoped.meta && typeof scoped.meta === 'object' && Object.keys(scoped.meta).length) params._meta = scoped.meta;
|
|
1673
|
+
// `out.rpcId` is set synchronously at issue time - the caller's cancel-translation
|
|
1674
|
+
// hook (the same contract as forwardRaw's).
|
|
1675
|
+
try {
|
|
1676
|
+
return await client.request('tools/call', params, out);
|
|
1677
|
+
} finally {
|
|
1678
|
+
scoped.release();
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
return { execute, toolName: name, upstream: upstreamId };
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Close every cached client and clear the caches. Idempotent - safe to call twice and
|
|
1689
|
+
* safe when nothing was ever connected. NEVER throws (close() on McpClient never throws).
|
|
1690
|
+
* @returns {Promise<void>}
|
|
1691
|
+
*/
|
|
1692
|
+
async closeAll() {
|
|
1693
|
+
this._closed = true; // latch BEFORE teardown so any in-flight execute thunk refuses to re-spawn
|
|
1694
|
+
LIVE_WRITERS.delete(this._writer.id);
|
|
1695
|
+
for (const [, timer] of this._reconnectTimers) {
|
|
1696
|
+
try { clearTimeout(timer); } catch (_e) { /* ignore */ }
|
|
1697
|
+
}
|
|
1698
|
+
this._reconnectTimers.clear();
|
|
1699
|
+
this._reconnecting.clear(); // no in-flight attempt survives teardown (its finally is a no-op then)
|
|
1700
|
+
for (const [id, client] of this._clients) {
|
|
1701
|
+
try {
|
|
1702
|
+
if (client && typeof client.close === 'function') client.close();
|
|
1703
|
+
// Activity log (self-gating): the disconnect half. A reload closes the old aggregator here,
|
|
1704
|
+
// so a reconnect reads as a disconnect line followed by a connect line for the same id.
|
|
1705
|
+
logger.log({ type: 'mcp', event: 'disconnect', upstream: id });
|
|
1706
|
+
} catch (_e) {
|
|
1707
|
+
/* McpClient.close never throws, but never let teardown sink the loop */
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
this._clients.clear();
|
|
1711
|
+
this._tools.clear();
|
|
1712
|
+
this._rawTools.clear();
|
|
1713
|
+
this._pending.clear();
|
|
1714
|
+
this._withheld.clear();
|
|
1715
|
+
this._invalid.clear();
|
|
1716
|
+
this._progressRoutes.clear();
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* Connect a single upstream entry and cache the client + its discovered tools.
|
|
1723
|
+
* Throws on factory (isolation), connect, or listTools failure - callers decide
|
|
1724
|
+
* whether to collect (connectAll) or surface (discover) the error. On throw, the
|
|
1725
|
+
* caller is responsible for _discard()-ing the id.
|
|
1726
|
+
* @param {object} upstream a normalised ExposeStore upstream
|
|
1727
|
+
*/
|
|
1728
|
+
async _connectOne(upstream) {
|
|
1729
|
+
// The factory may throw (isolation guard) - that propagates out as a connect failure. Pass an
|
|
1730
|
+
// onClose so an UNEXPECTED upstream death triggers a BACKGROUND reconnect (not a lazy one in the
|
|
1731
|
+
// run path, which would freeze the stdio chain).
|
|
1732
|
+
const client = this._clientFactory(upstream, () => this._handleUpstreamDown(upstream.id));
|
|
1733
|
+
if (!client || typeof client.connect !== 'function') {
|
|
1734
|
+
throw new Error(`clientFactory returned an invalid client for "${upstream.id}"`);
|
|
1735
|
+
}
|
|
1736
|
+
// Bridge this upstream's server-initiated change-notifications to the transport (which fans them
|
|
1737
|
+
// out to modern subscriptions/listen streams). Filtered to the known change methods so arbitrary
|
|
1738
|
+
// upstream chatter never reaches a client. NEVER throws.
|
|
1739
|
+
client.onNotification = (n) => this._forwardUpstreamNotification(upstream.id, n);
|
|
1740
|
+
// Elicitation bridge: surface server-initiated requests with the upstream id + the live client (the
|
|
1741
|
+
// handler answers on it). Guarded so a handler bug can never break the client's read loop.
|
|
1742
|
+
client.onServerRequest = (m) => {
|
|
1743
|
+
try {
|
|
1744
|
+
if (this._closed) { client.respondToServerError(m.id, -32603, 'shutting down'); return; }
|
|
1745
|
+
this.onUpstreamServerRequest(upstream.id, m, client);
|
|
1746
|
+
} catch (_e) {
|
|
1747
|
+
try { client.respondToServerError(m.id, -32603, 'request failed'); } catch (_e2) { /* ignore */ }
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
// Cache the client BEFORE connect so a connect-failure path can still _discard() and
|
|
1751
|
+
// close any half-spawned child.
|
|
1752
|
+
this._clients.set(upstream.id, client);
|
|
1753
|
+
try {
|
|
1754
|
+
await client.connect();
|
|
1755
|
+
// The LIST is part of the connect contract: a handshake that cannot list its tools is a
|
|
1756
|
+
// FAILED connect to every caller, and the era observation must not outlive it either.
|
|
1757
|
+
await this._listAndCache(upstream.id);
|
|
1758
|
+
} catch (err) {
|
|
1759
|
+
// The failed attempt may have been era-HINTED (probe skipped on the memo). Whatever the
|
|
1760
|
+
// cause - handshake or the post-handshake list - a failure invalidates the observation:
|
|
1761
|
+
// clear it so the NEXT attempt re-runs the full negotiation. An upstream upgraded to
|
|
1762
|
+
// modern-only across a restart recovers on the following backoff retry instead of failing
|
|
1763
|
+
// hinted forever.
|
|
1764
|
+
this._eraMemo.delete(upstream.id);
|
|
1765
|
+
throw err;
|
|
1766
|
+
}
|
|
1767
|
+
// Remember the negotiated era so a death-driven reconnect can skip the probe (eraHint) - but
|
|
1768
|
+
// only a DEFINITIVE observation (the probe was ANSWERED). A probe that timed out proves
|
|
1769
|
+
// nothing about the upstream's eras, and memoising that fallback would pin a merely-SLOW
|
|
1770
|
+
// dual-era upstream to legacy on every future reconnect. A hinted connect (probe skipped)
|
|
1771
|
+
// leaves the existing memo in place.
|
|
1772
|
+
if (client.eraDefinitive !== false) this._eraMemo.set(upstream.id, client.era);
|
|
1773
|
+
// Re-arm any agreed resource subscriptions on the fresh process - every connect
|
|
1774
|
+
// path funnels through here, so reconnects and reloads replay uniformly.
|
|
1775
|
+
this._replaySubscriptions(upstream.id);
|
|
1776
|
+
// Activity log (self-gating; no-op unless logging is enabled): the upstream half of connect
|
|
1777
|
+
// logging. EVERY connect flows through here - startup connectAll, a live reload, a lazy
|
|
1778
|
+
// discover, and (later) a health-driven reconnect - so all of them are logged uniformly.
|
|
1779
|
+
logger.log({
|
|
1780
|
+
type: 'mcp',
|
|
1781
|
+
event: 'connect',
|
|
1782
|
+
upstream: upstream.id,
|
|
1783
|
+
command: upstream.command,
|
|
1784
|
+
tools: (this._tools.get(upstream.id) || []).length,
|
|
1785
|
+
});
|
|
1786
|
+
// A connect that worked ends any permanent-failure count or stop (every connect path runs here).
|
|
1787
|
+
this._permanentFailures.delete(upstream.id);
|
|
1788
|
+
this._stopped.delete(upstream.id);
|
|
1789
|
+
// Legacy shim STARTUP warning (the pin's contract: warn at startup AND per call, naming the
|
|
1790
|
+
// version). stderr, so the operator sees it without the client's content ever growing.
|
|
1791
|
+
if (upstream.legacyPin === true) {
|
|
1792
|
+
try {
|
|
1793
|
+
process.stderr.write(
|
|
1794
|
+
`[toolfunnel] legacy shim: upstream "${upstream.id}" is PINNED to MCP ${LEGACY_UPSTREAM_VERSION} ` +
|
|
1795
|
+
'(legacyPin) - this gateway keeps it working for modern clients; it cannot speak 2026-07-28 itself.\n'
|
|
1796
|
+
);
|
|
1797
|
+
} catch (_e) { /* never let a warning sink a connect */ }
|
|
1798
|
+
logger.log({ type: 'mcp', event: 'legacy_shim_connect', upstream: upstream.id, protocolVersion: LEGACY_UPSTREAM_VERSION });
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/** tools/list one connected upstream and cache the result; returns a clone of what is SERVED. */
|
|
1803
|
+
async _listAndCache(upstreamId) {
|
|
1804
|
+
const client = this._clients.get(upstreamId);
|
|
1805
|
+
if (!client) throw new Error(`no connected client for "${upstreamId}"`);
|
|
1806
|
+
const tools = await client.listTools();
|
|
1807
|
+
const safe = Array.isArray(tools) ? tools.filter((t) => t && isNonEmptyString(t.name)) : [];
|
|
1808
|
+
// Every descriptor enters here (connect, discover, list_changed). Drop structurally broken tools
|
|
1809
|
+
// FIRST (one bad tool never takes the upstream down), then keep the RAW listing and SERVE only
|
|
1810
|
+
// approved descriptors: every listing reads _tools, so none can show unapproved text.
|
|
1811
|
+
this._rawTools.set(upstreamId, this._dropInvalidTools(upstreamId, safe));
|
|
1812
|
+
this._applyApprovals(upstreamId, { fresh: true });
|
|
1813
|
+
return cloneTools(this._tools.get(upstreamId) || []);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Split a listing into servable tools and dropped ones (toolSchemaProblem); record the dropped
|
|
1817
|
+
* for invalidTools() and name each once on stderr. NEVER throws. */
|
|
1818
|
+
_dropInvalidTools(upstreamId, tools) {
|
|
1819
|
+
const keep = [];
|
|
1820
|
+
const dropped = [];
|
|
1821
|
+
for (const t of tools) {
|
|
1822
|
+
let problem;
|
|
1823
|
+
try { problem = toolSchemaProblem(t); } catch (err) { problem = `cannot be checked (${errMsg(err)})`; }
|
|
1824
|
+
if (!problem) { keep.push(t); continue; }
|
|
1825
|
+
dropped.push({ tool: t.name, problem });
|
|
1826
|
+
this._approvalNotice(`invalid:${upstreamId}:${t.name}:${problem}`,
|
|
1827
|
+
`[toolfunnel] upstream "${upstreamId}": dropped tool ${JSON.stringify(t.name)} - ${problem}. ` +
|
|
1828
|
+
'Its other tools are unaffected; the tool returns when the server fixes its descriptor.');
|
|
1829
|
+
}
|
|
1830
|
+
this._invalid.set(upstreamId, dropped);
|
|
1831
|
+
if (dropped.length) logger.log({ type: 'mcp', event: 'tool_dropped', upstream: upstreamId, tools: dropped });
|
|
1832
|
+
return keep;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/** Tools dropped by schema validation: [{upstream, tool, problem}] (fresh copies; the /health source). */
|
|
1836
|
+
invalidTools() {
|
|
1837
|
+
const out = [];
|
|
1838
|
+
for (const [upstream, list] of this._invalid) for (const d of list) out.push({ upstream, tool: d.tool, problem: d.problem });
|
|
1839
|
+
return out;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// ── tool descriptor approval ────────────────────────────────────────────────────────────────
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* Apply the approval rules (tool-approvals.js evaluate) to one upstream's RAW listing: fill _tools
|
|
1846
|
+
* with what may be served and _pending with what must refuse, persist a changed record, report on
|
|
1847
|
+
* stderr. An unreadable approvals file WITHHOLDS the upstream's tools (fail closed): treating it as
|
|
1848
|
+
* empty would approve everything as first use. NEVER throws.
|
|
1849
|
+
*
|
|
1850
|
+
* Only a FRESH listing ({fresh: true}, from _listAndCache) - or a first use, with no record yet -
|
|
1851
|
+
* WRITES the file and reports events. Every other call (the file watcher, a refusal's re-read, an
|
|
1852
|
+
* accept) only re-derives what this process serves: gateways sharing a home each run their own
|
|
1853
|
+
* child of the upstream, and a write from one's cached listing would erase another's pending
|
|
1854
|
+
* entry or reverse its upgrade, each write waking the other's watcher.
|
|
1855
|
+
* A write keeps other processes' pending entries (tool-approvals keepForeignPending).
|
|
1856
|
+
*/
|
|
1857
|
+
_applyApprovals(upstreamId, opts) {
|
|
1858
|
+
const fresh = !!(opts && opts.fresh);
|
|
1859
|
+
const raw = this._rawTools.get(upstreamId) || [];
|
|
1860
|
+
const withhold = (key, line) => {
|
|
1861
|
+
this._withheld.add(upstreamId);
|
|
1862
|
+
this._tools.set(upstreamId, []);
|
|
1863
|
+
this._pending.set(upstreamId, new Map());
|
|
1864
|
+
this._approvalNotice(key, line);
|
|
1865
|
+
};
|
|
1866
|
+
let doc;
|
|
1867
|
+
try {
|
|
1868
|
+
doc = approvals.loadApprovals(this._approvalsPath);
|
|
1869
|
+
} catch (err) {
|
|
1870
|
+
withhold(`unreadable:${upstreamId}`, `[toolfunnel] ${errMsg(err)} - the tools of upstream "${upstreamId}" are withheld ` +
|
|
1871
|
+
'until mcp/tool-approvals.json is repaired (or that upstream\'s entry removed from it - removing an entry approves its current tools as first use).');
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
this._approvalNoticed.delete(`unreadable:${upstreamId}`); // readable again: a later breakage reports anew
|
|
1875
|
+
const client = this._clients.get(upstreamId);
|
|
1876
|
+
const serverInfo = (client && client.initializeResult && client.initializeResult.serverInfo) || {};
|
|
1877
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
1878
|
+
const stored = approvals.getRecord(doc, upstreamId) || this._unsaved.get(upstreamId);
|
|
1879
|
+
const authoritative = fresh || !stored;
|
|
1880
|
+
let r;
|
|
1881
|
+
try {
|
|
1882
|
+
r = approvals.evaluate({
|
|
1883
|
+
record: stored,
|
|
1884
|
+
serverInfo,
|
|
1885
|
+
tools: raw,
|
|
1886
|
+
strict: !!(upstream && upstream.strictApproval === true),
|
|
1887
|
+
writer: this._writer,
|
|
1888
|
+
});
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
// A descriptor the hash cannot walk (nesting deep enough to exhaust the stack) is not served.
|
|
1891
|
+
withhold(`unhashable:${upstreamId}`, `[toolfunnel] cannot check the tool descriptors of upstream "${upstreamId}" ` +
|
|
1892
|
+
`(${errMsg(err)}) - its tools are withheld.`);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const before = this._pending.get(upstreamId);
|
|
1896
|
+
this._withheld.delete(upstreamId);
|
|
1897
|
+
this._tools.set(upstreamId, r.effective);
|
|
1898
|
+
this._pending.set(upstreamId, new Map(r.pending.map((p) => [p.tool, p])));
|
|
1899
|
+
if ((r.changed || this._unsaved.has(upstreamId)) && authoritative) { // an unsaved record retries
|
|
1900
|
+
try {
|
|
1901
|
+
// Read-modify-write the CURRENT file: the UI, the CLI and other gateways write it too.
|
|
1902
|
+
const latest = approvals.loadApprovals(this._approvalsPath);
|
|
1903
|
+
const current = approvals.getRecord(latest, upstreamId);
|
|
1904
|
+
const mine = this._ownPending.get(upstreamId) || new Set();
|
|
1905
|
+
const merged = approvals.keepForeignPending(current, r.record, (n, h) => mine.has(`${n}@${h}`), writerAlive);
|
|
1906
|
+
if (!current || approvals.canonical(current) !== approvals.canonical(merged)) {
|
|
1907
|
+
approvals.setRecord(latest, upstreamId, merged);
|
|
1908
|
+
try {
|
|
1909
|
+
approvals.saveApprovals(this._approvalsPath, latest);
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
this._unsaved.set(upstreamId, merged);
|
|
1912
|
+
throw err;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
this._unsaved.delete(upstreamId);
|
|
1916
|
+
this._approvalNoticed.delete(`save:${upstreamId}`); // saved again: a later failure reports anew
|
|
1917
|
+
this._ownPending.set(upstreamId, new Set(Object.keys(r.record.pending || {}).map((n) => `${n}@${r.record.pending[n].hash}`)));
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
if (!this._unsaved.has(upstreamId)) this._unsaved.set(upstreamId, r.record);
|
|
1920
|
+
this._approvalNotice(`save:${upstreamId}`, `[toolfunnel] could not save tool approvals for upstream "${upstreamId}": ` +
|
|
1921
|
+
`${errMsg(err)} - approvals are kept in memory until a save succeeds (changes still wait for acceptance).`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
// Events (first use, upgrade, server change) are decisions, reported once, when they are made.
|
|
1925
|
+
this._reportApprovals(upstreamId, authoritative ? r : { ...r, events: [] }, before, serverInfo);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** stderr + activity log for what an evaluation did. Pending changes are reported when the SET changes. */
|
|
1929
|
+
_reportApprovals(upstreamId, r, before, serverInfo) {
|
|
1930
|
+
const label = (s) => `${s.name || 'unnamed server'}${s.version ? ' ' + s.version : ''}`;
|
|
1931
|
+
const say = (line) => { try { process.stderr.write(approvals.printable(line) + '\n'); } catch (_e) { /* never sink the caller */ } };
|
|
1932
|
+
for (const e of r.events) {
|
|
1933
|
+
logger.log({ type: 'mcp', event: 'tool_approval', kind: e.kind, upstream: upstreamId, from: e.from, to: e.to, tools: e.tools });
|
|
1934
|
+
if (e.kind === 'first-use') {
|
|
1935
|
+
say(`[toolfunnel] upstream "${upstreamId}": approved ${e.tools.length} tool descriptor(s) on first use (${label(serverInfo)}). ` +
|
|
1936
|
+
'A later change at the same server version will wait for your acceptance.');
|
|
1937
|
+
} else if (e.kind === 'server-changed') {
|
|
1938
|
+
say(`[toolfunnel] upstream "${upstreamId}" now reports a DIFFERENT server ("${e.from}" -> "${e.to}"): approved the new ` +
|
|
1939
|
+
'descriptor(s) of ' + e.tools.map((t) => `${t.tool} (${t.reason})`).join(', ') +
|
|
1940
|
+
'. If you did not change this upstream\'s command, check it (strictApproval holds such changes for acceptance).');
|
|
1941
|
+
this._recordApprovalEvent({ upstream: upstreamId, kind: 'server-changed', from: e.from, to: e.to, tools: e.tools });
|
|
1942
|
+
} else if (e.kind === 'upgrade') {
|
|
1943
|
+
say(`[toolfunnel] upstream "${upstreamId}" upgraded from ${e.from} to ${e.to}: approved the new descriptor(s) of ` +
|
|
1944
|
+
e.tools.map((t) => `${t.tool} (${t.reason})`).join(', ') + '.');
|
|
1945
|
+
this._recordApprovalEvent({ upstream: upstreamId, kind: 'upgrade', from: e.from, to: e.to, tools: e.tools });
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
const sig = (list) => list.map((p) => `${p.tool}@${p.hash}`).sort().join('|');
|
|
1949
|
+
const now = sig(r.pending);
|
|
1950
|
+
if (r.pending.length && now !== sig(before ? [...before.values()] : [])) {
|
|
1951
|
+
const version = serverInfo && serverInfo.version;
|
|
1952
|
+
const stayed = !version || (r.record && r.record.server && r.record.server.version === version);
|
|
1953
|
+
say(`[toolfunnel] upstream "${upstreamId}": ${r.pending.length} tool(s) changed ` +
|
|
1954
|
+
(stayed ? `while the server version stayed ${version || '(not reported)'}` : `with the server upgrade to ${version} (strictApproval)`) +
|
|
1955
|
+
' - withheld until accepted: ' + r.pending.map((p) => `${p.tool} (${p.reason})`).join(', ') +
|
|
1956
|
+
`. Review, then accept with the tf_accept_change tool, the UI, or: toolfunnel accept ${upstreamId}`);
|
|
1957
|
+
logger.log({ type: 'mcp', event: 'tool_approval', kind: 'pending', upstream: upstreamId, tools: r.pending.map((p) => ({ tool: p.tool, reason: p.reason })) });
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
_recordApprovalEvent(e) {
|
|
1962
|
+
this._approvalEvents.push(Object.assign({ at: new Date().toISOString() }, e));
|
|
1963
|
+
if (this._approvalEvents.length > 50) this._approvalEvents.splice(0, this._approvalEvents.length - 50);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
_approvalNotice(key, line) {
|
|
1967
|
+
if (this._approvalNoticed.has(key)) return;
|
|
1968
|
+
this._approvalNoticed.add(key);
|
|
1969
|
+
try { process.stderr.write(approvals.printable(line) + '\n'); } catch (_e) { /* never sink the caller */ }
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* The refusal for a call to a tool whose changed descriptor waits for acceptance, or null.
|
|
1974
|
+
* `route`: 'lean' (text for a thrown error), 'curated' / 'raw' (an isError envelope). The wrap
|
|
1975
|
+
* ('raw') has no meta-tools, so it names only the UI and CLI routes.
|
|
1976
|
+
* @returns {{ text: string, envelope: object } | null}
|
|
1977
|
+
*/
|
|
1978
|
+
_approvalRefusal(upstreamId, realTool, route) {
|
|
1979
|
+
const waiting = this._pending.get(upstreamId);
|
|
1980
|
+
// Fast path: nothing waits for this tool AND the upstream is not withheld. A withheld upstream
|
|
1981
|
+
// has an EMPTY pending map, so without the flag a call resolved while its tool was pending (one
|
|
1982
|
+
// held at a permission prompt) would run once the watcher withheld it.
|
|
1983
|
+
if (!this._withheld.has(upstreamId) && (!waiting || !waiting.has(realTool))) return null;
|
|
1984
|
+
// The rare path, so re-read the approvals file first: an accept written by the UI, the CLI or a
|
|
1985
|
+
// management script (another process) counts from the very NEXT call, not from whenever the
|
|
1986
|
+
// file watcher fires.
|
|
1987
|
+
if (this._rawTools.has(upstreamId)) {
|
|
1988
|
+
const before = safeStringify(this._tools.get(upstreamId) || []);
|
|
1989
|
+
this._applyApprovals(upstreamId);
|
|
1990
|
+
if (safeStringify(this._tools.get(upstreamId) || []) !== before) this._signalToolsChanged(upstreamId);
|
|
1991
|
+
}
|
|
1992
|
+
const m = this._pending.get(upstreamId);
|
|
1993
|
+
const p = m && m.get(realTool);
|
|
1994
|
+
if (!p) {
|
|
1995
|
+
// Released by the re-read - unless it is no longer SERVED at all (the file turned unreadable,
|
|
1996
|
+
// which withholds the upstream, or the tool left the listing). Never fail open on that.
|
|
1997
|
+
if ((this._tools.get(upstreamId) || []).some((t) => t && t.name === realTool)) return null;
|
|
1998
|
+
const text = `ToolFunnel withheld this call: the tool ${JSON.stringify(realTool)} on upstream ${JSON.stringify(upstreamId)} ` +
|
|
1999
|
+
'is not approved right now (its approvals could not be read, or the server no longer lists it). See the gateway log.';
|
|
2000
|
+
return { text, envelope: { content: [{ type: 'text', text }], structuredContent: { toolfunnel: 'tool-withheld', upstream: upstreamId, tool: realTool }, isError: true } };
|
|
2001
|
+
}
|
|
2002
|
+
const arg = (s) => (/^[A-Za-z0-9_.:@/-]+$/.test(s) ? s : JSON.stringify(s));
|
|
2003
|
+
const client = this._clients.get(upstreamId);
|
|
2004
|
+
const version = client && client.initializeResult && client.initializeResult.serverInfo && client.initializeResult.serverInfo.version;
|
|
2005
|
+
const cli = `toolfunnel accept ${arg(upstreamId)} ${arg(realTool)} --hash ${p.hash}`;
|
|
2006
|
+
const acceptArgs = { upstream: upstreamId, tool: realTool, hash: p.hash };
|
|
2007
|
+
// The new text is never quoted here: an unapproved description is the attack, so it must not
|
|
2008
|
+
// reach the model's context through the refusal either. The user reviews it in the CLI or UI.
|
|
2009
|
+
const text = [
|
|
2010
|
+
`ToolFunnel withheld this call: the tool ${JSON.stringify(realTool)} on upstream ${JSON.stringify(upstreamId)} ` +
|
|
2011
|
+
`${p.reason === 'new' ? 'is new since its server was approved' : 'changed since it was approved'} (server version ${version || 'not reported'}).`,
|
|
2012
|
+
'That can be a legitimate update or a tampered server. Ask the user before accepting it.',
|
|
2013
|
+
`Changed: ${p.changed.join(', ')}. The user can review the new text with \`toolfunnel changes\` or in the ToolFunnel UI.`,
|
|
2014
|
+
// The agent route is a MANAGEMENT tool run through toolfunnel_run_tool, not a fifth meta-tool:
|
|
2015
|
+
// the public surface stays at four.
|
|
2016
|
+
route === 'raw'
|
|
2017
|
+
? `To accept it after the user agrees: the ToolFunnel UI, or run: ${cli}`
|
|
2018
|
+
: 'To accept it after the user agrees: run the tool tf_accept_change - toolfunnel_run_tool with ' +
|
|
2019
|
+
`${JSON.stringify({ name: 'tf_accept_change', args: acceptArgs })} - or use the ToolFunnel UI, or run: ${cli}`,
|
|
2020
|
+
].join('\n');
|
|
2021
|
+
const structuredContent = {
|
|
2022
|
+
toolfunnel: 'tool-change-withheld',
|
|
2023
|
+
upstream: upstreamId,
|
|
2024
|
+
tool: realTool,
|
|
2025
|
+
reason: p.reason,
|
|
2026
|
+
serverVersion: version || null,
|
|
2027
|
+
changed: p.changed,
|
|
2028
|
+
hash: p.hash,
|
|
2029
|
+
accept: route === 'raw' ? { cli } : { tool: 'tf_accept_change', via: 'toolfunnel_run_tool', arguments: acceptArgs, cli },
|
|
2030
|
+
};
|
|
2031
|
+
return { text, envelope: { content: [{ type: 'text', text }], structuredContent, isError: true } };
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** Accept one held change, only while it is still the one at `expectHash`, and re-apply it here.
|
|
2035
|
+
* The MRTR route - the UI, the CLI and tf_accept_change write the file from their own process.
|
|
2036
|
+
* @returns the acceptInFile result. Throws on an unreadable approvals file. */
|
|
2037
|
+
acceptChange(upstreamId, toolName, expectHash) {
|
|
2038
|
+
const res = approvals.acceptInFile(this._approvalsPath, upstreamId, toolName, expectHash);
|
|
2039
|
+
if (res.accepted.length) {
|
|
2040
|
+
logger.log({ type: 'mcp', event: 'tool_approval', kind: 'accepted', upstream: upstreamId, tools: res.accepted });
|
|
2041
|
+
try { process.stderr.write(approvals.printable(`[toolfunnel] accepted the changed descriptor(s) of upstream "${upstreamId}": ${res.accepted.join(', ')}.`) + '\n'); } catch (_e) { /* ignore */ }
|
|
2042
|
+
this.reloadApprovals();
|
|
2043
|
+
}
|
|
2044
|
+
return res;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** Re-apply approvals to every listed upstream (the approvals file changed on disk - a UI or CLI
|
|
2048
|
+
* accept). Signals a surface change for each upstream whose served tools changed. NEVER throws. */
|
|
2049
|
+
reloadApprovals() {
|
|
2050
|
+
for (const upstreamId of [...this._rawTools.keys()]) {
|
|
2051
|
+
try {
|
|
2052
|
+
const before = safeStringify(this._tools.get(upstreamId) || []);
|
|
2053
|
+
this._applyApprovals(upstreamId);
|
|
2054
|
+
if (safeStringify(this._tools.get(upstreamId) || []) !== before) this._signalToolsChanged(upstreamId);
|
|
2055
|
+
} catch (_e) { /* one upstream never blocks the rest */ }
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** What waits for acceptance, and recent upgrades - the /health, CLI and UI source. Fresh copies. */
|
|
2060
|
+
toolChanges() {
|
|
2061
|
+
const pending = [];
|
|
2062
|
+
for (const [upstream, m] of this._pending) {
|
|
2063
|
+
for (const p of m.values()) pending.push({ upstream, tool: p.tool, reason: p.reason, changed: p.changed, hash: p.hash });
|
|
2064
|
+
}
|
|
2065
|
+
return { pending, upgraded: this._approvalEvents.map((e) => JSON.parse(JSON.stringify(e))) };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** Tear down + forget a cached client (used on a failed connect). Never throws. */
|
|
2069
|
+
_discard(upstreamId) {
|
|
2070
|
+
const client = this._clients.get(upstreamId);
|
|
2071
|
+
if (client && typeof client.close === 'function') {
|
|
2072
|
+
try { client.close(); } catch (_e) { /* never throw */ }
|
|
2073
|
+
}
|
|
2074
|
+
this._clients.delete(upstreamId);
|
|
2075
|
+
this._tools.delete(upstreamId);
|
|
2076
|
+
this._rawTools.delete(upstreamId);
|
|
2077
|
+
this._pending.delete(upstreamId);
|
|
2078
|
+
this._withheld.delete(upstreamId);
|
|
2079
|
+
this._invalid.delete(upstreamId);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
/**
|
|
2083
|
+
* Shared lookup behind isExposed / resolveExposedExecution: find the ENABLED expose
|
|
2084
|
+
* entry whose downstream name === `name`, whose upstream is connected AND enabled, and
|
|
2085
|
+
* whose real tool the upstream advertises. Returns the resolution context or null.
|
|
2086
|
+
* @param {string} name
|
|
2087
|
+
* @returns {{ client:object, realTool:string, upstream:string, exposedName:string } | null}
|
|
2088
|
+
*/
|
|
2089
|
+
_findExposedExecution(name) {
|
|
2090
|
+
if (!isNonEmptyString(name)) return null;
|
|
2091
|
+
let exposed;
|
|
2092
|
+
try {
|
|
2093
|
+
exposed = this._store.listExposed({ enabledOnly: true });
|
|
2094
|
+
} catch (_err) {
|
|
2095
|
+
return null;
|
|
2096
|
+
}
|
|
2097
|
+
for (const e of exposed) {
|
|
2098
|
+
if (!e) continue;
|
|
2099
|
+
if (this._store.exposedName(e) !== name) continue;
|
|
2100
|
+
const client = this._clients.get(e.upstream);
|
|
2101
|
+
if (!client) continue; // upstream not connected
|
|
2102
|
+
const upstream = this._store.getUpstream(e.upstream);
|
|
2103
|
+
if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
|
|
2104
|
+
const upstreamTools = this._tools.get(e.upstream) || [];
|
|
2105
|
+
if (!upstreamTools.some((t) => t && t.name === e.tool)) continue; // tool not advertised
|
|
2106
|
+
return { client, realTool: e.tool, upstream: e.upstream, exposedName: name };
|
|
2107
|
+
}
|
|
2108
|
+
return null;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// ── small helpers (no external deps) ───────────────────────────────────────────
|
|
2113
|
+
|
|
2114
|
+
/** Extract a clean message from any thrown value. */
|
|
2115
|
+
function errMsg(err) {
|
|
2116
|
+
return (err && err.message) || String(err);
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/** JSON.stringify that never throws (circular/oddball values fall back to String). */
|
|
2120
|
+
function safeStringify(value) {
|
|
2121
|
+
try { return JSON.stringify(value); } catch (_e) { return String(value); }
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* Turn an upstream MCP tools/call envelope { content:[...], isError? } into a clean payload for the
|
|
2126
|
+
* LEAN run path. A single text block -> its string (so toolfunnel_run_tool returns "pong", matching
|
|
2127
|
+
* what the curated-direct content passthrough surfaces); otherwise the content array; otherwise the
|
|
2128
|
+
* value as-is when it isn't an envelope. This is what avoids double-wrapping the envelope as JSON
|
|
2129
|
+
* inside wrapProtocolResult (the curated-direct path passes the envelope through transparently
|
|
2130
|
+
* instead; the lean path goes through protocol.runTool -> wrapProtocolResult, so it unwraps here).
|
|
2131
|
+
* NOTE: an upstream isError:true is surfaced as its text content with ok:true (homogeneous
|
|
2132
|
+
* with how local tools surface error text) - mapping isError -> ok:false is a later concern.
|
|
2133
|
+
* @param {*} envelope
|
|
2134
|
+
* @returns {*}
|
|
2135
|
+
*/
|
|
2136
|
+
function unwrapEnvelope(envelope) {
|
|
2137
|
+
if (!envelope || typeof envelope !== 'object') return envelope;
|
|
2138
|
+
const content = envelope.content;
|
|
2139
|
+
if (Array.isArray(content)) {
|
|
2140
|
+
if (content.length === 1 && content[0] && content[0].type === 'text' && typeof content[0].text === 'string') {
|
|
2141
|
+
return content[0].text;
|
|
2142
|
+
}
|
|
2143
|
+
return content;
|
|
2144
|
+
}
|
|
2145
|
+
return envelope;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/** Plain-JSON deep clone (schemas are JSON; structuredClone may be absent on old node). */
|
|
2149
|
+
function cloneJson(value) {
|
|
2150
|
+
return JSON.parse(JSON.stringify(value));
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** The upstream tool-def metadata the funnel FORWARDS onto advertised defs.
|
|
2154
|
+
* ICONS ARE WITHHELD by policy: 2025-11-25 and 2026-07-28 both require icon consumers to
|
|
2155
|
+
* verify icon URIs are SAME-ORIGIN with the server, so gateway-forwarded icons are third-party
|
|
2156
|
+
* URLs a conforming client must reject - or worse, trust as ours. Reversible (adding a field
|
|
2157
|
+
* later is non-breaking); the decision is open - forwarding them later flips it here. */
|
|
2158
|
+
const FORWARDED_TOOL_META = Object.freeze(['title', 'annotations', 'outputSchema', '_meta', 'execution']);
|
|
2159
|
+
|
|
2160
|
+
/** Selectively copy the forwardable metadata from a RAW discovered def onto an advertised def.
|
|
2161
|
+
* NEVER a spread: lean defs carry internal routing ids (upstream/tool), and a spread would
|
|
2162
|
+
* also inherit unknown future fields sight-unseen (the explicit selective-add rule). Mutates + returns def. */
|
|
2163
|
+
function withForwardedToolMeta(def, raw) {
|
|
2164
|
+
if (!raw || typeof raw !== 'object') return def;
|
|
2165
|
+
for (const k of FORWARDED_TOOL_META) {
|
|
2166
|
+
if (raw[k] !== undefined && def[k] === undefined) def[k] = cloneJson(raw[k]);
|
|
2167
|
+
}
|
|
2168
|
+
return def;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/** HTTP field-name token syntax (RFC 9110 §5.1) - the constraint on x-mcp-header values. */
|
|
2172
|
+
const XMCP_TCHAR = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* Strip INVALID `x-mcp-header` annotations from an advertised inputSchema, in place (ingest
|
|
2176
|
+
* hygiene). The spec makes a single malformed annotation invalidate the WHOLE tool -
|
|
2177
|
+
* conforming Streamable-HTTP clients MUST drop it from tools/list - so forwarding a broken
|
|
2178
|
+
* upstream annotation verbatim makes the tool vanish client-side. Valid = on a pure
|
|
2179
|
+
* `properties` chain from the root, a non-empty tchar token, on a string/integer/boolean
|
|
2180
|
+
* param, case-insensitively unique among the valid set. Everything else is deleted (the TOOL
|
|
2181
|
+
* survives; the annotation was the contaminant). The WRAP path stays raw-verbatim - a wrapped
|
|
2182
|
+
* server's brokenness is presented faithfully, exactly as a direct connection would.
|
|
2183
|
+
* @param {object} schema the advertised (already-cloned) inputSchema - mutated
|
|
2184
|
+
* @returns {object} schema
|
|
2185
|
+
*/
|
|
2186
|
+
function sanitiseXMcpHeaders(schema) {
|
|
2187
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
2188
|
+
const valid = []; // { node, name } - candidates that pass every per-node rule
|
|
2189
|
+
const strip = []; // nodes whose annotation is deleted
|
|
2190
|
+
(function walk(node, onChain) {
|
|
2191
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
|
2192
|
+
if (Object.prototype.hasOwnProperty.call(node, 'x-mcp-header')) {
|
|
2193
|
+
const v = node['x-mcp-header'];
|
|
2194
|
+
const okType = node.type === 'string' || node.type === 'integer' || node.type === 'boolean';
|
|
2195
|
+
if (onChain && typeof v === 'string' && XMCP_TCHAR.test(v) && okType) valid.push({ node, name: v.toLowerCase() });
|
|
2196
|
+
else strip.push(node);
|
|
2197
|
+
}
|
|
2198
|
+
for (const k of Object.keys(node)) {
|
|
2199
|
+
const child = node[k];
|
|
2200
|
+
if (!child || typeof child !== 'object') continue;
|
|
2201
|
+
if (k === 'properties' && !Array.isArray(child)) {
|
|
2202
|
+
for (const sub of Object.values(child)) walk(sub, onChain); // the chain continues
|
|
2203
|
+
} else if (Array.isArray(child)) {
|
|
2204
|
+
for (const el of child) walk(el, false); // items/oneOf/... break the chain
|
|
2205
|
+
} else {
|
|
2206
|
+
walk(child, false);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
})(schema, true);
|
|
2210
|
+
const seen = new Map();
|
|
2211
|
+
for (const e of valid) seen.set(e.name, (seen.get(e.name) || 0) + 1);
|
|
2212
|
+
for (const e of valid) if (seen.get(e.name) > 1) strip.push(e.node); // ambiguous duplicates: all go
|
|
2213
|
+
for (const n of strip) delete n['x-mcp-header'];
|
|
2214
|
+
return schema;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/** Clone an array of tool defs so callers can't mutate the aggregator's cache. */
|
|
2218
|
+
function cloneTools(tools) {
|
|
2219
|
+
return Array.isArray(tools) ? tools.map((t) => cloneJson(t)) : [];
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
module.exports = {
|
|
2223
|
+
Aggregator,
|
|
2224
|
+
// exported for unit tests / reuse
|
|
2225
|
+
defaultClientFactory,
|
|
2226
|
+
// The documented permanent-failure ceiling and its classifier.
|
|
2227
|
+
PERMANENT_RETRY_LIMIT,
|
|
2228
|
+
PERMANENT_ERROR_CODES,
|
|
2229
|
+
isPermanentConnectError,
|
|
2230
|
+
// Structural tool schema validation (drop one tool, never the upstream).
|
|
2231
|
+
toolSchemaProblem,
|
|
2232
|
+
MAX_SCHEMA_DEPTH,
|
|
2233
|
+
looksLikePath,
|
|
2234
|
+
isInside,
|
|
2235
|
+
unwrapEnvelope,
|
|
2236
|
+
DEFAULT_ROOT,
|
|
2237
|
+
// 3.7: the forwardable def metadata + the selective-add - server.js's hot-promotion
|
|
2238
|
+
// projection uses the same list so the two advertised paths can never drift apart again.
|
|
2239
|
+
FORWARDED_TOOL_META,
|
|
2240
|
+
withForwardedToolMeta,
|
|
2241
|
+
// 4.3: ingest hygiene for x-mcp-header annotations (exported for tests).
|
|
2242
|
+
sanitiseXMcpHeaders,
|
|
2243
|
+
// A3: the wrap-time SECURITY NOTICE (bin/toolfunnel.js) enumerates the same classes the guard
|
|
2244
|
+
// judges - shared lists, so the two surfaces can never classify differently.
|
|
2245
|
+
isDriveRelative,
|
|
2246
|
+
CODE_OPT_ENV,
|
|
2247
|
+
CODE_PATH_ENV,
|
|
2248
|
+
};
|