toolfunnel 0.4.0 → 0.6.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 +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- 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-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/src/mcp/mcp-client.js
CHANGED
|
@@ -1,530 +1,907 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* mcp-client.js
|
|
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 Phase 2
|
|
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:
|
|
14
|
-
* MCP
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
this.
|
|
116
|
-
this
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
this.
|
|
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
|
-
this.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.
|
|
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
|
-
this.
|
|
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
|
-
this.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
this.
|
|
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
|
-
}
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-client.js - the MCP CLIENT (architecture notes §1, §7; Phase 2 aggregator).
|
|
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 Phase 2
|
|
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 path = require('node:path');
|
|
39
|
+
|
|
40
|
+
const JSONRPC = '2.0';
|
|
41
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
42
|
+
// The 2026-07-28 ("modern") protocol version this client can SPEAK to a modern upstream. Over
|
|
43
|
+
// stdio the modern era needs only per-request `_meta` - the Mcp-Method/Mcp-Name/MCP-Protocol-Version
|
|
44
|
+
// headers are HTTP-transport-only, so a stdio client is modern by adding `_meta` + skipping the
|
|
45
|
+
// initialize handshake. Built to the RC spec (locked 2026-05-21); reconcile minor final deltas 28 July.
|
|
46
|
+
const MODERN_PROTOCOL_VERSION = '2026-07-28';
|
|
47
|
+
const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cross-platform launch resolution for the upstream command.
|
|
51
|
+
*
|
|
52
|
+
* POSIX: pass through unchanged - execvp resolves `npx` / `uvx` / `node` / ... on PATH.
|
|
53
|
+
*
|
|
54
|
+
* Windows: child_process with shell:false CANNOT run a `.cmd` / `.bat` shim - a bare name ENOENTs,
|
|
55
|
+
* and an explicit `.cmd` throws EINVAL (the CVE-2024-27980 mitigation). Most MCP servers launch via
|
|
56
|
+
* exactly such shims (`npx`, `npm`, `uvx`, `pnpm`, ...), so on Windows route anything that isn't already
|
|
57
|
+
* a concrete `.exe`/`.com` through cmd.exe, which resolves PATHEXT shims. The args stay DISCRETE
|
|
58
|
+
* (never shell-concatenated into one string) and there is none of the `shell:true` DEP0190 hazard.
|
|
59
|
+
* HONEST CAVEAT: because the spawned binary here is cmd.exe itself (an .exe), Node applies its
|
|
60
|
+
* STANDARD argv quoting - not cmd.exe ^-metacharacter escaping (that special-casing only fires when
|
|
61
|
+
* the command IS the .cmd/.bat). An arg containing cmd metacharacters (& | > < ") outside quotes can
|
|
62
|
+
* therefore still be reinterpreted by cmd.exe. These args come from the operator's own expose.json,
|
|
63
|
+
* so this is a correctness caveat on operator-authored config, not a privilege boundary; the fully
|
|
64
|
+
* escaped alternative (resolve the real interpreter à la auth/install.js's `node + npm-cli.js` and
|
|
65
|
+
* spawn it directly) is the upgrade path if it ever bites. close()'s taskkill /T still reaps the
|
|
66
|
+
* whole cmd -> shim -> server process tree.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} command the configured upstream command (e.g. "npx", "node", an abs path)
|
|
69
|
+
* @param {string[]} args the configured argv
|
|
70
|
+
* @returns {{ command: string, args: string[] }} what to actually hand to spawn()
|
|
71
|
+
*/
|
|
72
|
+
function winLaunch(command, args) {
|
|
73
|
+
const argv = Array.isArray(args) ? args : [];
|
|
74
|
+
if (process.platform !== 'win32') return { command, args: argv };
|
|
75
|
+
// A concrete executable can be spawned directly - no shim resolution needed.
|
|
76
|
+
if (path.isAbsolute(command) && /\.(exe|com)$/i.test(command)) return { command, args: argv };
|
|
77
|
+
// Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT.
|
|
78
|
+
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
79
|
+
return { command: comspec, args: ['/c', command, ...argv] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* McpClient - a hand-rolled JSON-RPC 2.0 client over a child process's stdio.
|
|
84
|
+
*
|
|
85
|
+
* Lifecycle: `new McpClient(opts)` -> `await connect()` -> `listTools()` / `callTool()`
|
|
86
|
+
* -> `close()` (idempotent).
|
|
87
|
+
*/
|
|
88
|
+
// Payload-bearing methods get the LONG window (toolTimeoutMs): a tool call legitimately runs as
|
|
89
|
+
// long as the tool runs. Everything else (handshake, lists, discover, ping) keeps the SHORT
|
|
90
|
+
// requestTimeoutMs - that is the dead-upstream detector.
|
|
91
|
+
const PAYLOAD_METHODS = new Set(['tools/call', 'prompts/get', 'resources/read']);
|
|
92
|
+
|
|
93
|
+
class McpClient {
|
|
94
|
+
/**
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {string} [opts.id] caller-chosen id for this upstream (diagnostics only)
|
|
97
|
+
* @param {string} opts.command executable to spawn (e.g. process.execPath)
|
|
98
|
+
* @param {string[]} [opts.args] argv for the child (e.g. [serverPath])
|
|
99
|
+
* @param {object} [opts.env] extra env merged over process.env for the child
|
|
100
|
+
* @param {string} [opts.cwd] child working directory
|
|
101
|
+
* @param {Function} [opts.spawnImpl] injectable spawn (default node:child_process.spawn)
|
|
102
|
+
* @param {number} [opts.requestTimeoutMs] control-plane timeout in ms (default 10000) -
|
|
103
|
+
* handshake/list/discover/ping; the dead-upstream detector
|
|
104
|
+
* @param {number} [opts.toolTimeoutMs] PAYLOAD timeout in ms (default 120000) for
|
|
105
|
+
* tools/call | prompts/get | resources/read; a
|
|
106
|
+
* token-matched progress beat re-arms it
|
|
107
|
+
* @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
|
|
108
|
+
* while not deliberately close()d). Lets the owner
|
|
109
|
+
* (the aggregator) schedule a background reconnect.
|
|
110
|
+
*/
|
|
111
|
+
constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs, toolTimeoutMs, onClose, forceLegacy = false, modernOnly = false, onNotification, clientInfo } = {}) {
|
|
112
|
+
if (typeof command !== 'string' || command.length === 0) {
|
|
113
|
+
throw new Error('McpClient: "command" (non-empty string) is required');
|
|
114
|
+
}
|
|
115
|
+
this.id = typeof id === 'string' && id.length ? id : 'mcp-client';
|
|
116
|
+
// clientInfo: the identity this client PRESENTS to the upstream (legacy initialize and the
|
|
117
|
+
// modern _meta trio). Defaults to the built-in - absent the opt, wire behaviour is byte-identical
|
|
118
|
+
// to before. The wrap's identity mirroring hands the REAL downstream client's info through here.
|
|
119
|
+
// The FULL object is carried (title etc.), not a name/version whitelist - the wrap's serverInfo
|
|
120
|
+
// direction proved a whitelist silently eats spec fields.
|
|
121
|
+
this._clientInfo =
|
|
122
|
+
clientInfo && typeof clientInfo === 'object' &&
|
|
123
|
+
typeof clientInfo.name === 'string' && clientInfo.name.length
|
|
124
|
+
? Object.freeze(Object.assign({}, clientInfo, {
|
|
125
|
+
version: typeof clientInfo.version === 'string' ? clientInfo.version : '0.0.0',
|
|
126
|
+
}))
|
|
127
|
+
: CLIENT_INFO;
|
|
128
|
+
this._command = command;
|
|
129
|
+
this._args = Array.isArray(args) ? args.slice() : [];
|
|
130
|
+
this._env = env && typeof env === 'object' ? env : {};
|
|
131
|
+
this._cwd = cwd;
|
|
132
|
+
this._spawn = typeof spawnImpl === 'function' ? spawnImpl : childProcess.spawn;
|
|
133
|
+
this._requestTimeoutMs =
|
|
134
|
+
Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 10000;
|
|
135
|
+
// An EXPLICITLY configured control window also governs the era probe in connect(): the
|
|
136
|
+
// operator opted into slower connects for a slow-boot upstream, and the probe must not
|
|
137
|
+
// quietly undo that (a slow-boot modern-only server could otherwise never attach).
|
|
138
|
+
this._explicitRequestTimeout = Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0;
|
|
139
|
+
// The PAYLOAD window (tools/call | prompts/get | resources/read): a tool legitimately runs
|
|
140
|
+
// as long as the tool runs - the fixed 10 s window hard-killed every wrapped tool slower
|
|
141
|
+
// than that and silently dropped the upstream's eventual result. Per-upstream
|
|
142
|
+
// `timeoutMs` config lands here; a token-matched progress beat re-arms the window.
|
|
143
|
+
this._toolTimeoutMs =
|
|
144
|
+
Number.isFinite(toolTimeoutMs) && toolTimeoutMs > 0 ? toolTimeoutMs : 120000;
|
|
145
|
+
this._onClose = typeof onClose === 'function' ? onClose : null;
|
|
146
|
+
// forceLegacy (legacyPin): skip the modern server/discover probe and speak legacy unconditionally.
|
|
147
|
+
this._forceLegacy = forceLegacy === true;
|
|
148
|
+
// modernOnly: the MIRROR of legacyPin - refuse the legacy fallback entirely. A modernOnly
|
|
149
|
+
// upstream that does not answer server/discover with the modern protocol FAILS the connect
|
|
150
|
+
// with a clear error instead of silently negotiating down (era-policy switches, 2026-07-18).
|
|
151
|
+
this._modernOnly = modernOnly === true;
|
|
152
|
+
// onNotification: called with each server-initiated NOTIFICATION (a message with a method and
|
|
153
|
+
// NO id) - e.g. notifications/resources/updated, .../resources/list_changed, .../prompts/
|
|
154
|
+
// list_changed, .../tools/list_changed. Lets the aggregator bridge upstream change-notifications
|
|
155
|
+
// to a modern client's subscriptions/listen stream. Settable post-construction too.
|
|
156
|
+
this.onNotification = typeof onNotification === 'function' ? onNotification : null;
|
|
157
|
+
// onServerRequest: called with each SERVER-INITIATED REQUEST (a message with a method AND an
|
|
158
|
+
// id - elicitation/create, sampling/createMessage, roots/list). The owner answers it via
|
|
159
|
+
// respondToServer/respondToServerError. Unset -> every such request is answered -32601, so a
|
|
160
|
+
// conformant upstream is never left holding an unanswered request (Bridge B infrastructure).
|
|
161
|
+
this.onServerRequest = null;
|
|
162
|
+
this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
|
|
163
|
+
this._everConnected = false; // true once the handshake completed - onClose fires ONLY for the
|
|
164
|
+
// death of an ESTABLISHED connection, never a connect-time failure
|
|
165
|
+
|
|
166
|
+
// Transport state.
|
|
167
|
+
this._child = null;
|
|
168
|
+
this._connected = false;
|
|
169
|
+
this._closed = false;
|
|
170
|
+
this._buf = Buffer.alloc(0);
|
|
171
|
+
this._stderr = '';
|
|
172
|
+
this._nextId = 1;
|
|
173
|
+
/** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
|
|
174
|
+
this._waiters = new Map();
|
|
175
|
+
|
|
176
|
+
// The initialize result, captured for connect()'s return value.
|
|
177
|
+
this._initializeResult = null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @returns {boolean} true once the initialize handshake has completed and not yet closed. */
|
|
181
|
+
get connected() {
|
|
182
|
+
return this._connected === true && this._closed === false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @returns {object|null} the upstream's raw `initialize` result ({ protocolVersion, serverInfo,
|
|
186
|
+
* capabilities, instructions }) captured at connect. Used by the passthrough WRAP to present the
|
|
187
|
+
* wrapped server's OWN identity (a wrapped MCP must be indistinguishable from the real thing). */
|
|
188
|
+
get initializeResult() {
|
|
189
|
+
return this._initializeResult;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Raw JSON-RPC passthrough for the WRAP: forward a method to the upstream verbatim and return its
|
|
194
|
+
* result. Used to relay non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
|
|
195
|
+
* server behaves exactly as it would connected directly. Tool calls do NOT use this - they go
|
|
196
|
+
* through the gated forward path. Rejects on timeout / transport failure (same as callTool).
|
|
197
|
+
* @param {string} method
|
|
198
|
+
* @param {any} [params]
|
|
199
|
+
* @returns {Promise<any>} the upstream's raw result
|
|
200
|
+
*/
|
|
201
|
+
async request(method, params, out) {
|
|
202
|
+
if (typeof method !== 'string' || method.length === 0) {
|
|
203
|
+
throw new Error('McpClient.request: "method" (non-empty string) is required');
|
|
204
|
+
}
|
|
205
|
+
let p = params == null ? {} : params;
|
|
206
|
+
// Modern upstream: ensure the per-request `_meta` protocol trio is present. MERGE with any
|
|
207
|
+
// caller-supplied _meta (progressToken, trace keys survive a wrap forward) - the trio wins on
|
|
208
|
+
// collision; a partial caller _meta must never reach a strict modern upstream trio-less.
|
|
209
|
+
if (this._modern && p && typeof p === 'object' && !Array.isArray(p)) {
|
|
210
|
+
const callerMeta = (p._meta && typeof p._meta === 'object' && !Array.isArray(p._meta)) ? p._meta : null;
|
|
211
|
+
p = Object.assign({}, p, { _meta: Object.assign({}, callerMeta, this._modernMeta()) });
|
|
212
|
+
}
|
|
213
|
+
return await this._request(method, p, out);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Send a NOTIFICATION to the upstream - no id, no waiter, no reply. Used by a wrap to relay
|
|
218
|
+
* client notifications (notifications/cancelled, notifications/progress ...) that a
|
|
219
|
+
* directly-connected server would receive. Fire-and-forget; NEVER throws.
|
|
220
|
+
* @param {string} method
|
|
221
|
+
* @param {any} params
|
|
222
|
+
*/
|
|
223
|
+
notify(method, params) {
|
|
224
|
+
try {
|
|
225
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
226
|
+
if (typeof method !== 'string' || method.length === 0) return;
|
|
227
|
+
const payload = { jsonrpc: JSONRPC, method };
|
|
228
|
+
if (params !== undefined) payload.params = params;
|
|
229
|
+
this._child.stdin.write(frameRequest(payload));
|
|
230
|
+
} catch (_e) { /* fire-and-forget */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Answer a SERVER-INITIATED request (Bridge B): write a JSON-RPC RESPONSE carrying the
|
|
235
|
+
* upstream's own request id. Fire-and-forget; NEVER throws.
|
|
236
|
+
* @param {number|string} id the id the UPSTREAM used on its request
|
|
237
|
+
* @param {any} result the result payload (e.g. an ElicitResult { action, content })
|
|
238
|
+
*/
|
|
239
|
+
respondToServer(id, result) {
|
|
240
|
+
try {
|
|
241
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
242
|
+
if (id === undefined || id === null) return;
|
|
243
|
+
this._child.stdin.write(frameRequest({ jsonrpc: JSONRPC, id, result: result === undefined ? {} : result }));
|
|
244
|
+
} catch (_e) { /* fire-and-forget */ }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extend the timeout of an in-flight request (Bridge B): a mid-call elicitation proves the
|
|
249
|
+
* upstream is alive and deliberately holding the call open while a HUMAN answers - the normal
|
|
250
|
+
* dead-upstream timeout must not kill the suspension. Re-arms the waiter's timer; returns
|
|
251
|
+
* false if the request already settled. NEVER throws.
|
|
252
|
+
* @param {number|string} id the rpc id of the in-flight request
|
|
253
|
+
* @param {number} ms the new timeout window from now
|
|
254
|
+
*/
|
|
255
|
+
extendRequestTimeout(id, ms) {
|
|
256
|
+
try {
|
|
257
|
+
const w = this._waiters.get(id);
|
|
258
|
+
if (!w) return false;
|
|
259
|
+
clearTimeout(w.timer);
|
|
260
|
+
// The extension becomes the waiter's WINDOW, not just its current timer: a token-matched
|
|
261
|
+
// progress beat re-arms to w.win, so leaving the old (short) win in place let a single
|
|
262
|
+
// beat during a Bridge-B suspension collapse the human-answer hold back to toolTimeoutMs
|
|
263
|
+
//Progress during a hold now re-arms to the hold.
|
|
264
|
+
const win = Number.isFinite(ms) && ms > 0 ? ms : this._requestTimeoutMs;
|
|
265
|
+
w.win = win;
|
|
266
|
+
w.timer = setTimeout(() => {
|
|
267
|
+
this._waiters.delete(id);
|
|
268
|
+
w.reject(new Error(`McpClient: request("${w.method}") timed out (suspended-call window elapsed)`));
|
|
269
|
+
}, win);
|
|
270
|
+
if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
|
|
271
|
+
return true;
|
|
272
|
+
} catch (_e) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Re-arm the timeout of the in-flight request matching this progressToken:
|
|
279
|
+
* a progress beat is proof of life for THAT request. Token-matched only - a dead upstream
|
|
280
|
+
* still times out on its full window. NEVER throws.
|
|
281
|
+
* @param {number|string} token the progressToken from a notifications/progress params
|
|
282
|
+
*/
|
|
283
|
+
_bumpProgressWaiter(token) {
|
|
284
|
+
try {
|
|
285
|
+
for (const [id, w] of this._waiters) {
|
|
286
|
+
if (w.progressToken === undefined || w.progressToken !== token) continue;
|
|
287
|
+
clearTimeout(w.timer);
|
|
288
|
+
const win = Number.isFinite(w.win) && w.win > 0 ? w.win : this._toolTimeoutMs;
|
|
289
|
+
w.timer = setTimeout(() => {
|
|
290
|
+
this._waiters.delete(id);
|
|
291
|
+
w.reject(new Error(
|
|
292
|
+
`McpClient: timeout after ${win}ms without progress waiting for "${w.method}". stderr so far:\n${this._stderr}`
|
|
293
|
+
));
|
|
294
|
+
}, win);
|
|
295
|
+
if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
} catch (_e) { /* re-arm is best-effort; the original timer stands */ }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Answer a server-initiated request with a JSON-RPC ERROR. Fire-and-forget; NEVER throws. */
|
|
302
|
+
respondToServerError(id, code, message) {
|
|
303
|
+
try {
|
|
304
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
305
|
+
if (id === undefined || id === null) return;
|
|
306
|
+
this._child.stdin.write(frameRequest({
|
|
307
|
+
jsonrpc: JSONRPC, id,
|
|
308
|
+
error: { code: typeof code === 'number' ? code : -32603, message: String(message || 'error') },
|
|
309
|
+
}));
|
|
310
|
+
} catch (_e) { /* fire-and-forget */ }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Diagnostics -> never throw. ───────────────────────────────────────────────────────────────
|
|
314
|
+
_logErr(...parts) {
|
|
315
|
+
try {
|
|
316
|
+
process.stderr.write(`[mcp-client:${this.id}] ${parts.join(' ')}\n`);
|
|
317
|
+
} catch (_e) {
|
|
318
|
+
/* never let logging throw */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
323
|
+
// connect(): spawn the child, perform the MCP initialize handshake, send the
|
|
324
|
+
// notifications/initialized notification, set connected. On ANY failure (spawn
|
|
325
|
+
// error, initialize timeout/error) close() the child and reject - never leave a zombie.
|
|
326
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
327
|
+
async connect() {
|
|
328
|
+
if (this._closed) throw new Error('McpClient: connect() called after close()');
|
|
329
|
+
if (this._connected) return this._initializeResult;
|
|
330
|
+
|
|
331
|
+
// 1) Spawn the child (shared with probeDiscover - a probe may already have spawned it).
|
|
332
|
+
if (!this._child) this._spawnChild();
|
|
333
|
+
|
|
334
|
+
// 2) DUAL-ERA negotiation (the spec's dual-era CLIENT rule: try MODERN first). Probe
|
|
335
|
+
// `server/discover`: a modern or dual-era upstream answers with a DiscoverResult that lists
|
|
336
|
+
// the modern version; a legacy-only upstream rejects it (method-not-found) or drops. On a
|
|
337
|
+
// valid modern answer we speak MODERN (no initialize; per-request `_meta` on every call).
|
|
338
|
+
// Otherwise (or when legacyPin FORCES legacy) we fall back to the LEGACY initialize handshake
|
|
339
|
+
// (byte-for-byte the prior behaviour).
|
|
340
|
+
let discovered = null;
|
|
341
|
+
if (!this._forceLegacy) {
|
|
342
|
+
let probe = null;
|
|
343
|
+
// The probe gets a SHORT timeout of its own: a legacy server that silently DROPS unknown
|
|
344
|
+
// methods (instead of answering -32601) would otherwise stall every connect by the full
|
|
345
|
+
// request timeout - serialised across upstreams at startup. An EXPLICIT per-upstream
|
|
346
|
+
// requestTimeoutMs overrides the clamp - a slow-boot modern-only server needs the raised
|
|
347
|
+
// window HERE, and the bin-side era probe already honours it (keep the two symmetric).
|
|
348
|
+
const fullTimeout = this._requestTimeoutMs;
|
|
349
|
+
this._requestTimeoutMs = this._explicitRequestTimeout
|
|
350
|
+
? fullTimeout : Math.min(3000, fullTimeout || 3000);
|
|
351
|
+
try {
|
|
352
|
+
probe = await this._request('server/discover', { _meta: this._modernMeta() });
|
|
353
|
+
} catch (_probeErr) {
|
|
354
|
+
probe = null; // legacy upstream (method-not-found) - fall through to initialize
|
|
355
|
+
if (this._closed) throw new Error('McpClient: connect() aborted (closed during discover probe)');
|
|
356
|
+
if (!this._child) this._spawnChild(); // a fragile legacy server may have died on the probe
|
|
357
|
+
} finally {
|
|
358
|
+
this._requestTimeoutMs = fullTimeout;
|
|
359
|
+
}
|
|
360
|
+
// Only treat the reply as MODERN if it is a real DiscoverResult advertising the modern
|
|
361
|
+
// version - a permissive legacy server that answers unknown methods with `{}`/an empty object
|
|
362
|
+
// must NOT be misdetected as modern.
|
|
363
|
+
if (probe && typeof probe === 'object' && Array.isArray(probe.supportedVersions) &&
|
|
364
|
+
probe.supportedVersions.includes(MODERN_PROTOCOL_VERSION)) {
|
|
365
|
+
discovered = probe;
|
|
366
|
+
} else if (probe && this._child) {
|
|
367
|
+
// Answered, but not a modern DiscoverResult -> legacy. The child is alive (it replied), so the
|
|
368
|
+
// legacy initialize below runs on the same child.
|
|
369
|
+
discovered = null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// modernOnly refuses the legacy fallback: no valid modern DiscoverResult means no connection,
|
|
374
|
+
// with an error that names the policy - never a silent downgrade.
|
|
375
|
+
if (!discovered && this._modernOnly) {
|
|
376
|
+
this.close();
|
|
377
|
+
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`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (discovered) {
|
|
381
|
+
// MODERN upstream - identity from discover; NO initialize / notifications/initialized.
|
|
382
|
+
this._modern = true;
|
|
383
|
+
this._initializeResult = this._identityFromDiscover(discovered);
|
|
384
|
+
} else {
|
|
385
|
+
// LEGACY upstream - the initialize handshake. A spawn 'error' rejects via the waiter path; a
|
|
386
|
+
// silent server hits the request timeout. Either way close() + a clear error.
|
|
387
|
+
try {
|
|
388
|
+
const result = await this._request('initialize', {
|
|
389
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
390
|
+
clientInfo: this._clientInfo,
|
|
391
|
+
// elicitation: real SDK-based servers GATE ctx.elicit() on the client declaring this
|
|
392
|
+
// capability - without it they answer "Method not found" and never elicit at all
|
|
393
|
+
// (wild-scout finding, 2026-07-17). We honestly handle every elicitation now: the
|
|
394
|
+
// Bridge B MRTR path for modern callers, a clean decline otherwise.
|
|
395
|
+
capabilities: { elicitation: {} },
|
|
396
|
+
});
|
|
397
|
+
this._initializeResult = result;
|
|
398
|
+
} catch (err) {
|
|
399
|
+
this.close();
|
|
400
|
+
throw new Error(`McpClient: initialize failed: ${(err && err.message) || String(err)}`);
|
|
401
|
+
}
|
|
402
|
+
// Tell the server the handshake is complete (notification - no id, no reply).
|
|
403
|
+
this._notify('notifications/initialized', {});
|
|
404
|
+
this._modern = false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this._connected = true;
|
|
408
|
+
this._everConnected = true; // from here on, an exit/error is an ESTABLISHED-connection death
|
|
409
|
+
return this._initializeResult;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** @returns {'modern'|'legacy'} the era negotiated at connect (default 'legacy' before connect). */
|
|
413
|
+
get era() {
|
|
414
|
+
return this._modern === true ? 'modern' : 'legacy';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The per-request `_meta` a modern client MUST send on every request (stdio: no headers). */
|
|
418
|
+
_modernMeta() {
|
|
419
|
+
return {
|
|
420
|
+
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
|
|
421
|
+
'io.modelcontextprotocol/clientCapabilities': {},
|
|
422
|
+
'io.modelcontextprotocol/clientInfo': this._clientInfo,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @returns {{name:string, version:string}} the identity this client presents upstream. */
|
|
427
|
+
get clientInfo() {
|
|
428
|
+
return this._clientInfo;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Normalise a `server/discover` result into the common identity shape wrappedIdentity + connect()
|
|
433
|
+
* consumers expect ({ protocolVersion, serverInfo, capabilities, instructions }) - so a MODERN
|
|
434
|
+
* upstream's identity reads the same as a legacy `initialize` result. serverInfo lives in the
|
|
435
|
+
* discover result's `_meta['io.modelcontextprotocol/serverInfo']`.
|
|
436
|
+
*/
|
|
437
|
+
_identityFromDiscover(disc) {
|
|
438
|
+
const d = disc && typeof disc === 'object' ? disc : {};
|
|
439
|
+
const meta = d._meta && typeof d._meta === 'object' ? d._meta : {};
|
|
440
|
+
const si = meta['io.modelcontextprotocol/serverInfo'];
|
|
441
|
+
const versions = Array.isArray(d.supportedVersions) ? d.supportedVersions : [];
|
|
442
|
+
return {
|
|
443
|
+
// We negotiated MODERN (this path is only reached for a modern DiscoverResult), so report the
|
|
444
|
+
// modern version - not `versions[0]`, whose ordering the spec does not guarantee (a dual-era
|
|
445
|
+
// upstream listing legacy first would otherwise yield a contradictory identity).
|
|
446
|
+
protocolVersion: versions.includes(MODERN_PROTOCOL_VERSION) ? MODERN_PROTOCOL_VERSION : (versions[0] || MODERN_PROTOCOL_VERSION),
|
|
447
|
+
serverInfo: si && typeof si === 'object' ? si : undefined,
|
|
448
|
+
capabilities: d.capabilities && typeof d.capabilities === 'object' ? d.capabilities : undefined,
|
|
449
|
+
instructions: typeof d.instructions === 'string' ? d.instructions : undefined,
|
|
450
|
+
supportedVersions: versions,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Spawn the child + wire its pipes (no handshake). A synchronous spawn throw (bad command) is
|
|
456
|
+
* caught and rethrown clean. Extracted from connect() so probeDiscover() can share it.
|
|
457
|
+
*/
|
|
458
|
+
_spawnChild() {
|
|
459
|
+
try {
|
|
460
|
+
// Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
|
|
461
|
+
const launch = winLaunch(this._command, this._args);
|
|
462
|
+
this._child = this._spawn(launch.command, launch.args, {
|
|
463
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
464
|
+
cwd: this._cwd,
|
|
465
|
+
env: Object.assign({}, process.env, this._env),
|
|
466
|
+
windowsHide: true,
|
|
467
|
+
});
|
|
468
|
+
} catch (err) {
|
|
469
|
+
this._child = null;
|
|
470
|
+
throw new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!this._child || !this._child.stdin || !this._child.stdout) {
|
|
474
|
+
// A fake/odd spawnImpl that didn't give us pipes - bail cleanly.
|
|
475
|
+
this.close();
|
|
476
|
+
throw new Error('McpClient: spawned child is missing stdio pipes');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
this._wireChild();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Era probe - the 2026-07-28 spec's dual-era client rule for stdio: try `server/discover`
|
|
484
|
+
* BEFORE any legacy `initialize`. A modern or dual-era server answers with its
|
|
485
|
+
* supportedVersions; a legacy server rejects it (-32601 Method not found) or times out.
|
|
486
|
+
* Resolves the DiscoverResult, rejects otherwise. Sends the request with modern per-request
|
|
487
|
+
* `_meta` (required in the modern era). Does NOT set connected - this is a probe, not a
|
|
488
|
+
* handshake; call connect() for the legacy handshake (fresh client recommended: a badly
|
|
489
|
+
* behaved legacy server may react badly to an unknown method).
|
|
490
|
+
* @returns {Promise<object>} the server/discover result
|
|
491
|
+
*/
|
|
492
|
+
async probeDiscover() {
|
|
493
|
+
if (this._closed) throw new Error('McpClient: probeDiscover() called after close()');
|
|
494
|
+
if (!this._child) this._spawnChild();
|
|
495
|
+
try {
|
|
496
|
+
return await this._request('server/discover', {
|
|
497
|
+
_meta: {
|
|
498
|
+
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
|
499
|
+
'io.modelcontextprotocol/clientCapabilities': {},
|
|
500
|
+
'io.modelcontextprotocol/clientInfo': this._clientInfo,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
} catch (err) {
|
|
504
|
+
// Mirror connect()'s close-on-failure contract: a probe that times out or errors must not
|
|
505
|
+
// leave a zombie child (the caller may not have a finally). close() is idempotent.
|
|
506
|
+
this.close();
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* tools/list -> the upstream's tool definitions. Returns the raw array
|
|
513
|
+
* [{name, description, inputSchema}] from result.tools (empty array if the
|
|
514
|
+
* server returned no tools). Rejects on timeout / transport failure.
|
|
515
|
+
* @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
|
|
516
|
+
*/
|
|
517
|
+
async listTools() {
|
|
518
|
+
// Modern upstream: every request carries per-request `_meta` (stdio needs no headers).
|
|
519
|
+
const params = this._modern ? { _meta: this._modernMeta() } : {};
|
|
520
|
+
const result = await this._request('tools/list', params);
|
|
521
|
+
const tools = result && Array.isArray(result.tools) ? result.tools : [];
|
|
522
|
+
return tools;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* tools/call -> invoke one upstream tool. Returns the MCP tools/call result
|
|
527
|
+
* envelope { content, isError }. Rejects on timeout / transport failure (a tool
|
|
528
|
+
* that ran but returned an error sets isError:true in the resolved value - that
|
|
529
|
+
* is NOT a rejection).
|
|
530
|
+
* @param {string} name the upstream tool name
|
|
531
|
+
* @param {any} args structured arguments (forwarded as the `arguments` field)
|
|
532
|
+
* @param {object} [meta] the caller's request-scoped _meta (progressToken et al., protocol keys
|
|
533
|
+
* already stripped by the server). Rides the forward: without it the upstream
|
|
534
|
+
* never emits progress for the call, so a long funnel tool cannot keep its
|
|
535
|
+
* own call alive.
|
|
536
|
+
* @returns {Promise<{content:any[], isError:boolean}>}
|
|
537
|
+
*/
|
|
538
|
+
async callTool(name, args, meta) {
|
|
539
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
540
|
+
throw new Error('McpClient.callTool: "name" (non-empty string) is required');
|
|
541
|
+
}
|
|
542
|
+
const params = { name, arguments: args == null ? {} : args };
|
|
543
|
+
if (meta && typeof meta === 'object' && !Array.isArray(meta) && Object.keys(meta).length) {
|
|
544
|
+
params._meta = { ...meta };
|
|
545
|
+
}
|
|
546
|
+
// modern upstream: the per-request protocol trio always wins over caller keys
|
|
547
|
+
if (this._modern) params._meta = { ...(params._meta || {}), ...this._modernMeta() };
|
|
548
|
+
const result = await this._request('tools/call', params);
|
|
549
|
+
const r = result && typeof result === 'object' ? result : {};
|
|
550
|
+
const out = {
|
|
551
|
+
content: Array.isArray(r.content) ? r.content : [],
|
|
552
|
+
isError: r.isError === true,
|
|
553
|
+
};
|
|
554
|
+
// structuredContent rides along verbatim - dropping it silently stripped modern upstreams'
|
|
555
|
+
// structured results on the curated-direct path while the raw wrap path kept them
|
|
556
|
+
if (r.structuredContent !== undefined) out.structuredContent = r.structuredContent;
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
561
|
+
// close(): idempotent teardown. End child stdin so a well-behaved server exits on
|
|
562
|
+
// EOF; if it is still alive, tree-kill it (win32 taskkill /T /F to reap the whole
|
|
563
|
+
// process tree; else child.kill()). Reject every in-flight request so nothing leaks.
|
|
564
|
+
// NEVER throws - it is called from error paths.
|
|
565
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
566
|
+
close() {
|
|
567
|
+
if (this._closed) return;
|
|
568
|
+
this._closed = true;
|
|
569
|
+
this._connected = false;
|
|
570
|
+
|
|
571
|
+
const child = this._child;
|
|
572
|
+
|
|
573
|
+
// Reject any in-flight requests first (so they don't fire their timers after teardown).
|
|
574
|
+
this._rejectAllWaiters(new Error('McpClient: connection closed'));
|
|
575
|
+
|
|
576
|
+
if (!child) return;
|
|
577
|
+
|
|
578
|
+
// Best-effort: end stdin to let the server exit on EOF.
|
|
579
|
+
try {
|
|
580
|
+
if (child.stdin && !child.stdin.destroyed) child.stdin.end();
|
|
581
|
+
} catch (_e) {
|
|
582
|
+
/* ignore */
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// If still alive, tree-kill so no descendant is orphaned (zombie guard).
|
|
586
|
+
try {
|
|
587
|
+
const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
|
|
588
|
+
if (alive && typeof child.pid === 'number') {
|
|
589
|
+
if (process.platform === 'win32') {
|
|
590
|
+
// /T = whole tree, /F = force. spawnSync so it completes synchronously here.
|
|
591
|
+
try {
|
|
592
|
+
childProcess.spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
593
|
+
windowsHide: true,
|
|
594
|
+
});
|
|
595
|
+
} catch (_e) {
|
|
596
|
+
// Fall back to a plain kill if taskkill is unavailable.
|
|
597
|
+
try { child.kill(); } catch (_e2) { /* ignore */ }
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
child.kill();
|
|
601
|
+
}
|
|
602
|
+
} else if (typeof child.kill === 'function') {
|
|
603
|
+
// Already exited or a fake child - a no-op kill is harmless.
|
|
604
|
+
try { child.kill(); } catch (_e) { /* ignore */ }
|
|
605
|
+
}
|
|
606
|
+
} catch (_e) {
|
|
607
|
+
/* never throw out of close */
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
this._child = null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
614
|
+
// Internals.
|
|
615
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
/** Attach the stdout reader, stderr capture, and exit/error handlers to the child. */
|
|
618
|
+
_wireChild() {
|
|
619
|
+
const child = this._child;
|
|
620
|
+
|
|
621
|
+
child.stdout.on('data', (chunk) => this._onData(chunk));
|
|
622
|
+
child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
|
|
623
|
+
|
|
624
|
+
if (child.stderr) {
|
|
625
|
+
child.stderr.on('data', (d) => {
|
|
626
|
+
this._stderr += d.toString('utf8');
|
|
627
|
+
// Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
|
|
628
|
+
if (this._stderr.length > 64 * 1024) {
|
|
629
|
+
this._stderr = this._stderr.slice(-32 * 1024);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
child.stderr.on('error', () => { /* ignore */ });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Either of these means the child is gone - fail every in-flight request so nothing hangs, and
|
|
636
|
+
// NULL this._child so a later connect() on the same instance respawns instead of writing to a
|
|
637
|
+
// dead stdin (connect() reuses `this._child` when set - a probeDiscover() against a legacy
|
|
638
|
+
// server that dies on the unknown method would otherwise poison a follow-up connect()). Only
|
|
639
|
+
// null it if it is still THIS child (a fresh respawn may have replaced it).
|
|
640
|
+
child.on('exit', (code, signal) => {
|
|
641
|
+
this._connected = false;
|
|
642
|
+
if (this._child === child) this._child = null;
|
|
643
|
+
this._rejectAllWaiters(
|
|
644
|
+
new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
|
|
645
|
+
);
|
|
646
|
+
this._fireOnClose(`exited (code=${code}, signal=${signal})`);
|
|
647
|
+
});
|
|
648
|
+
child.on('error', (err) => {
|
|
649
|
+
// Spawn-level failure (e.g. ENOENT) - surfaces here, not as a throw from spawn().
|
|
650
|
+
this._connected = false;
|
|
651
|
+
if (this._child === child) this._child = null;
|
|
652
|
+
this._rejectAllWaiters(
|
|
653
|
+
new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`)
|
|
654
|
+
);
|
|
655
|
+
this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Fire the onClose callback ONCE, and ONLY for an UNEXPECTED death - never when close() was
|
|
661
|
+
* called deliberately (close() sets _closed=true before tree-killing the child, so the resulting
|
|
662
|
+
* 'exit' is suppressed here). This is the signal the aggregator uses to schedule a background
|
|
663
|
+
* reconnect. Wrapped - a throwing callback must not escape an event handler.
|
|
664
|
+
* @param {string} reason
|
|
665
|
+
*/
|
|
666
|
+
_fireOnClose(reason) {
|
|
667
|
+
// ONLY an established connection dying counts. A connect-time failure (child exits during the
|
|
668
|
+
// handshake, ENOENT, etc.) must NOT fire onClose: it surfaces as connect()'s thrown error, and
|
|
669
|
+
// the aggregator's reconnect-retry path owns the backoff. Firing here would let a death inside a
|
|
670
|
+
// reconnect attempt reset the backoff counter to 0 -> an unbounded ~1s respawn storm.
|
|
671
|
+
if (!this._everConnected) return;
|
|
672
|
+
if (this._closed) return; // deliberate close() - not an unexpected death
|
|
673
|
+
if (this._onCloseFired) return; // exit AND error can both fire; notify once
|
|
674
|
+
this._onCloseFired = true;
|
|
675
|
+
if (typeof this._onClose === 'function') {
|
|
676
|
+
try { this._onClose(reason); } catch (_e) { this._logErr('onClose callback threw (ignored)'); }
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Reject + clear every pending waiter (used on close / exit / error). */
|
|
681
|
+
_rejectAllWaiters(err) {
|
|
682
|
+
if (this._waiters.size === 0) return;
|
|
683
|
+
for (const [, w] of this._waiters) {
|
|
684
|
+
try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
|
|
685
|
+
try { w.reject(err); } catch (_e) { /* ignore */ }
|
|
686
|
+
}
|
|
687
|
+
this._waiters.clear();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Send a JSON-RPC request with an id and await its matching response. Rejects on
|
|
692
|
+
* timeout (requestTimeoutMs) after deleting the waiter so it can't leak. Resolves
|
|
693
|
+
* with the response's `result` (or rejects with its `error`).
|
|
694
|
+
*/
|
|
695
|
+
_request(method, params, out) {
|
|
696
|
+
if (this._closed) {
|
|
697
|
+
return Promise.reject(new Error(`McpClient: request("${method}") after close()`));
|
|
698
|
+
}
|
|
699
|
+
if (!this._child || !this._child.stdin) {
|
|
700
|
+
return Promise.reject(new Error(`McpClient: request("${method}") with no live child`));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const id = this._nextId++;
|
|
704
|
+
// Report the issued rpc id synchronously (before any I/O) - a wrap needs the client-id ->
|
|
705
|
+
// upstream-id mapping LIVE to translate notifications/cancelled for in-flight forwards.
|
|
706
|
+
if (out && typeof out === 'object') out.rpcId = id;
|
|
707
|
+
const payload = { jsonrpc: JSONRPC, id, method };
|
|
708
|
+
if (params !== undefined) payload.params = params;
|
|
709
|
+
|
|
710
|
+
// Method-class window: payload methods wait toolTimeoutMs, control-plane keeps the short
|
|
711
|
+
// dead-upstream window. The caller's progressToken (rides params._meta on
|
|
712
|
+
// a wrap forward) is remembered so a matching progress beat can re-arm the window.
|
|
713
|
+
const win = PAYLOAD_METHODS.has(method) ? this._toolTimeoutMs : this._requestTimeoutMs;
|
|
714
|
+
const ptok = params && params._meta && typeof params._meta === 'object' &&
|
|
715
|
+
params._meta.progressToken !== undefined ? params._meta.progressToken : undefined;
|
|
716
|
+
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
const timer = setTimeout(() => {
|
|
719
|
+
this._waiters.delete(id);
|
|
720
|
+
reject(new Error(
|
|
721
|
+
`McpClient: timeout after ${win}ms waiting for "${method}". stderr so far:\n${this._stderr}`
|
|
722
|
+
));
|
|
723
|
+
}, win);
|
|
724
|
+
// Don't let a pending timer keep the event loop (and the test process) alive.
|
|
725
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
726
|
+
|
|
727
|
+
this._waiters.set(id, { resolve, reject, timer, method, win, progressToken: ptok });
|
|
728
|
+
|
|
729
|
+
let framed;
|
|
730
|
+
try {
|
|
731
|
+
framed = frameRequest(payload);
|
|
732
|
+
} catch (err) {
|
|
733
|
+
this._waiters.delete(id);
|
|
734
|
+
clearTimeout(timer);
|
|
735
|
+
reject(new Error(`McpClient: failed to serialise "${method}": ${(err && err.message) || String(err)}`));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
this._child.stdin.write(framed, (err) => {
|
|
741
|
+
if (err) {
|
|
742
|
+
this._waiters.delete(id);
|
|
743
|
+
clearTimeout(timer);
|
|
744
|
+
reject(new Error(`McpClient: stdin write failed for "${method}": ${(err && err.message) || String(err)}`));
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
} catch (err) {
|
|
748
|
+
this._waiters.delete(id);
|
|
749
|
+
clearTimeout(timer);
|
|
750
|
+
reject(new Error(`McpClient: stdin write threw for "${method}": ${(err && err.message) || String(err)}`));
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Fire-and-forget JSON-RPC notification (no id, no reply expected). Never throws. */
|
|
756
|
+
_notify(method, params) {
|
|
757
|
+
if (this._closed || !this._child || !this._child.stdin) return;
|
|
758
|
+
const payload = { jsonrpc: JSONRPC, method };
|
|
759
|
+
if (params !== undefined) payload.params = params;
|
|
760
|
+
let framed;
|
|
761
|
+
try {
|
|
762
|
+
framed = frameRequest(payload);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
this._logErr('failed to serialise notification', method + ':', (err && err.message) || String(err));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
this._child.stdin.write(framed);
|
|
769
|
+
} catch (err) {
|
|
770
|
+
this._logErr('notification write failed', method + ':', (err && err.message) || String(err));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Buffer incoming stdout bytes and drain every complete message (dual framing). */
|
|
775
|
+
_onData(chunk) {
|
|
776
|
+
this._buf = Buffer.concat([this._buf, chunk]);
|
|
777
|
+
this._drain();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Drain as many complete messages as are buffered. Header (Content-Length) framing
|
|
782
|
+
* is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
|
|
783
|
+
* JSON. Mirrors server.js's dual-drain so the client reads either wire format.
|
|
784
|
+
*/
|
|
785
|
+
_drain() {
|
|
786
|
+
for (;;) {
|
|
787
|
+
if (this._tryHeaderFramed()) continue;
|
|
788
|
+
if (this._tryLineFramed()) continue;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Pull one Content-Length-framed message off the front of the buffer. */
|
|
794
|
+
_tryHeaderFramed() {
|
|
795
|
+
const headerEnd = this._buf.indexOf('\r\n\r\n');
|
|
796
|
+
if (headerEnd === -1) return false;
|
|
797
|
+
const header = this._buf.slice(0, headerEnd).toString('utf8');
|
|
798
|
+
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
|
799
|
+
if (!m) return false; // a \r\n\r\n that isn't a Content-Length header -> not this framing
|
|
800
|
+
const len = parseInt(m[1], 10);
|
|
801
|
+
const bodyStart = headerEnd + 4;
|
|
802
|
+
if (this._buf.length < bodyStart + len) return false; // body not all here yet
|
|
803
|
+
const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
|
|
804
|
+
this._buf = this._buf.slice(bodyStart + len);
|
|
805
|
+
this._parseAndDispatch(body);
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Pull one newline-delimited JSON message off the front of the buffer. */
|
|
810
|
+
_tryLineFramed() {
|
|
811
|
+
const nl = this._buf.indexOf(0x0a); // '\n'
|
|
812
|
+
if (nl === -1) return false;
|
|
813
|
+
const line = this._buf.slice(0, nl).toString('utf8').trim();
|
|
814
|
+
this._buf = this._buf.slice(nl + 1);
|
|
815
|
+
if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
|
|
816
|
+
// A stray header line ("Content-Length: ...") on the line path means the body hasn't
|
|
817
|
+
// arrived yet - but header framing is always tried first, so anything non-JSON here is
|
|
818
|
+
// a framing artifact; ignore it rather than erroring.
|
|
819
|
+
if (line[0] !== '{' && line[0] !== '[') return true;
|
|
820
|
+
this._parseAndDispatch(line);
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Parse one JSON body and route it to its waiter by id. Bad JSON / unmatched ids are ignored. */
|
|
825
|
+
_parseAndDispatch(text) {
|
|
826
|
+
let msg;
|
|
827
|
+
try {
|
|
828
|
+
msg = JSON.parse(text);
|
|
829
|
+
} catch (_e) {
|
|
830
|
+
this._logErr('dropping unparseable message from upstream');
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
|
|
834
|
+
|
|
835
|
+
const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
|
|
836
|
+
if (!hasId) {
|
|
837
|
+
// A progress beat for an in-flight request proves the upstream is alive and WORKING that
|
|
838
|
+
// request - re-arm its window so a slow-but-progressing call never dies to the fixed
|
|
839
|
+
// timeout. The notification still relays below, untouched.
|
|
840
|
+
if (msg.method === 'notifications/progress' && msg.params &&
|
|
841
|
+
msg.params.progressToken !== undefined) {
|
|
842
|
+
this._bumpProgressWaiter(msg.params.progressToken);
|
|
843
|
+
}
|
|
844
|
+
// Server-initiated NOTIFICATION (method, no id) - surface it so the aggregator can bridge
|
|
845
|
+
// change-notifications to a modern subscriptions/listen stream. NEVER throws.
|
|
846
|
+
if (typeof msg.method === 'string' && typeof this.onNotification === 'function') {
|
|
847
|
+
try { this.onNotification(msg); } catch (_e) { this._logErr('onNotification threw (ignored)'); }
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (typeof msg.method === 'string') {
|
|
853
|
+
// SERVER->CLIENT REQUEST (id + method): the upstream is asking ITS client something -
|
|
854
|
+
// elicitation/create, sampling/createMessage, roots/list. Previously dropped, which left
|
|
855
|
+
// a conformant upstream holding an open request forever (Bridge B). Surface to the owner;
|
|
856
|
+
// unhandled -> answer -32601 (the honest "this client cannot do that").
|
|
857
|
+
if (typeof this.onServerRequest === 'function') {
|
|
858
|
+
try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
|
|
859
|
+
}
|
|
860
|
+
this.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const w = this._waiters.get(msg.id);
|
|
865
|
+
if (!w) return; // response to an id we no longer await (timed out / closed) - drop it
|
|
866
|
+
this._waiters.delete(msg.id);
|
|
867
|
+
try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
|
|
868
|
+
|
|
869
|
+
if (msg.error) {
|
|
870
|
+
const e = msg.error;
|
|
871
|
+
const message = e && e.message ? e.message : 'JSON-RPC error';
|
|
872
|
+
const code = e && typeof e.code === 'number' ? ` (code ${e.code})` : '';
|
|
873
|
+
const err = new Error(`McpClient: upstream error for "${w.method}": ${message}${code}`);
|
|
874
|
+
// Preserve the VERBATIM JSON-RPC error object - a wrap must relay the upstream's own error
|
|
875
|
+
// (code/message/data) untouched; the stringified message above is for logs and lean paths.
|
|
876
|
+
err.rpcError = { code: e && typeof e.code === 'number' ? e.code : -32603, message };
|
|
877
|
+
if (e && e.data !== undefined) err.rpcError.data = e.data;
|
|
878
|
+
w.reject(err);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
w.resolve(msg.result);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Frame a JSON-RPC message for the MCP stdio transport: one compact newline-delimited
|
|
887
|
+
* JSON message. NOT Content-Length framing - see the field note inside.
|
|
888
|
+
* @param {object} payload
|
|
889
|
+
* @returns {string}
|
|
890
|
+
*/
|
|
891
|
+
function frameRequest(payload) {
|
|
892
|
+
// MCP stdio transport framing: one compact JSON-RPC message per line, newline-delimited - NOT
|
|
893
|
+
// LSP-style Content-Length. JSON.stringify emits no literal newlines, so a single trailing "\n"
|
|
894
|
+
// cleanly delimits the message. This is what real SDK-based MCP servers read on stdin; writing
|
|
895
|
+
// Content-Length here is what silently broke every real upstream (the read side still tolerates
|
|
896
|
+
// both framings for robustness against the occasional Content-Length server).
|
|
897
|
+
return JSON.stringify(payload) + '\n';
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
module.exports = {
|
|
901
|
+
McpClient,
|
|
902
|
+
// exported for unit tests / reuse
|
|
903
|
+
frameRequest,
|
|
904
|
+
winLaunch,
|
|
905
|
+
PROTOCOL_VERSION,
|
|
906
|
+
CLIENT_INFO,
|
|
907
|
+
};
|