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/mcp-client.js
CHANGED
|
@@ -1,1070 +1,1174 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* mcp-client.js - the MCP CLIENT: the aggregator's per-upstream connection.
|
|
5
|
-
*
|
|
6
|
-
* This is the REVERSE of src/mcp/server.js. Where server.js is a JSON-RPC 2.0
|
|
7
|
-
* server that the CLI connects TO, this is a JSON-RPC 2.0 client that spawns an
|
|
8
|
-
* UPSTREAM MCP server as a child process and talks to it over stdio. The
|
|
9
|
-
* aggregator owns one McpClient per upstream MCP server, lists their tools, and
|
|
10
|
-
* routes curated calls through them.
|
|
11
|
-
*
|
|
12
|
-
* Transport contract (mirrors server.js's framing helpers but in the other direction):
|
|
13
|
-
* WRITE: newline-delimited JSON - one compact message per line, which is what real
|
|
14
|
-
* SDK-based MCP servers read on stdin (Content-Length framing silently broke
|
|
15
|
-
* every real upstream - see frameRequest()).
|
|
16
|
-
* READ: tolerate BOTH `Content-Length:` framing AND newline-delimited JSON, so the
|
|
17
|
-
* same client works against a real MCP server AND against our own server.js
|
|
18
|
-
* (which WRITES newline-delimited JSON). This is server.js's dual-drain logic
|
|
19
|
-
* re-used in the client.
|
|
20
|
-
*
|
|
21
|
-
* JSON-RPC requests are matched to responses by `id`. Every request carries a
|
|
22
|
-
* timeout (requestTimeoutMs) - a slow/dead upstream rejects the in-flight promise
|
|
23
|
-
* instead of leaking it. Notifications (no `id`) from the server are ignored.
|
|
24
|
-
*
|
|
25
|
-
* SAFETY / defensiveness:
|
|
26
|
-
* - close() is idempotent and NEVER leaves a zombie child: it ends stdin, then if the
|
|
27
|
-
* child is still alive tree-kills it (win32: taskkill /T /F; else child.kill()).
|
|
28
|
-
* - connect() that fails (spawn error or initialize timeout) calls close() so no child
|
|
29
|
-
* is left running, then rejects with a clear error.
|
|
30
|
-
* - A child that exits while requests are in flight rejects every waiter (no hang).
|
|
31
|
-
* - spawnImpl is injectable so a unit test can fake the child, but the real round-trip
|
|
32
|
-
* against the fixture is the load-bearing test.
|
|
33
|
-
*
|
|
34
|
-
* CommonJS only. Node built-ins only. No @modelcontextprotocol/sdk.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
const childProcess = require('node:child_process');
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* @param {string
|
|
70
|
-
* @
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
const
|
|
79
|
-
return { command
|
|
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
|
-
|
|
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
|
-
this.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this.
|
|
198
|
-
this.
|
|
199
|
-
this.
|
|
200
|
-
this.
|
|
201
|
-
|
|
202
|
-
this.
|
|
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
|
-
* @param {
|
|
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
|
-
this.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
if (this.
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
this.
|
|
896
|
-
if (this.
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*/
|
|
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
|
-
|
|
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
|
-
if (
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
if (
|
|
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
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-client.js - the MCP CLIENT: the aggregator's per-upstream connection.
|
|
5
|
+
*
|
|
6
|
+
* This is the REVERSE of src/mcp/server.js. Where server.js is a JSON-RPC 2.0
|
|
7
|
+
* server that the CLI connects TO, this is a JSON-RPC 2.0 client that spawns an
|
|
8
|
+
* UPSTREAM MCP server as a child process and talks to it over stdio. The
|
|
9
|
+
* aggregator owns one McpClient per upstream MCP server, lists their tools, and
|
|
10
|
+
* routes curated calls through them.
|
|
11
|
+
*
|
|
12
|
+
* Transport contract (mirrors server.js's framing helpers but in the other direction):
|
|
13
|
+
* WRITE: newline-delimited JSON - one compact message per line, which is what real
|
|
14
|
+
* SDK-based MCP servers read on stdin (Content-Length framing silently broke
|
|
15
|
+
* every real upstream - see frameRequest()).
|
|
16
|
+
* READ: tolerate BOTH `Content-Length:` framing AND newline-delimited JSON, so the
|
|
17
|
+
* same client works against a real MCP server AND against our own server.js
|
|
18
|
+
* (which WRITES newline-delimited JSON). This is server.js's dual-drain logic
|
|
19
|
+
* re-used in the client.
|
|
20
|
+
*
|
|
21
|
+
* JSON-RPC requests are matched to responses by `id`. Every request carries a
|
|
22
|
+
* timeout (requestTimeoutMs) - a slow/dead upstream rejects the in-flight promise
|
|
23
|
+
* instead of leaking it. Notifications (no `id`) from the server are ignored.
|
|
24
|
+
*
|
|
25
|
+
* SAFETY / defensiveness:
|
|
26
|
+
* - close() is idempotent and NEVER leaves a zombie child: it ends stdin, then if the
|
|
27
|
+
* child is still alive tree-kills it (win32: taskkill /T /F; else child.kill()).
|
|
28
|
+
* - connect() that fails (spawn error or initialize timeout) calls close() so no child
|
|
29
|
+
* is left running, then rejects with a clear error.
|
|
30
|
+
* - A child that exits while requests are in flight rejects every waiter (no hang).
|
|
31
|
+
* - spawnImpl is injectable so a unit test can fake the child, but the real round-trip
|
|
32
|
+
* against the fixture is the load-bearing test.
|
|
33
|
+
*
|
|
34
|
+
* CommonJS only. Node built-ins only. No @modelcontextprotocol/sdk.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const childProcess = require('node:child_process');
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const path = require('node:path');
|
|
40
|
+
|
|
41
|
+
const JSONRPC = '2.0';
|
|
42
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
43
|
+
// The 2026-07-28 ("modern") protocol version this client can SPEAK to a modern upstream. Over
|
|
44
|
+
// stdio the modern era needs only per-request `_meta` - the Mcp-Method/Mcp-Name/MCP-Protocol-Version
|
|
45
|
+
// headers are HTTP-transport-only, so a stdio client is modern by adding `_meta` + skipping the
|
|
46
|
+
// initialize handshake.
|
|
47
|
+
const { MODERN_PROTOCOL_VERSION } = require('./modern'); // the ONE definition (version-literal.test.js pins it)
|
|
48
|
+
const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cross-platform launch resolution for the upstream command.
|
|
52
|
+
*
|
|
53
|
+
* POSIX: pass through unchanged - execvp resolves `npx` / `uvx` / `node` / ... on PATH.
|
|
54
|
+
*
|
|
55
|
+
* Windows: child_process with shell:false CANNOT run a `.cmd` / `.bat` shim - a bare name ENOENTs,
|
|
56
|
+
* and an explicit `.cmd` throws EINVAL (the CVE-2024-27980 mitigation). Most MCP servers launch via
|
|
57
|
+
* exactly such shims (`npx`, `npm`, `uvx`, `pnpm`, ...), so on Windows route anything that isn't already
|
|
58
|
+
* a concrete `.exe`/`.com` through cmd.exe, which resolves PATHEXT shims. The args stay DISCRETE
|
|
59
|
+
* (never shell-concatenated into one string) and there is none of the `shell:true` DEP0190 hazard.
|
|
60
|
+
* HONEST CAVEAT: because the spawned binary here is cmd.exe itself (an .exe), Node applies its
|
|
61
|
+
* STANDARD argv quoting - not cmd.exe ^-metacharacter escaping (that special-casing only fires when
|
|
62
|
+
* the command IS the .cmd/.bat). An arg containing cmd metacharacters (& | > < ") outside quotes can
|
|
63
|
+
* therefore still be reinterpreted by cmd.exe. These args come from the operator's own expose.json,
|
|
64
|
+
* so this is a correctness caveat on operator-authored config, not a privilege boundary; the fully
|
|
65
|
+
* escaped alternative (resolve the real interpreter à la auth/install.js's `node + npm-cli.js` and
|
|
66
|
+
* spawn it directly) is the upgrade path if it ever bites. close()'s taskkill /T still reaps the
|
|
67
|
+
* whole cmd -> shim -> server process tree.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} command the configured upstream command (e.g. "npx", "node", an abs path)
|
|
70
|
+
* @param {string[]} args the configured argv
|
|
71
|
+
* @returns {{ command: string, args: string[] }} what to actually hand to spawn()
|
|
72
|
+
*/
|
|
73
|
+
function winLaunch(command, args) {
|
|
74
|
+
const argv = Array.isArray(args) ? args : [];
|
|
75
|
+
if (process.platform !== 'win32') return { command, args: argv };
|
|
76
|
+
// A concrete executable can be spawned directly - no shim resolution needed. A relative one
|
|
77
|
+
// (`../../x/node.exe`, the drive-independent config) resolves against the spawn's cwd.
|
|
78
|
+
const pathShaped = path.isAbsolute(command) || /[\\/]/.test(command);
|
|
79
|
+
if (pathShaped && /\.(exe|com)$/i.test(command)) return { command, args: argv };
|
|
80
|
+
// Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT. cmd reads a '/'
|
|
81
|
+
// as a switch, so a path reaches it with backslashes.
|
|
82
|
+
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
83
|
+
return { command: comspec, args: ['/c', pathShaped ? command.replace(/\//g, '\\') : command, ...argv] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Windows only: is `command` definitely absent from everywhere cmd.exe would look - the working
|
|
88
|
+
* directory, then each PATH directory, trying each PATHEXT extension?
|
|
89
|
+
*
|
|
90
|
+
* winLaunch routes every non-.exe command through cmd.exe, so a missing command never fails the
|
|
91
|
+
* spawn with ENOENT: cmd prints a LOCALISED "is not recognized" line and exits 1, exactly like a
|
|
92
|
+
* server that crashed on start. The owner classifies permanent failures by error code (the
|
|
93
|
+
* aggregator's reconnect ceiling), so connect() asks this AFTER a failed connect and stamps ENOENT.
|
|
94
|
+
* It never blocks a spawn. Conservative by construction - it answers true only when every
|
|
95
|
+
* candidate is definitely absent: a stat error other than ENOENT/ENOTDIR (an App Execution Alias
|
|
96
|
+
* can refuse stat) counts as found. A PATH entry is split quote-aware (cmd.exe accepts
|
|
97
|
+
* "C:\a;b" as one directory), and a %VAR% the env cannot expand stays literal, as it does for cmd.exe.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} command
|
|
100
|
+
* @param {string} [cwd] the child's working directory
|
|
101
|
+
* @param {object} [env] the child's merged environment
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function commandMissing(command, cwd, env) {
|
|
105
|
+
if (process.platform !== 'win32' || typeof command !== 'string' || command.length === 0) return false;
|
|
106
|
+
const e = env || process.env;
|
|
107
|
+
// Env names are case-insensitive on Windows and a merged env can hold both `Path` and `PATH`:
|
|
108
|
+
// take every spelling (more candidates can only make the answer more conservative).
|
|
109
|
+
const all = (name) => Object.keys(e).filter((k) => k.toUpperCase() === name).map((k) => String(e[k] || ''));
|
|
110
|
+
const exts = all('PATHEXT').join(';').split(';').filter(Boolean);
|
|
111
|
+
const candidates = [command, ...(exts.length ? exts : ['.COM', '.EXE', '.BAT', '.CMD']).map((x) => command + x)];
|
|
112
|
+
const base = typeof cwd === 'string' && cwd.length ? path.resolve(cwd) : process.cwd();
|
|
113
|
+
const isPath = /[\\/]/.test(command) || path.isAbsolute(command);
|
|
114
|
+
const dirs = [base];
|
|
115
|
+
if (!isPath) {
|
|
116
|
+
for (const d of all('PATH').join(';').match(/(?:"[^"]*"|[^;])+/g) || []) {
|
|
117
|
+
const dir = d.replace(/"/g, '').trim();
|
|
118
|
+
if (!dir) continue;
|
|
119
|
+
// Expand %VAR% from the child's env where we can; an unknown one stays literal. Either way the
|
|
120
|
+
// extra candidate can only turn up a match (the safe direction), never hide one.
|
|
121
|
+
dirs.push(dir.replace(/%([^%]+)%/g, (m, v) => { const hit = all(v.toUpperCase())[0]; return hit !== undefined ? hit : m; }));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const dir of dirs) {
|
|
125
|
+
for (const name of candidates) {
|
|
126
|
+
try {
|
|
127
|
+
fs.statSync(path.resolve(dir, name));
|
|
128
|
+
return false;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* McpClient - a hand-rolled JSON-RPC 2.0 client over a child process's stdio.
|
|
139
|
+
*
|
|
140
|
+
* Lifecycle: `new McpClient(opts)` -> `await connect()` -> `listTools()` / `callTool()`
|
|
141
|
+
* -> `close()` (idempotent).
|
|
142
|
+
*/
|
|
143
|
+
// Payload-bearing methods get the LONG window (toolTimeoutMs): a tool call legitimately runs as
|
|
144
|
+
// long as the tool runs. Everything else (handshake, lists, discover, ping) keeps the SHORT
|
|
145
|
+
// requestTimeoutMs - that is the dead-upstream detector.
|
|
146
|
+
const PAYLOAD_METHODS = new Set(['tools/call', 'prompts/get', 'resources/read']);
|
|
147
|
+
|
|
148
|
+
// Hard ceiling on one buffered stdout message (matches http-transport's MAX_BODY_BYTES). A
|
|
149
|
+
// declared Content-Length over the cap is refused and its bytes discarded as they arrive, so
|
|
150
|
+
// the pipe stays in frame; a buffer past the cap with NO complete frame has no resync point,
|
|
151
|
+
// so the connection is dropped (the owner's reconnect machinery takes over). Without the cap
|
|
152
|
+
// a single giant unframed line from a broken upstream grew this buffer until the whole gateway
|
|
153
|
+
// process died.
|
|
154
|
+
const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
|
|
155
|
+
|
|
156
|
+
class McpClient {
|
|
157
|
+
/**
|
|
158
|
+
* @param {object} opts
|
|
159
|
+
* @param {string} [opts.id] caller-chosen id for this upstream (diagnostics only)
|
|
160
|
+
* @param {string} opts.command executable to spawn (e.g. process.execPath)
|
|
161
|
+
* @param {string[]} [opts.args] argv for the child (e.g. [serverPath])
|
|
162
|
+
* @param {object} [opts.env] extra env merged over process.env for the child
|
|
163
|
+
* @param {string} [opts.cwd] child working directory
|
|
164
|
+
* @param {Function} [opts.spawnImpl] injectable spawn (default node:child_process.spawn)
|
|
165
|
+
* @param {number} [opts.requestTimeoutMs] control-plane timeout in ms (default 10000) -
|
|
166
|
+
* handshake/list/discover/ping; the dead-upstream detector
|
|
167
|
+
* @param {number} [opts.toolTimeoutMs] PAYLOAD timeout in ms (default 120000) for
|
|
168
|
+
* tools/call | prompts/get | resources/read; a
|
|
169
|
+
* token-matched progress beat re-arms it
|
|
170
|
+
* @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
|
|
171
|
+
* while not deliberately close()d). Lets the owner
|
|
172
|
+
* (the aggregator) schedule a background reconnect.
|
|
173
|
+
* @param {'legacy'|'modern'} [opts.eraHint] the owner's memo of the era negotiated LAST connect.
|
|
174
|
+
* 'legacy' skips the server/discover probe (a reconnect
|
|
175
|
+
* goes straight to initialize); 'modern' is a no-op (the
|
|
176
|
+
* probe IS the modern handshake). An observation, not a
|
|
177
|
+
* policy: the owner clears its memo when a hinted
|
|
178
|
+
* connect fails. modernOnly always wins over the hint.
|
|
179
|
+
*/
|
|
180
|
+
constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs, toolTimeoutMs, onClose, forceLegacy = false, modernOnly = false, eraHint, onNotification, clientInfo } = {}) {
|
|
181
|
+
if (typeof command !== 'string' || command.length === 0) {
|
|
182
|
+
throw new Error('McpClient: "command" (non-empty string) is required');
|
|
183
|
+
}
|
|
184
|
+
this.id = typeof id === 'string' && id.length ? id : 'mcp-client';
|
|
185
|
+
// clientInfo: the identity this client PRESENTS to the upstream (legacy initialize and the
|
|
186
|
+
// modern _meta trio). Defaults to the built-in - absent the opt, wire behaviour is byte-identical
|
|
187
|
+
// to before. The wrap's identity mirroring hands the REAL downstream client's info through here.
|
|
188
|
+
// The FULL object is carried (title etc.), not a name/version whitelist - the wrap's serverInfo
|
|
189
|
+
// direction proved a whitelist silently eats spec fields.
|
|
190
|
+
this._clientInfo =
|
|
191
|
+
clientInfo && typeof clientInfo === 'object' &&
|
|
192
|
+
typeof clientInfo.name === 'string' && clientInfo.name.length
|
|
193
|
+
? Object.freeze(Object.assign({}, clientInfo, {
|
|
194
|
+
version: typeof clientInfo.version === 'string' ? clientInfo.version : '0.0.0',
|
|
195
|
+
}))
|
|
196
|
+
: CLIENT_INFO;
|
|
197
|
+
this._command = command;
|
|
198
|
+
this._args = Array.isArray(args) ? args.slice() : [];
|
|
199
|
+
this._env = env && typeof env === 'object' ? env : {};
|
|
200
|
+
this._cwd = cwd;
|
|
201
|
+
this._spawn = typeof spawnImpl === 'function' ? spawnImpl : childProcess.spawn;
|
|
202
|
+
this._requestTimeoutMs =
|
|
203
|
+
Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 10000;
|
|
204
|
+
// An EXPLICITLY configured control window also governs the era probe in connect(): the
|
|
205
|
+
// operator opted into slower connects for a slow-boot upstream, and the probe must not
|
|
206
|
+
// quietly undo that (a slow-boot modern-only server could otherwise never attach).
|
|
207
|
+
this._explicitRequestTimeout = Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0;
|
|
208
|
+
// The PAYLOAD window (tools/call | prompts/get | resources/read): a tool legitimately runs
|
|
209
|
+
// as long as the tool runs - the fixed 10 s window hard-killed every wrapped tool slower
|
|
210
|
+
// than that and silently dropped the upstream's eventual result. Per-upstream
|
|
211
|
+
// `timeoutMs` config lands here; a token-matched progress beat re-arms the window.
|
|
212
|
+
this._toolTimeoutMs =
|
|
213
|
+
Number.isFinite(toolTimeoutMs) && toolTimeoutMs > 0 ? toolTimeoutMs : 120000;
|
|
214
|
+
this._onClose = typeof onClose === 'function' ? onClose : null;
|
|
215
|
+
// forceLegacy (legacyPin): skip the modern server/discover probe and speak legacy unconditionally.
|
|
216
|
+
this._forceLegacy = forceLegacy === true;
|
|
217
|
+
// modernOnly: the MIRROR of legacyPin - refuse the legacy fallback entirely. A modernOnly
|
|
218
|
+
// upstream that does not answer server/discover with the modern protocol FAILS the connect
|
|
219
|
+
// with a clear error instead of silently negotiating down.
|
|
220
|
+
this._modernOnly = modernOnly === true;
|
|
221
|
+
// eraHint ('legacy' is the only meaningful value): the OWNER's memo of the era this upstream
|
|
222
|
+
// negotiated LAST time - an observation, not a policy. It skips the server/discover probe the
|
|
223
|
+
// way forceLegacy does, but the caller is expected to clear its memo when a hinted connect
|
|
224
|
+
// FAILS (the upstream may have changed underneath) - which forceLegacy, being policy, never
|
|
225
|
+
// does. 'modern' is accepted-and-ignored: for a modern upstream the probe IS the handshake,
|
|
226
|
+
// so there is nothing to skip. modernOnly always wins over the hint - a policy that demands
|
|
227
|
+
// the probe cannot be undone by a stale observation.
|
|
228
|
+
this._eraHint = eraHint === 'legacy' ? 'legacy' : null;
|
|
229
|
+
// Era-observation confidence (set per connect()): true only when the server/discover probe was
|
|
230
|
+
// ANSWERED - a valid modern DiscoverResult, a non-modern reply, or a JSON-RPC rejection like
|
|
231
|
+
// -32601. A probe that TIMED OUT (or a skipped probe) observed nothing about the upstream's
|
|
232
|
+
// eras, and the owner must not memoise the legacy fallback it forced.
|
|
233
|
+
this._eraDefinitive = false;
|
|
234
|
+
// onNotification: called with each server-initiated NOTIFICATION (a message with a method and
|
|
235
|
+
// NO id) - e.g. notifications/resources/updated, .../resources/list_changed, .../prompts/
|
|
236
|
+
// list_changed, .../tools/list_changed. Lets the aggregator bridge upstream change-notifications
|
|
237
|
+
// to a modern client's subscriptions/listen stream. Settable post-construction too.
|
|
238
|
+
this.onNotification = typeof onNotification === 'function' ? onNotification : null;
|
|
239
|
+
// onServerRequest: called with each SERVER-INITIATED REQUEST (a message with a method AND an
|
|
240
|
+
// id - elicitation/create, sampling/createMessage, roots/list). The owner answers it via
|
|
241
|
+
// respondToServer/respondToServerError. Unset -> every such request is answered -32601, so a
|
|
242
|
+
// conformant upstream is never left holding an unanswered request (elicitation-bridge infrastructure).
|
|
243
|
+
this.onServerRequest = null;
|
|
244
|
+
this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
|
|
245
|
+
this._everConnected = false; // true once the handshake completed - onClose fires ONLY for the
|
|
246
|
+
// death of an ESTABLISHED connection, never a connect-time failure
|
|
247
|
+
|
|
248
|
+
// Transport state.
|
|
249
|
+
this._child = null;
|
|
250
|
+
this._connected = false;
|
|
251
|
+
this._closed = false;
|
|
252
|
+
this._buf = Buffer.alloc(0);
|
|
253
|
+
this._skipRemaining = 0; // bytes of a refused over-cap message still to discard
|
|
254
|
+
this._stderr = '';
|
|
255
|
+
this._nextId = 1;
|
|
256
|
+
/** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
|
|
257
|
+
this._waiters = new Map();
|
|
258
|
+
|
|
259
|
+
// The initialize result, captured for connect()'s return value.
|
|
260
|
+
this._initializeResult = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @returns {boolean} true once the initialize handshake has completed and not yet closed. */
|
|
264
|
+
get connected() {
|
|
265
|
+
return this._connected === true && this._closed === false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @returns {object|null} the upstream's raw `initialize` result ({ protocolVersion, serverInfo,
|
|
269
|
+
* capabilities, instructions }) captured at connect. Used by the passthrough WRAP to present the
|
|
270
|
+
* wrapped server's OWN identity (a wrapped MCP must be indistinguishable from the real thing). */
|
|
271
|
+
get initializeResult() {
|
|
272
|
+
return this._initializeResult;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Raw JSON-RPC passthrough for the WRAP: forward a method to the upstream verbatim and return its
|
|
277
|
+
* result. Used to relay non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
|
|
278
|
+
* server behaves exactly as it would connected directly. Tool calls do NOT use this - they go
|
|
279
|
+
* through the gated forward path. Rejects on timeout / transport failure (same as callTool).
|
|
280
|
+
* @param {string} method
|
|
281
|
+
* @param {any} [params]
|
|
282
|
+
* @returns {Promise<any>} the upstream's raw result
|
|
283
|
+
*/
|
|
284
|
+
async request(method, params, out) {
|
|
285
|
+
if (typeof method !== 'string' || method.length === 0) {
|
|
286
|
+
throw new Error('McpClient.request: "method" (non-empty string) is required');
|
|
287
|
+
}
|
|
288
|
+
let p = params == null ? {} : params;
|
|
289
|
+
p = this._translateLogLevel(p); // era-keyed logLevel (1.4): modern keeps it, legacy gets setLevel
|
|
290
|
+
// Modern upstream: ensure the per-request `_meta` protocol trio is present. MERGE with any
|
|
291
|
+
// caller-supplied _meta (progressToken, trace keys survive a wrap forward) - the trio wins on
|
|
292
|
+
// collision; a partial caller _meta must never reach a strict modern upstream trio-less.
|
|
293
|
+
if (this._modern && p && typeof p === 'object' && !Array.isArray(p)) {
|
|
294
|
+
const callerMeta = (p._meta && typeof p._meta === 'object' && !Array.isArray(p._meta)) ? p._meta : null;
|
|
295
|
+
p = Object.assign({}, p, { _meta: Object.assign({}, callerMeta, this._modernMeta()) });
|
|
296
|
+
}
|
|
297
|
+
return await this._request(method, p, out);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Send a NOTIFICATION to the upstream - no id, no waiter, no reply. Used by a wrap to relay
|
|
302
|
+
* client notifications (notifications/cancelled, notifications/progress ...) that a
|
|
303
|
+
* directly-connected server would receive. Fire-and-forget; NEVER throws.
|
|
304
|
+
* @param {string} method
|
|
305
|
+
* @param {any} params
|
|
306
|
+
*/
|
|
307
|
+
notify(method, params) {
|
|
308
|
+
try {
|
|
309
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
310
|
+
if (typeof method !== 'string' || method.length === 0) return;
|
|
311
|
+
const payload = { jsonrpc: JSONRPC, method };
|
|
312
|
+
if (params !== undefined) payload.params = params;
|
|
313
|
+
this._child.stdin.write(frameRequest(payload));
|
|
314
|
+
} catch (_e) { /* fire-and-forget */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Answer a SERVER-INITIATED request (the elicitation bridge): write a JSON-RPC RESPONSE carrying the
|
|
319
|
+
* upstream's own request id. Fire-and-forget; NEVER throws.
|
|
320
|
+
* @param {number|string} id the id the UPSTREAM used on its request
|
|
321
|
+
* @param {any} result the result payload (e.g. an ElicitResult { action, content })
|
|
322
|
+
*/
|
|
323
|
+
respondToServer(id, result) {
|
|
324
|
+
try {
|
|
325
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
326
|
+
if (id === undefined || id === null) return;
|
|
327
|
+
this._child.stdin.write(frameRequest({ jsonrpc: JSONRPC, id, result: result === undefined ? {} : result }));
|
|
328
|
+
} catch (_e) { /* fire-and-forget */ }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Extend the timeout of an in-flight request (the elicitation bridge): a mid-call elicitation proves the
|
|
333
|
+
* upstream is alive and deliberately holding the call open while a HUMAN answers - the normal
|
|
334
|
+
* dead-upstream timeout must not kill the suspension. Re-arms the waiter's timer; returns
|
|
335
|
+
* false if the request already settled. NEVER throws.
|
|
336
|
+
* @param {number|string} id the rpc id of the in-flight request
|
|
337
|
+
* @param {number} ms the new timeout window from now
|
|
338
|
+
*/
|
|
339
|
+
extendRequestTimeout(id, ms) {
|
|
340
|
+
try {
|
|
341
|
+
const w = this._waiters.get(id);
|
|
342
|
+
if (!w) return false;
|
|
343
|
+
clearTimeout(w.timer);
|
|
344
|
+
// The extension becomes the waiter's WINDOW, not just its current timer: a token-matched
|
|
345
|
+
// progress beat re-arms to w.win, so leaving the old (short) win in place let a single
|
|
346
|
+
// beat during a Bridge-B suspension collapse the human-answer hold back to toolTimeoutMs
|
|
347
|
+
//Progress during a hold now re-arms to the hold.
|
|
348
|
+
const win = Number.isFinite(ms) && ms > 0 ? ms : this._requestTimeoutMs;
|
|
349
|
+
w.win = win;
|
|
350
|
+
w.timer = setTimeout(() => {
|
|
351
|
+
this._waiters.delete(id);
|
|
352
|
+
w.reject(new Error(`McpClient: request("${w.method}") timed out (suspended-call window elapsed)`));
|
|
353
|
+
}, win);
|
|
354
|
+
if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
|
|
355
|
+
return true;
|
|
356
|
+
} catch (_e) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Re-arm the timeout of the in-flight request matching this progressToken:
|
|
363
|
+
* a progress beat is proof of life for THAT request. Token-matched only - a dead upstream
|
|
364
|
+
* still times out on its full window. NEVER throws.
|
|
365
|
+
* @param {number|string} token the progressToken from a notifications/progress params
|
|
366
|
+
*/
|
|
367
|
+
_bumpProgressWaiter(token) {
|
|
368
|
+
try {
|
|
369
|
+
for (const [id, w] of this._waiters) {
|
|
370
|
+
if (w.progressToken === undefined || w.progressToken !== token) continue;
|
|
371
|
+
clearTimeout(w.timer);
|
|
372
|
+
const win = Number.isFinite(w.win) && w.win > 0 ? w.win : this._toolTimeoutMs;
|
|
373
|
+
w.timer = setTimeout(() => {
|
|
374
|
+
this._waiters.delete(id);
|
|
375
|
+
w.reject(new Error(
|
|
376
|
+
`McpClient: timeout after ${win}ms without progress waiting for "${w.method}". stderr so far:\n${this._stderr}`
|
|
377
|
+
));
|
|
378
|
+
}, win);
|
|
379
|
+
if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
} catch (_e) { /* re-arm is best-effort; the original timer stands */ }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Answer a server-initiated request with a JSON-RPC ERROR. Fire-and-forget; NEVER throws. */
|
|
386
|
+
respondToServerError(id, code, message) {
|
|
387
|
+
try {
|
|
388
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
389
|
+
if (id === undefined || id === null) return;
|
|
390
|
+
this._child.stdin.write(frameRequest({
|
|
391
|
+
jsonrpc: JSONRPC, id,
|
|
392
|
+
error: { code: typeof code === 'number' ? code : -32603, message: String(message || 'error') },
|
|
393
|
+
}));
|
|
394
|
+
} catch (_e) { /* fire-and-forget */ }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Diagnostics -> never throw. ───────────────────────────────────────────────────────────────
|
|
398
|
+
_logErr(...parts) {
|
|
399
|
+
try {
|
|
400
|
+
process.stderr.write(`[mcp-client:${this.id}] ${parts.join(' ')}\n`);
|
|
401
|
+
} catch (_e) {
|
|
402
|
+
/* never let logging throw */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
407
|
+
// connect(): spawn the child, perform the MCP initialize handshake, send the
|
|
408
|
+
// notifications/initialized notification, set connected. On ANY failure (spawn
|
|
409
|
+
// error, initialize timeout/error) close() the child and reject - never leave a zombie.
|
|
410
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
411
|
+
async connect() {
|
|
412
|
+
if (this._closed) throw new Error('McpClient: connect() called after close()');
|
|
413
|
+
if (this._connected) return this._initializeResult;
|
|
414
|
+
|
|
415
|
+
// 1) Spawn the child (shared with probeDiscover - a probe may already have spawned it).
|
|
416
|
+
if (!this._child) this._spawnChild();
|
|
417
|
+
|
|
418
|
+
// 2) DUAL-ERA negotiation (the spec's dual-era CLIENT rule: try MODERN first). Probe
|
|
419
|
+
// `server/discover`: a modern or dual-era upstream answers with a DiscoverResult that lists
|
|
420
|
+
// the modern version; a legacy-only upstream rejects it (method-not-found) or drops. On a
|
|
421
|
+
// valid modern answer we speak MODERN (no initialize; per-request `_meta` on every call).
|
|
422
|
+
// Otherwise (or when legacyPin FORCES legacy) we fall back to the LEGACY initialize handshake
|
|
423
|
+
// (byte-for-byte the prior behaviour).
|
|
424
|
+
let discovered = null;
|
|
425
|
+
// The probe is skipped by POLICY (legacyPin) or by the owner's era MEMO (eraHint 'legacy' -
|
|
426
|
+
// this upstream negotiated legacy last time, so a reconnect goes straight to initialize
|
|
427
|
+
// instead of re-paying the probe window). modernOnly overrides the hint: that policy's whole
|
|
428
|
+
// point is the probe, and a stale memo must never silently downgrade it.
|
|
429
|
+
const skipProbe = this._forceLegacy || (this._eraHint === 'legacy' && !this._modernOnly);
|
|
430
|
+
this._eraDefinitive = false; // no observation yet this connect (stays false when the probe is skipped)
|
|
431
|
+
if (!skipProbe) {
|
|
432
|
+
let probe = null;
|
|
433
|
+
// The probe gets a SHORT timeout of its own: a legacy server that silently DROPS unknown
|
|
434
|
+
// methods (instead of answering -32601) would otherwise stall every connect by the full
|
|
435
|
+
// request timeout - serialised across upstreams at startup. An EXPLICIT per-upstream
|
|
436
|
+
// requestTimeoutMs overrides the clamp - a slow-boot modern-only server needs the raised
|
|
437
|
+
// window HERE, and the bin-side era probe already honours it (keep the two symmetric).
|
|
438
|
+
const fullTimeout = this._requestTimeoutMs;
|
|
439
|
+
this._requestTimeoutMs = this._explicitRequestTimeout
|
|
440
|
+
? fullTimeout : Math.min(3000, fullTimeout || 3000);
|
|
441
|
+
try {
|
|
442
|
+
probe = await this._request('server/discover', { _meta: this._modernMeta() });
|
|
443
|
+
this._eraDefinitive = true; // the probe was answered - whatever we conclude is observed
|
|
444
|
+
} catch (probeErr) {
|
|
445
|
+
probe = null; // legacy upstream (method-not-found) - fall through to initialize
|
|
446
|
+
// An ANSWERED rejection (rpcError present, e.g. -32601) is a definitive legacy
|
|
447
|
+
// observation; a timeout/transport drop is SILENCE and proves nothing.
|
|
448
|
+
this._eraDefinitive = !!(probeErr && probeErr.rpcError);
|
|
449
|
+
if (this._closed) throw new Error('McpClient: connect() aborted (closed during discover probe)');
|
|
450
|
+
if (!this._child) this._spawnChild(); // a fragile legacy server may have died on the probe
|
|
451
|
+
} finally {
|
|
452
|
+
this._requestTimeoutMs = fullTimeout;
|
|
453
|
+
}
|
|
454
|
+
// Only treat the reply as MODERN if it is a real DiscoverResult advertising the modern
|
|
455
|
+
// version - a permissive legacy server that answers unknown methods with `{}`/an empty object
|
|
456
|
+
// must NOT be misdetected as modern.
|
|
457
|
+
if (probe && typeof probe === 'object' && Array.isArray(probe.supportedVersions) &&
|
|
458
|
+
probe.supportedVersions.includes(MODERN_PROTOCOL_VERSION)) {
|
|
459
|
+
discovered = probe;
|
|
460
|
+
} else if (probe && this._child) {
|
|
461
|
+
// Answered, but not a modern DiscoverResult -> legacy. The child is alive (it replied), so the
|
|
462
|
+
// legacy initialize below runs on the same child.
|
|
463
|
+
discovered = null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// modernOnly refuses the legacy fallback: no valid modern DiscoverResult means no connection,
|
|
468
|
+
// with an error that names the policy - never a silent downgrade.
|
|
469
|
+
if (!discovered && this._modernOnly) {
|
|
470
|
+
this.close();
|
|
471
|
+
// A command that was never found cannot answer discover either - say THAT, not the policy.
|
|
472
|
+
const missing = this._missingCommandError('McpClient: upstream could not start');
|
|
473
|
+
if (missing) throw missing;
|
|
474
|
+
throw new Error(`McpClient: upstream "${this.id}" is modernOnly but did not answer server/discover with the 2026-07-28 protocol - refusing the legacy fallback`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (discovered) {
|
|
478
|
+
// MODERN upstream - identity from discover; NO initialize / notifications/initialized.
|
|
479
|
+
this._modern = true;
|
|
480
|
+
this._initializeResult = this._identityFromDiscover(discovered);
|
|
481
|
+
} else {
|
|
482
|
+
// LEGACY upstream - the initialize handshake. A spawn 'error' rejects via the waiter path; a
|
|
483
|
+
// silent server hits the request timeout. Either way close() + a clear error.
|
|
484
|
+
try {
|
|
485
|
+
const result = await this._request('initialize', {
|
|
486
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
487
|
+
clientInfo: this._clientInfo,
|
|
488
|
+
// elicitation: real SDK-based servers GATE ctx.elicit() on the client declaring this
|
|
489
|
+
// capability - without it they answer "Method not found" and never elicit at all.
|
|
490
|
+
// Every elicitation is handled: the elicitation bridge's MRTR path for modern callers,
|
|
491
|
+
// a clean decline otherwise.
|
|
492
|
+
// BOTH modes declared: the 2025-11-25 back-compat rule reads a bare
|
|
493
|
+
// `{}` as FORM ONLY, and "Servers MUST NOT send elicitation requests with modes that
|
|
494
|
+
// are not supported by the client" - so without `url` here a conformant upstream is
|
|
495
|
+
// FORBIDDEN from url mode and the relay is structurally dead. Every elicit of either
|
|
496
|
+
// mode gets an answer: relayed to a caller that declared it, declined otherwise.
|
|
497
|
+
capabilities: { elicitation: { form: {}, url: {} } },
|
|
498
|
+
});
|
|
499
|
+
this._initializeResult = result;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
this.close();
|
|
502
|
+
const message = `McpClient: initialize failed: ${(err && err.message) || String(err)}`;
|
|
503
|
+
// Name a missing working directory or command first (a spawn ENOENT names only the
|
|
504
|
+
// executable, and the Windows launch shim hides a missing command behind "exited (code=1)"),
|
|
505
|
+
// else keep the cause's code - the owner classifies by it.
|
|
506
|
+
const missing = this._missingCommandError(message);
|
|
507
|
+
if (missing) throw missing;
|
|
508
|
+
if (err && typeof err.code === 'string') throw Object.assign(new Error(message), { code: err.code });
|
|
509
|
+
throw new Error(message);
|
|
510
|
+
}
|
|
511
|
+
// Tell the server the handshake is complete (notification - no id, no reply).
|
|
512
|
+
this._notify('notifications/initialized', {});
|
|
513
|
+
this._modern = false;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
this._connected = true;
|
|
517
|
+
this._everConnected = true; // from here on, an exit/error is an ESTABLISHED-connection death
|
|
518
|
+
return this._initializeResult;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** @returns {'modern'|'legacy'} the era negotiated at connect (default 'legacy' before connect). */
|
|
522
|
+
get era() {
|
|
523
|
+
return this._modern === true ? 'modern' : 'legacy';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** @returns {boolean} true when the LAST connect's era came from an ANSWERED probe; false when
|
|
527
|
+
* the probe was skipped (policy/hint) or went unanswered (timeout/drop). The owner memoises
|
|
528
|
+
* the era only on a definitive observation - silence must never pin an era. */
|
|
529
|
+
get eraDefinitive() {
|
|
530
|
+
return this._eraDefinitive === true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** The per-request `_meta` a modern client MUST send on every request (stdio: no headers). */
|
|
534
|
+
_modernMeta() {
|
|
535
|
+
return {
|
|
536
|
+
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
|
|
537
|
+
'io.modelcontextprotocol/clientCapabilities': {},
|
|
538
|
+
'io.modelcontextprotocol/clientInfo': this._clientInfo,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** @returns {{name:string, version:string}} the identity this client presents upstream. */
|
|
543
|
+
get clientInfo() {
|
|
544
|
+
return this._clientInfo;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Era-keyed logLevel handling. A caller's per-request
|
|
549
|
+
* `io.modelcontextprotocol/logLevel` must reach the upstream in the upstream's own vocabulary:
|
|
550
|
+
* MODERN upstream -> left in `_meta` (the trio merge in request()/callTool() preserves it - the
|
|
551
|
+
* trio does not define logLevel), because the modern severity clause obliges the upstream to
|
|
552
|
+
* WITHHOLD notifications/message when the field is absent. LEGACY upstream -> the key is
|
|
553
|
+
* REMOVED (no legacy schema defines it; leaking a modern reserved key is also a wrap tell)
|
|
554
|
+
* and, when the upstream declared the `logging` capability, translated to a `logging/setLevel`
|
|
555
|
+
* request ISSUED BEFORE the main request (stdio writes are ordered - the server sees the level
|
|
556
|
+
* first), deduped per distinct level. Best-effort: a setLevel failure never fails the main
|
|
557
|
+
* call. Deprecation-sized (SEP-2577): one key, one translation, no wider _meta policy.
|
|
558
|
+
* @param {any} params the outgoing request params
|
|
559
|
+
* @returns {any} params, possibly cloned with the logLevel key removed
|
|
560
|
+
*/
|
|
561
|
+
_translateLogLevel(params) {
|
|
562
|
+
const meta = params && typeof params === 'object' && !Array.isArray(params) ? params._meta : null;
|
|
563
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return params;
|
|
564
|
+
const level = meta['io.modelcontextprotocol/logLevel'];
|
|
565
|
+
if (level === undefined) return params;
|
|
566
|
+
if (this._modern) return params; // modern upstream: rides the forwarded _meta verbatim
|
|
567
|
+
const rest = { ...meta };
|
|
568
|
+
delete rest['io.modelcontextprotocol/logLevel'];
|
|
569
|
+
const out = { ...params };
|
|
570
|
+
if (Object.keys(rest).length) out._meta = rest; else delete out._meta;
|
|
571
|
+
const caps = this._initializeResult && this._initializeResult.capabilities;
|
|
572
|
+
if (typeof level === 'string' && caps && caps.logging && level !== this._lastSetLogLevel) {
|
|
573
|
+
this._lastSetLogLevel = level;
|
|
574
|
+
// Fire-and-forget, ordered by the pipe (issued BEFORE the main request). A refusal or timeout
|
|
575
|
+
// reverts the memo so the next call at this level asks again - memoising it as done left the
|
|
576
|
+
// upstream at whatever level it had for the rest of the session.
|
|
577
|
+
this._request('logging/setLevel', { level }).catch(() => { if (this._lastSetLogLevel === level) this._lastSetLogLevel = undefined; });
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Normalise a `server/discover` result into the common identity shape wrappedIdentity + connect()
|
|
584
|
+
* consumers expect ({ protocolVersion, serverInfo, capabilities, instructions }) - so a MODERN
|
|
585
|
+
* upstream's identity reads the same as a legacy `initialize` result. serverInfo lives in the
|
|
586
|
+
* discover result's `_meta['io.modelcontextprotocol/serverInfo']`.
|
|
587
|
+
*/
|
|
588
|
+
_identityFromDiscover(disc) {
|
|
589
|
+
const d = disc && typeof disc === 'object' ? disc : {};
|
|
590
|
+
const meta = d._meta && typeof d._meta === 'object' ? d._meta : {};
|
|
591
|
+
const si = meta['io.modelcontextprotocol/serverInfo'];
|
|
592
|
+
const versions = Array.isArray(d.supportedVersions) ? d.supportedVersions : [];
|
|
593
|
+
return {
|
|
594
|
+
// We negotiated MODERN (this path is only reached for a modern DiscoverResult), so report the
|
|
595
|
+
// modern version - not `versions[0]`, whose ordering the spec does not guarantee (a dual-era
|
|
596
|
+
// upstream listing legacy first would otherwise yield a contradictory identity).
|
|
597
|
+
protocolVersion: versions.includes(MODERN_PROTOCOL_VERSION) ? MODERN_PROTOCOL_VERSION : (versions[0] || MODERN_PROTOCOL_VERSION),
|
|
598
|
+
serverInfo: si && typeof si === 'object' ? si : undefined,
|
|
599
|
+
capabilities: d.capabilities && typeof d.capabilities === 'object' ? d.capabilities : undefined,
|
|
600
|
+
instructions: typeof d.instructions === 'string' ? d.instructions : undefined,
|
|
601
|
+
supportedVersions: versions,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Spawn the child + wire its pipes (no handshake). A synchronous spawn throw (bad command) is
|
|
607
|
+
* caught and rethrown clean. Extracted from connect() so probeDiscover() can share it.
|
|
608
|
+
*/
|
|
609
|
+
_spawnChild() {
|
|
610
|
+
// Per-child read state starts CLEAN. On an in-place respawn (connect/probeDiscover after the
|
|
611
|
+
// previous child died) a half-arrived frame, a refused over-cap skip count, or the old child's
|
|
612
|
+
// stderr would otherwise be prepended to the new child's first bytes: the drain then swallows
|
|
613
|
+
// its first reply as the rest of a dead body, and every error quotes the wrong stderr. Same
|
|
614
|
+
// values as the constructor. The log-level memo goes too - a new process knows no level.
|
|
615
|
+
this._buf = Buffer.alloc(0);
|
|
616
|
+
this._skipRemaining = 0;
|
|
617
|
+
this._stderr = '';
|
|
618
|
+
this._lastSetLogLevel = undefined;
|
|
619
|
+
try {
|
|
620
|
+
// Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
|
|
621
|
+
const launch = winLaunch(this._command, this._args);
|
|
622
|
+
this._child = this._spawn(launch.command, launch.args, {
|
|
623
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
624
|
+
cwd: this._cwd,
|
|
625
|
+
env: Object.assign({}, process.env, this._env),
|
|
626
|
+
windowsHide: true,
|
|
627
|
+
});
|
|
628
|
+
} catch (err) {
|
|
629
|
+
this._child = null;
|
|
630
|
+
const failed = new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
|
|
631
|
+
if (err && typeof err.code === 'string') failed.code = err.code; // the owner classifies by code
|
|
632
|
+
throw failed;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (!this._child || !this._child.stdin || !this._child.stdout) {
|
|
636
|
+
// A fake/odd spawnImpl that didn't give us pipes - bail cleanly.
|
|
637
|
+
this.close();
|
|
638
|
+
throw new Error('McpClient: spawned child is missing stdio pipes');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
this._wireChild();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** A connect-failure Error carrying code ENOENT when the working directory or the command is
|
|
645
|
+
* definitely absent (commandMissing - Windows, where the launch shim hides it), else null. */
|
|
646
|
+
_missingCommandError(message) {
|
|
647
|
+
if (typeof this._cwd === 'string' && this._cwd.length && !fs.existsSync(this._cwd)) {
|
|
648
|
+
return Object.assign(new Error(`McpClient: working directory "${this._cwd}" does not exist - ${message}`), { code: 'ENOENT' });
|
|
649
|
+
}
|
|
650
|
+
if (!commandMissing(this._command, this._cwd, Object.assign({}, process.env, this._env))) return null;
|
|
651
|
+
// The cause goes FIRST: the message continues with the upstream's stderr, which a reader
|
|
652
|
+
// (or the aggregator's bounded stop line) may cut short.
|
|
653
|
+
return Object.assign(
|
|
654
|
+
new Error(`McpClient: command "${this._command}" was not found (looked in the working directory and on PATH) - ${message}`),
|
|
655
|
+
{ code: 'ENOENT' }
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Era probe - the 2026-07-28 spec's dual-era client rule for stdio: try `server/discover`
|
|
661
|
+
* BEFORE any legacy `initialize`. A modern or dual-era server answers with its
|
|
662
|
+
* supportedVersions; a legacy server rejects it (-32601 Method not found) or times out.
|
|
663
|
+
* Resolves the DiscoverResult, rejects otherwise. Sends the request with modern per-request
|
|
664
|
+
* `_meta` (required in the modern era). Does NOT set connected - this is a probe, not a
|
|
665
|
+
* handshake; call connect() for the legacy handshake (fresh client recommended: a badly
|
|
666
|
+
* behaved legacy server may react badly to an unknown method).
|
|
667
|
+
* @returns {Promise<object>} the server/discover result
|
|
668
|
+
*/
|
|
669
|
+
async probeDiscover() {
|
|
670
|
+
if (this._closed) throw new Error('McpClient: probeDiscover() called after close()');
|
|
671
|
+
if (!this._child) this._spawnChild();
|
|
672
|
+
try {
|
|
673
|
+
return await this._request('server/discover', {
|
|
674
|
+
_meta: {
|
|
675
|
+
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
|
|
676
|
+
'io.modelcontextprotocol/clientCapabilities': {},
|
|
677
|
+
'io.modelcontextprotocol/clientInfo': this._clientInfo,
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
} catch (err) {
|
|
681
|
+
// Mirror connect()'s close-on-failure contract: a probe that times out or errors must not
|
|
682
|
+
// leave a zombie child (the caller may not have a finally). close() is idempotent.
|
|
683
|
+
this.close();
|
|
684
|
+
throw err;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* tools/list -> the upstream's COMPLETE tool definitions, following `nextCursor` pagination
|
|
690
|
+
* (a single-shot list would silently truncate a paginating upstream to page 1, leaving its
|
|
691
|
+
* later tools undiscoverable AND unrunnable). Returns the raw concatenated array
|
|
692
|
+
* [{name, description, inputSchema, ...}] from every page (empty array if the server returned
|
|
693
|
+
* no tools). Rejects on timeout / transport failure of ANY page.
|
|
694
|
+
*
|
|
695
|
+
* Defensive bounds (a misbehaving upstream must not hang discovery):
|
|
696
|
+
* - a cursor already seen ends the loop (a repeating cursor would spin forever);
|
|
697
|
+
* - MAX_LIST_PAGES caps a pathological endless-unique-cursor upstream. Both end the loop
|
|
698
|
+
* with the pages already fetched - partial truth beats a wedged aggregator.
|
|
699
|
+
* @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
|
|
700
|
+
*/
|
|
701
|
+
async listTools() {
|
|
702
|
+
const MAX_LIST_PAGES = 64;
|
|
703
|
+
const tools = [];
|
|
704
|
+
const seen = new Set();
|
|
705
|
+
let cursor;
|
|
706
|
+
for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
|
|
707
|
+
// Modern upstream: every request carries per-request `_meta` (stdio needs no headers).
|
|
708
|
+
const params = this._modern ? { _meta: this._modernMeta() } : {};
|
|
709
|
+
if (cursor !== undefined) params.cursor = cursor;
|
|
710
|
+
const result = await this._request('tools/list', params);
|
|
711
|
+
if (result && Array.isArray(result.tools)) tools.push(...result.tools);
|
|
712
|
+
const next = result && typeof result.nextCursor === 'string' && result.nextCursor.length
|
|
713
|
+
? result.nextCursor : undefined;
|
|
714
|
+
if (next === undefined || seen.has(next)) return tools;
|
|
715
|
+
seen.add(next);
|
|
716
|
+
cursor = next;
|
|
717
|
+
}
|
|
718
|
+
return tools;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* tools/call -> invoke one upstream tool. Returns the MCP tools/call result
|
|
723
|
+
* envelope { content, isError }. Rejects on timeout / transport failure (a tool
|
|
724
|
+
* that ran but returned an error sets isError:true in the resolved value - that
|
|
725
|
+
* is NOT a rejection).
|
|
726
|
+
* @param {string} name the upstream tool name
|
|
727
|
+
* @param {any} args structured arguments (forwarded as the `arguments` field)
|
|
728
|
+
* @param {object} [meta] the caller's request-scoped _meta (progressToken et al., protocol keys
|
|
729
|
+
* already stripped by the server). Rides the forward: without it the upstream
|
|
730
|
+
* never emits progress for the call, so a long funnel tool cannot keep its
|
|
731
|
+
* own call alive.
|
|
732
|
+
* @returns {Promise<{content:any[], isError:boolean}>}
|
|
733
|
+
*/
|
|
734
|
+
async callTool(name, args, meta) {
|
|
735
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
736
|
+
throw new Error('McpClient.callTool: "name" (non-empty string) is required');
|
|
737
|
+
}
|
|
738
|
+
let params = { name, arguments: args == null ? {} : args };
|
|
739
|
+
if (meta && typeof meta === 'object' && !Array.isArray(meta) && Object.keys(meta).length) {
|
|
740
|
+
params._meta = { ...meta };
|
|
741
|
+
}
|
|
742
|
+
params = this._translateLogLevel(params); // era-keyed logLevel (1.4)
|
|
743
|
+
// modern upstream: the per-request protocol trio always wins over caller keys
|
|
744
|
+
if (this._modern) params._meta = { ...(params._meta || {}), ...this._modernMeta() };
|
|
745
|
+
const result = await this._request('tools/call', params);
|
|
746
|
+
const r = result && typeof result === 'object' ? result : {};
|
|
747
|
+
const out = {
|
|
748
|
+
content: Array.isArray(r.content) ? r.content : [],
|
|
749
|
+
isError: r.isError === true,
|
|
750
|
+
};
|
|
751
|
+
// structuredContent rides along verbatim - dropping it silently stripped modern upstreams'
|
|
752
|
+
// structured results on the curated-direct path while the raw wrap path kept them
|
|
753
|
+
if (r.structuredContent !== undefined) out.structuredContent = r.structuredContent;
|
|
754
|
+
// result _meta rides along too (3.10, result side): the wrap relays it verbatim (the
|
|
755
|
+
// upstream's identity IS the presented identity there); the funnel shaping sites strip the
|
|
756
|
+
// protocol-identity keys before forwarding (they own their own identity).
|
|
757
|
+
if (r._meta !== undefined) out._meta = r._meta;
|
|
758
|
+
return out;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
762
|
+
// close(): idempotent teardown. End child stdin so a well-behaved server exits on
|
|
763
|
+
// EOF; if it is still alive, tree-kill it (win32 taskkill /T /F to reap the whole
|
|
764
|
+
// process tree; else child.kill()). Reject every in-flight request so nothing leaks.
|
|
765
|
+
// NEVER throws - it is called from error paths.
|
|
766
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
767
|
+
close() {
|
|
768
|
+
if (this._closed) return;
|
|
769
|
+
this._closed = true;
|
|
770
|
+
this._connected = false;
|
|
771
|
+
|
|
772
|
+
const child = this._child;
|
|
773
|
+
|
|
774
|
+
// Reject any in-flight requests first (so they don't fire their timers after teardown).
|
|
775
|
+
this._rejectAllWaiters(new Error('McpClient: connection closed'));
|
|
776
|
+
|
|
777
|
+
if (!child) return;
|
|
778
|
+
|
|
779
|
+
// Best-effort: end stdin to let the server exit on EOF.
|
|
780
|
+
try {
|
|
781
|
+
if (child.stdin && !child.stdin.destroyed) child.stdin.end();
|
|
782
|
+
} catch (_e) {
|
|
783
|
+
/* ignore */
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// If still alive, tree-kill so no descendant is orphaned (zombie guard).
|
|
787
|
+
this._treeKill(child);
|
|
788
|
+
|
|
789
|
+
this._child = null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Tree-kill a child so no descendant is orphaned (win32 taskkill /T /F reaps the whole
|
|
794
|
+
* process tree; else child.kill()). NEVER throws - called from close() and from the
|
|
795
|
+
* read-buffer overflow path, both of which must not raise out of an event handler.
|
|
796
|
+
* @param {import('node:child_process').ChildProcess|null} child
|
|
797
|
+
*/
|
|
798
|
+
_treeKill(child) {
|
|
799
|
+
try {
|
|
800
|
+
if (!child) return;
|
|
801
|
+
const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
|
|
802
|
+
if (alive && typeof child.pid === 'number') {
|
|
803
|
+
if (process.platform === 'win32') {
|
|
804
|
+
// /T = whole tree, /F = force. spawnSync so it completes synchronously here.
|
|
805
|
+
try {
|
|
806
|
+
childProcess.spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
807
|
+
windowsHide: true,
|
|
808
|
+
});
|
|
809
|
+
} catch (_e) {
|
|
810
|
+
// Fall back to a plain kill if taskkill is unavailable.
|
|
811
|
+
try { child.kill(); } catch (_e2) { /* ignore */ }
|
|
812
|
+
}
|
|
813
|
+
} else {
|
|
814
|
+
child.kill();
|
|
815
|
+
}
|
|
816
|
+
} else if (typeof child.kill === 'function') {
|
|
817
|
+
// Already exited or a fake child - a no-op kill is harmless.
|
|
818
|
+
try { child.kill(); } catch (_e) { /* ignore */ }
|
|
819
|
+
}
|
|
820
|
+
} catch (_e) {
|
|
821
|
+
/* never throws - see contract above */
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
826
|
+
// Internals.
|
|
827
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
/** Attach the stdout reader, stderr capture, and exit/error handlers to the child. */
|
|
830
|
+
_wireChild() {
|
|
831
|
+
const child = this._child;
|
|
832
|
+
|
|
833
|
+
// Every handler below answers only while `child` is STILL this._child. A child that has
|
|
834
|
+
// exited (or been replaced by an in-place respawn) can still drain buffered stdout/stderr and
|
|
835
|
+
// fire 'exit'/'error' afterwards - Node emits 'exit' before the stdio streams are done - and
|
|
836
|
+
// those late events would otherwise land in the NEW child's read state or reject its waiters.
|
|
837
|
+
child.stdout.on('data', (chunk) => { if (this._child === child) this._onData(chunk); });
|
|
838
|
+
child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
|
|
839
|
+
|
|
840
|
+
if (child.stderr) {
|
|
841
|
+
child.stderr.on('data', (d) => {
|
|
842
|
+
if (this._child !== child) return;
|
|
843
|
+
this._stderr += d.toString('utf8');
|
|
844
|
+
// Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
|
|
845
|
+
if (this._stderr.length > 64 * 1024) {
|
|
846
|
+
this._stderr = this._stderr.slice(-32 * 1024);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
child.stderr.on('error', () => { /* ignore */ });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Either of these means the child is gone - fail every in-flight request so nothing hangs, and
|
|
853
|
+
// NULL this._child so a later connect() on the same instance respawns instead of writing to a
|
|
854
|
+
// dead stdin (connect() reuses `this._child` when set - a probeDiscover() against a legacy
|
|
855
|
+
// server that dies on the unknown method would otherwise corrupt a follow-up connect()). Only
|
|
856
|
+
// null it if it is still THIS child (a fresh respawn may have replaced it).
|
|
857
|
+
child.on('exit', (code, signal) => {
|
|
858
|
+
if (this._child !== child) return; // a stale child's exit: already handled or superseded
|
|
859
|
+
this._connected = false;
|
|
860
|
+
this._child = null;
|
|
861
|
+
this._rejectAllWaiters(
|
|
862
|
+
new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
|
|
863
|
+
);
|
|
864
|
+
this._fireOnClose(`exited (code=${code}, signal=${signal})`);
|
|
865
|
+
});
|
|
866
|
+
child.on('error', (err) => {
|
|
867
|
+
// Spawn-level failure (e.g. ENOENT) - surfaces here, not as a throw from spawn().
|
|
868
|
+
if (this._child !== child) return; // a stale child's error: already handled or superseded
|
|
869
|
+
this._connected = false;
|
|
870
|
+
this._child = null;
|
|
871
|
+
const failed = new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`);
|
|
872
|
+
// Keep the code: the aggregator's reconnect ceiling classifies permanent failures by it
|
|
873
|
+
// (a re-wrap without it made a missing command look like any other death).
|
|
874
|
+
if (err && typeof err.code === 'string') failed.code = err.code;
|
|
875
|
+
this._rejectAllWaiters(failed);
|
|
876
|
+
this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Fire the onClose callback ONCE, and ONLY for an UNEXPECTED death - never when close() was
|
|
882
|
+
* called deliberately (close() sets _closed=true before tree-killing the child, so the resulting
|
|
883
|
+
* 'exit' is suppressed here). This is the signal the aggregator uses to schedule a background
|
|
884
|
+
* reconnect. Wrapped - a throwing callback must not escape an event handler.
|
|
885
|
+
* @param {string} reason
|
|
886
|
+
*/
|
|
887
|
+
_fireOnClose(reason) {
|
|
888
|
+
// ONLY an established connection dying counts. A connect-time failure (child exits during the
|
|
889
|
+
// handshake, ENOENT, etc.) must NOT fire onClose: it surfaces as connect()'s thrown error, and
|
|
890
|
+
// the aggregator's reconnect-retry path owns the backoff. Firing here would let a death inside a
|
|
891
|
+
// reconnect attempt reset the backoff counter to 0 -> an unbounded ~1s respawn storm.
|
|
892
|
+
if (!this._everConnected) return;
|
|
893
|
+
if (this._closed) return; // deliberate close() - not an unexpected death
|
|
894
|
+
if (this._onCloseFired) return; // exit AND error can both fire; notify once
|
|
895
|
+
this._onCloseFired = true;
|
|
896
|
+
if (typeof this._onClose === 'function') {
|
|
897
|
+
try { this._onClose(reason); } catch (_e) { this._logErr('onClose callback threw (ignored)'); }
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Reject + clear every pending waiter (used on close / exit / error). */
|
|
902
|
+
_rejectAllWaiters(err) {
|
|
903
|
+
if (this._waiters.size === 0) return;
|
|
904
|
+
for (const [, w] of this._waiters) {
|
|
905
|
+
try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
|
|
906
|
+
try { w.reject(err); } catch (_e) { /* ignore */ }
|
|
907
|
+
}
|
|
908
|
+
this._waiters.clear();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Send a JSON-RPC request with an id and await its matching response. Rejects on
|
|
913
|
+
* timeout (requestTimeoutMs) after deleting the waiter so it can't leak. Resolves
|
|
914
|
+
* with the response's `result` (or rejects with its `error`).
|
|
915
|
+
*/
|
|
916
|
+
_request(method, params, out) {
|
|
917
|
+
if (this._closed) {
|
|
918
|
+
return Promise.reject(new Error(`McpClient: request("${method}") after close()`));
|
|
919
|
+
}
|
|
920
|
+
if (!this._child || !this._child.stdin) {
|
|
921
|
+
return Promise.reject(new Error(`McpClient: request("${method}") with no live child`));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const id = this._nextId++;
|
|
925
|
+
// Report the issued rpc id synchronously (before any I/O) - a wrap needs the client-id ->
|
|
926
|
+
// upstream-id mapping LIVE to translate notifications/cancelled for in-flight forwards.
|
|
927
|
+
if (out && typeof out === 'object') out.rpcId = id;
|
|
928
|
+
const payload = { jsonrpc: JSONRPC, id, method };
|
|
929
|
+
if (params !== undefined) payload.params = params;
|
|
930
|
+
|
|
931
|
+
// Method-class window: payload methods wait toolTimeoutMs, control-plane keeps the short
|
|
932
|
+
// dead-upstream window. The caller's progressToken (rides params._meta on
|
|
933
|
+
// a wrap forward) is remembered so a matching progress beat can re-arm the window.
|
|
934
|
+
const win = PAYLOAD_METHODS.has(method) ? this._toolTimeoutMs : this._requestTimeoutMs;
|
|
935
|
+
const ptok = params && params._meta && typeof params._meta === 'object' &&
|
|
936
|
+
params._meta.progressToken !== undefined ? params._meta.progressToken : undefined;
|
|
937
|
+
|
|
938
|
+
return new Promise((resolve, reject) => {
|
|
939
|
+
const timer = setTimeout(() => {
|
|
940
|
+
this._waiters.delete(id);
|
|
941
|
+
reject(new Error(
|
|
942
|
+
`McpClient: timeout after ${win}ms waiting for "${method}". stderr so far:\n${this._stderr}`
|
|
943
|
+
));
|
|
944
|
+
}, win);
|
|
945
|
+
// Don't let a pending timer keep the event loop (and the test process) alive.
|
|
946
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
947
|
+
|
|
948
|
+
this._waiters.set(id, { resolve, reject, timer, method, win, progressToken: ptok });
|
|
949
|
+
|
|
950
|
+
let framed;
|
|
951
|
+
try {
|
|
952
|
+
framed = frameRequest(payload);
|
|
953
|
+
} catch (err) {
|
|
954
|
+
this._waiters.delete(id);
|
|
955
|
+
clearTimeout(timer);
|
|
956
|
+
reject(new Error(`McpClient: failed to serialise "${method}": ${(err && err.message) || String(err)}`));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
try {
|
|
961
|
+
this._child.stdin.write(framed, (err) => {
|
|
962
|
+
if (err) {
|
|
963
|
+
this._waiters.delete(id);
|
|
964
|
+
clearTimeout(timer);
|
|
965
|
+
reject(new Error(`McpClient: stdin write failed for "${method}": ${(err && err.message) || String(err)}`));
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
} catch (err) {
|
|
969
|
+
this._waiters.delete(id);
|
|
970
|
+
clearTimeout(timer);
|
|
971
|
+
reject(new Error(`McpClient: stdin write threw for "${method}": ${(err && err.message) || String(err)}`));
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Fire-and-forget JSON-RPC notification (no id, no reply expected). Never throws. */
|
|
977
|
+
_notify(method, params) {
|
|
978
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
979
|
+
const payload = { jsonrpc: JSONRPC, method };
|
|
980
|
+
if (params !== undefined) payload.params = params;
|
|
981
|
+
let framed;
|
|
982
|
+
try {
|
|
983
|
+
framed = frameRequest(payload);
|
|
984
|
+
} catch (err) {
|
|
985
|
+
this._logErr('failed to serialise notification', method + ':', (err && err.message) || String(err));
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
this._child.stdin.write(framed);
|
|
990
|
+
} catch (err) {
|
|
991
|
+
this._logErr('notification write failed', method + ':', (err && err.message) || String(err));
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Buffer incoming stdout bytes and drain every complete message (dual framing). */
|
|
996
|
+
_onData(chunk) {
|
|
997
|
+
this._buf = Buffer.concat([this._buf, chunk]);
|
|
998
|
+
this._drain();
|
|
999
|
+
if (this._buf.length > MAX_MESSAGE_BYTES) {
|
|
1000
|
+
// Past the cap with no complete frame there is no resync point - drop the connection.
|
|
1001
|
+
// The tree-kill surfaces as an unexpected exit: waiters reject and onClose lets the
|
|
1002
|
+
// owner schedule a reconnect (a deliberate close() would suppress that signal and
|
|
1003
|
+
// permanently retire this client).
|
|
1004
|
+
this._logErr(`buffered ${this._buf.length} bytes with no complete message (cap ${MAX_MESSAGE_BYTES}); dropping the connection`);
|
|
1005
|
+
this._buf = Buffer.alloc(0);
|
|
1006
|
+
this._treeKill(this._child);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Drain as many complete messages as are buffered. Header (Content-Length) framing
|
|
1012
|
+
* is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
|
|
1013
|
+
* JSON. Mirrors server.js's dual-drain so the client reads either wire format.
|
|
1014
|
+
* A header frame that is only PARTLY here ('wait') stops the drain - handing the
|
|
1015
|
+
* half-arrived header to line framing would eat it and orphan the body (the frame
|
|
1016
|
+
* was lost and the caller saw only a timeout; chunk boundaries are the transport's
|
|
1017
|
+
* choice, so any CL-framing upstream could hit it under load).
|
|
1018
|
+
*/
|
|
1019
|
+
_drain() {
|
|
1020
|
+
for (;;) {
|
|
1021
|
+
if (this._skipRemaining > 0) {
|
|
1022
|
+
// A refused over-cap message: discard exactly its declared bytes as they arrive so the
|
|
1023
|
+
// pipe stays in frame for everything after it.
|
|
1024
|
+
const eat = Math.min(this._skipRemaining, this._buf.length);
|
|
1025
|
+
this._buf = this._buf.slice(eat);
|
|
1026
|
+
this._skipRemaining -= eat;
|
|
1027
|
+
if (this._skipRemaining > 0) return; // the rest of the refused body is still in flight
|
|
1028
|
+
}
|
|
1029
|
+
const h = this._tryHeaderFramed();
|
|
1030
|
+
if (h === true) continue;
|
|
1031
|
+
if (h === 'wait') break; // an incomplete header frame - wait for more bytes
|
|
1032
|
+
if (this._tryLineFramed()) continue;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** True when the buffer BEGINS with a framing header whose block terminator hasn't arrived. */
|
|
1038
|
+
_headerInFlight() {
|
|
1039
|
+
return /^content-(length|type):/i.test(this._buf.slice(0, 16).toString('utf8'));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Pull one Content-Length-framed message off the front of the buffer.
|
|
1044
|
+
* Returns true (consumed one), false (not header framing - try line framing), or
|
|
1045
|
+
* 'wait' (a header frame is mid-flight - stop draining until more bytes arrive).
|
|
1046
|
+
*/
|
|
1047
|
+
_tryHeaderFramed() {
|
|
1048
|
+
const headerEnd = this._buf.indexOf('\r\n\r\n');
|
|
1049
|
+
if (headerEnd === -1) {
|
|
1050
|
+
// No complete header block - but a buffer that STARTS with a header line is a header
|
|
1051
|
+
// frame whose terminator is still in flight, not line-framed junk.
|
|
1052
|
+
return this._headerInFlight() ? 'wait' : false;
|
|
1053
|
+
}
|
|
1054
|
+
const header = this._buf.slice(0, headerEnd).toString('utf8');
|
|
1055
|
+
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
|
1056
|
+
if (!m) return false; // a \r\n\r\n that isn't a Content-Length header -> not this framing
|
|
1057
|
+
const len = parseInt(m[1], 10);
|
|
1058
|
+
const bodyStart = headerEnd + 4;
|
|
1059
|
+
if (len > MAX_MESSAGE_BYTES) {
|
|
1060
|
+
// Refuse the message, keep the connection: consume the header and let _drain discard
|
|
1061
|
+
// exactly the declared bytes as they arrive. The reply's waiter times out with the
|
|
1062
|
+
// standard window error; everything after the refused body still flows.
|
|
1063
|
+
this._logErr(`upstream declared a ${len}-byte message (cap ${MAX_MESSAGE_BYTES}); skipping it`);
|
|
1064
|
+
this._buf = this._buf.slice(bodyStart);
|
|
1065
|
+
this._skipRemaining = len;
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
if (this._buf.length < bodyStart + len) return 'wait'; // body still in flight - hold the drain
|
|
1069
|
+
const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
|
|
1070
|
+
this._buf = this._buf.slice(bodyStart + len);
|
|
1071
|
+
this._parseAndDispatch(body);
|
|
1072
|
+
return true;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Pull one newline-delimited JSON message off the front of the buffer. */
|
|
1076
|
+
_tryLineFramed() {
|
|
1077
|
+
const nl = this._buf.indexOf(0x0a); // '\n'
|
|
1078
|
+
if (nl === -1) return false;
|
|
1079
|
+
const line = this._buf.slice(0, nl).toString('utf8').trim();
|
|
1080
|
+
this._buf = this._buf.slice(nl + 1);
|
|
1081
|
+
if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
|
|
1082
|
+
// A header line at the FRONT of the buffer never reaches this path (header framing holds
|
|
1083
|
+
// the drain with 'wait'); any non-JSON line here is a framing artifact - ignore it rather
|
|
1084
|
+
// than erroring.
|
|
1085
|
+
if (line[0] !== '{' && line[0] !== '[') return true;
|
|
1086
|
+
this._parseAndDispatch(line);
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Parse one JSON body and route it to its waiter by id. Bad JSON / unmatched ids are ignored. */
|
|
1091
|
+
_parseAndDispatch(text) {
|
|
1092
|
+
let msg;
|
|
1093
|
+
try {
|
|
1094
|
+
msg = JSON.parse(text);
|
|
1095
|
+
} catch (_e) {
|
|
1096
|
+
this._logErr('dropping unparseable message from upstream');
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
|
|
1100
|
+
|
|
1101
|
+
const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
|
|
1102
|
+
if (!hasId) {
|
|
1103
|
+
// A progress beat for an in-flight request proves the upstream is alive and WORKING that
|
|
1104
|
+
// request - re-arm its window so a slow-but-progressing call never dies to the fixed
|
|
1105
|
+
// timeout. The notification still relays below, untouched.
|
|
1106
|
+
if (msg.method === 'notifications/progress' && msg.params &&
|
|
1107
|
+
msg.params.progressToken !== undefined) {
|
|
1108
|
+
this._bumpProgressWaiter(msg.params.progressToken);
|
|
1109
|
+
}
|
|
1110
|
+
// Server-initiated NOTIFICATION (method, no id) - surface it so the aggregator can bridge
|
|
1111
|
+
// change-notifications to a modern subscriptions/listen stream. NEVER throws.
|
|
1112
|
+
if (typeof msg.method === 'string' && typeof this.onNotification === 'function') {
|
|
1113
|
+
try { this.onNotification(msg); } catch (_e) { this._logErr('onNotification threw (ignored)'); }
|
|
1114
|
+
}
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (typeof msg.method === 'string') {
|
|
1119
|
+
// SERVER->CLIENT REQUEST (id + method): the upstream is asking ITS client something -
|
|
1120
|
+
// elicitation/create, sampling/createMessage, roots/list. Dropping one would leave a
|
|
1121
|
+
// conformant upstream holding an open request forever. Surface to the owner;
|
|
1122
|
+
// unhandled -> answer -32601 (the honest "this client cannot do that").
|
|
1123
|
+
if (typeof this.onServerRequest === 'function') {
|
|
1124
|
+
try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
|
|
1125
|
+
}
|
|
1126
|
+
this.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const w = this._waiters.get(msg.id);
|
|
1131
|
+
if (!w) return; // response to an id we no longer await (timed out / closed) - drop it
|
|
1132
|
+
this._waiters.delete(msg.id);
|
|
1133
|
+
try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
|
|
1134
|
+
|
|
1135
|
+
if (msg.error) {
|
|
1136
|
+
const e = msg.error;
|
|
1137
|
+
const message = e && e.message ? e.message : 'JSON-RPC error';
|
|
1138
|
+
const code = e && typeof e.code === 'number' ? ` (code ${e.code})` : '';
|
|
1139
|
+
const err = new Error(`McpClient: upstream error for "${w.method}": ${message}${code}`);
|
|
1140
|
+
// Preserve the VERBATIM JSON-RPC error object - a wrap must relay the upstream's own error
|
|
1141
|
+
// (code/message/data) untouched; the stringified message above is for logs and lean paths.
|
|
1142
|
+
err.rpcError = { code: e && typeof e.code === 'number' ? e.code : -32603, message };
|
|
1143
|
+
if (e && e.data !== undefined) err.rpcError.data = e.data;
|
|
1144
|
+
w.reject(err);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
w.resolve(msg.result);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Frame a JSON-RPC message for the MCP stdio transport: one compact newline-delimited
|
|
1153
|
+
* JSON message. NOT Content-Length framing - see the field note inside.
|
|
1154
|
+
* @param {object} payload
|
|
1155
|
+
* @returns {string}
|
|
1156
|
+
*/
|
|
1157
|
+
function frameRequest(payload) {
|
|
1158
|
+
// MCP stdio transport framing: one compact JSON-RPC message per line, newline-delimited - NOT
|
|
1159
|
+
// LSP-style Content-Length. JSON.stringify emits no literal newlines, so a single trailing "\n"
|
|
1160
|
+
// cleanly delimits the message. This is what real SDK-based MCP servers read on stdin; writing
|
|
1161
|
+
// Content-Length here is what silently broke every real upstream (the read side still tolerates
|
|
1162
|
+
// both framings for robustness against the occasional Content-Length server).
|
|
1163
|
+
return JSON.stringify(payload) + '\n';
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
module.exports = {
|
|
1167
|
+
McpClient,
|
|
1168
|
+
// exported for unit tests / reuse
|
|
1169
|
+
frameRequest,
|
|
1170
|
+
winLaunch,
|
|
1171
|
+
commandMissing,
|
|
1172
|
+
PROTOCOL_VERSION,
|
|
1173
|
+
CLIENT_INFO,
|
|
1174
|
+
};
|