toolfunnel 0.5.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 +231 -167
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -33
- 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 -1012
- 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 -1405
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -513
- 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/src/mcp/server.js
CHANGED
|
@@ -1,1405 +1,2688 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* server.js
|
|
6
|
-
*
|
|
7
|
-
* Runnable as: node src/mcp/server.js
|
|
8
|
-
*
|
|
9
|
-
* This is the THIN transport. It speaks JSON-RPC 2.0 over stdin/stdout and delegates ALL logic
|
|
10
|
-
* to the pure protocol layer (src/mcp/protocol.js). It is NOT an SDK wrapper
|
|
11
|
-
* @modelcontextprotocol/sdk and no new npm dependency; everything here is Node built-ins.
|
|
12
|
-
*
|
|
13
|
-
* JSON-RPC methods handled (MCP, the subset our test client speaks):
|
|
14
|
-
* - "initialize" -> { protocolVersion, serverInfo, capabilities: { tools: {} } }
|
|
15
|
-
* - "tools/list" -> { tools: [ ...protocol.toolDefinitions() ] } (the 4 meta-tools;
|
|
16
|
-
* a clear seam is left for curated-direct tools
|
|
17
|
-
* - "tools/call" { name, arguments } -> protocol.dispatch(name, arguments), wrapped in the
|
|
18
|
-
* MCP tools/call result shape: { content:[{ type:"text", text: JSON }],
|
|
19
|
-
* isError? }. On a protocol error result we set isError:true.
|
|
20
|
-
* - notifications (no `id`) are acknowledged silently and never answered.
|
|
21
|
-
* - any bad / unknown message NEVER crashes the process
|
|
22
|
-
*
|
|
23
|
-
* Framing: we READ both LSP-style `Content-Length:` framing AND newline-delimited JSON (the test
|
|
24
|
-
* client writes Content-Length with a trailing newline; a simple client may send line-delimited),
|
|
25
|
-
* and we WRITE newline-delimited JSON (the test reader accepts either framing on the wire). One
|
|
26
|
-
* framing on the way out keeps the transport trivial and matches "newline-delimited JSON is fine
|
|
27
|
-
* for our test client".
|
|
28
|
-
*
|
|
29
|
-
* Wiring (see the architecture notes §8
|
|
30
|
-
* src/tools/tools.register.json
|
|
31
|
-
* manifest, the real gatedRun, and the real howto. A small ADAPTER bridges the register's
|
|
32
|
-
* resolveExecution({type,run}) shape to the { execute, toolName, args } shape protocol.runTool
|
|
33
|
-
* injects into gatedRun
|
|
34
|
-
* actually gate + execute end to end.
|
|
35
|
-
*
|
|
36
|
-
* CommonJS only. Node built-ins only. No transport SDK.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
const fs = require('node:fs');
|
|
40
|
-
const path = require('node:path');
|
|
41
|
-
const {
|
|
42
|
-
|
|
43
|
-
const { makeProtocol } = require('./protocol');
|
|
44
|
-
const { gatedRun } = require('./gated-run');
|
|
45
|
-
const { Aggregator } = require('./aggregator');
|
|
46
|
-
const { loadExposeStore } = require('./expose-store');
|
|
47
|
-
const { loadRegistry } = require('../tools/registry');
|
|
48
|
-
const { loadToolState, isToolEnabled, isToolHot } = require('../tools/tool-state');
|
|
49
|
-
const { loadManifest } = require('../core/hook-loader');
|
|
50
|
-
const { HookEngine } = require('../core/hook-engine');
|
|
51
|
-
const { howto } = require('../extend/howto');
|
|
52
|
-
const logger = require('../core/logger');
|
|
53
|
-
const metrics = require('../core/metrics');
|
|
54
|
-
|
|
55
|
-
// ── Path anchors (everything stays under the host root) ───────────────────────────────────────
|
|
56
|
-
// The CODE lives in the package; the mutable CONFIG lives in the resolved config home
|
|
57
|
-
// directory in a git clone, distinct when TOOLFUNNEL_HOME / --config-dir points elsewhere (npm
|
|
58
|
-
// installs, npm-wrapped MCPs
|
|
59
|
-
// below derives from the HOME.
|
|
60
|
-
const { PKG_ROOT, resolveConfigHome, ensureConfigHome } = require('../core/config-home');
|
|
61
|
-
const ROOT = resolveConfigHome(); //
|
|
62
|
-
// The process-tree contract for child tools (defaultRunScript spawns with process.env): a
|
|
63
|
-
// management script SEEDED into an external home finds the engine code via TOOLFUNNEL_PKG, and
|
|
64
|
-
// the resolved ABSOLUTE home is written back so no child re-resolves a relative value against a
|
|
65
|
-
// different cwd.
|
|
66
|
-
process.env.TOOLFUNNEL_PKG = PKG_ROOT;
|
|
67
|
-
if (ROOT !== PKG_ROOT) process.env.TOOLFUNNEL_HOME = ROOT;
|
|
68
|
-
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
69
|
-
// The ACTIVE/DISABLED overlay (tool-state.js). toolfunnel_list_tools filters DISABLED tools out so
|
|
70
|
-
// they are not surfaced to the client. Read FRESH per list() call so UI toggles take effect with no
|
|
71
|
-
// restart. HIDDEN is a manager-list-only axis
|
|
72
|
-
// client's view).
|
|
73
|
-
const TOOL_STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
74
|
-
const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
|
|
75
|
-
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
76
|
-
// Phase 2: the persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
|
|
77
|
-
// aggregator connects to nothing and the curated-direct surface is empty
|
|
78
|
-
// behaviourally identical to Phase 1 until an upstream + expose entry is added.
|
|
79
|
-
const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
|
|
80
|
-
|
|
81
|
-
const PROTOCOL_VERSION = '2024-11-05';
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
},
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
//
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
function
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
if (!
|
|
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
|
-
|
|
896
|
-
|
|
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
|
-
if (
|
|
968
|
-
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
});
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
return
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
//
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* server.js - the HAND-ROLLED minimal MCP server over stdio (see the architecture notes §7).
|
|
6
|
+
*
|
|
7
|
+
* Runnable as: node src/mcp/server.js
|
|
8
|
+
*
|
|
9
|
+
* This is the THIN transport. It speaks JSON-RPC 2.0 over stdin/stdout and delegates ALL logic
|
|
10
|
+
* to the pure protocol layer (src/mcp/protocol.js). It is NOT an SDK wrapper - there is no
|
|
11
|
+
* @modelcontextprotocol/sdk and no new npm dependency; everything here is Node built-ins.
|
|
12
|
+
*
|
|
13
|
+
* JSON-RPC methods handled (MCP, the subset our test client speaks):
|
|
14
|
+
* - "initialize" -> { protocolVersion, serverInfo, capabilities: { tools: {} } }
|
|
15
|
+
* - "tools/list" -> { tools: [ ...protocol.toolDefinitions() ] } (the 4 meta-tools;
|
|
16
|
+
* a clear seam is left for curated-direct tools - Phase 2)
|
|
17
|
+
* - "tools/call" { name, arguments } -> protocol.dispatch(name, arguments), wrapped in the
|
|
18
|
+
* MCP tools/call result shape: { content:[{ type:"text", text: JSON }],
|
|
19
|
+
* isError? }. On a protocol error result we set isError:true.
|
|
20
|
+
* - notifications (no `id`) are acknowledged silently and never answered.
|
|
21
|
+
* - any bad / unknown message NEVER crashes the process - it gets a JSON-RPC error object.
|
|
22
|
+
*
|
|
23
|
+
* Framing: we READ both LSP-style `Content-Length:` framing AND newline-delimited JSON (the test
|
|
24
|
+
* client writes Content-Length with a trailing newline; a simple client may send line-delimited),
|
|
25
|
+
* and we WRITE newline-delimited JSON (the test reader accepts either framing on the wire). One
|
|
26
|
+
* framing on the way out keeps the transport trivial and matches "newline-delimited JSON is fine
|
|
27
|
+
* for our test client".
|
|
28
|
+
*
|
|
29
|
+
* Wiring (see the architecture notes §8 - Phase 1): a real Registry (loaded from
|
|
30
|
+
* src/tools/tools.register.json - the canonical tool register), a real HookEngine over the hooks
|
|
31
|
+
* manifest, the real gatedRun, and the real howto. A small ADAPTER bridges the register's
|
|
32
|
+
* resolveExecution({type,run}) shape to the { execute, toolName, args } shape protocol.runTool
|
|
33
|
+
* injects into gatedRun - keeping the pure modules untouched while making `toolfunnel_run_tool`
|
|
34
|
+
* actually gate + execute end to end.
|
|
35
|
+
*
|
|
36
|
+
* CommonJS only. Node built-ins only. No transport SDK.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
const { spawn } = require('node:child_process');
|
|
42
|
+
|
|
43
|
+
const { makeProtocol } = require('./protocol');
|
|
44
|
+
const { gatedRun } = require('./gated-run');
|
|
45
|
+
const { Aggregator } = require('./aggregator');
|
|
46
|
+
const { loadExposeStore } = require('./expose-store');
|
|
47
|
+
const { loadRegistry } = require('../tools/registry');
|
|
48
|
+
const { loadToolState, loadToolStateResult, isToolEnabled, isToolHot, getPassthrough } = require('../tools/tool-state');
|
|
49
|
+
const { loadManifest } = require('../core/hook-loader');
|
|
50
|
+
const { HookEngine } = require('../core/hook-engine');
|
|
51
|
+
const { howto } = require('../extend/howto');
|
|
52
|
+
const logger = require('../core/logger');
|
|
53
|
+
const metrics = require('../core/metrics');
|
|
54
|
+
|
|
55
|
+
// ── Path anchors (everything stays under the host root) ───────────────────────────────────────
|
|
56
|
+
// The CODE lives in the package; the mutable CONFIG lives in the resolved config home - the same
|
|
57
|
+
// directory in a git clone, distinct when TOOLFUNNEL_HOME / --config-dir points elsewhere (npm
|
|
58
|
+
// installs, npm-wrapped MCPs - an `npm update` must never eat the user's tools). Every anchor
|
|
59
|
+
// below derives from the HOME.
|
|
60
|
+
const { PKG_ROOT, resolveConfigHome, ensureConfigHome } = require('../core/config-home');
|
|
61
|
+
const ROOT = resolveConfigHome(); // <...>/<config home>
|
|
62
|
+
// The process-tree contract for child tools (defaultRunScript spawns with process.env): a
|
|
63
|
+
// management script SEEDED into an external home finds the engine code via TOOLFUNNEL_PKG, and
|
|
64
|
+
// the resolved ABSOLUTE home is written back so no child re-resolves a relative value against a
|
|
65
|
+
// different cwd.
|
|
66
|
+
process.env.TOOLFUNNEL_PKG = PKG_ROOT;
|
|
67
|
+
if (ROOT !== PKG_ROOT) process.env.TOOLFUNNEL_HOME = ROOT;
|
|
68
|
+
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
69
|
+
// The ACTIVE/DISABLED overlay (tool-state.js). toolfunnel_list_tools filters DISABLED tools out so
|
|
70
|
+
// they are not surfaced to the client. Read FRESH per list() call so UI toggles take effect with no
|
|
71
|
+
// restart. HIDDEN is a manager-list-only axis - it is NEVER consulted here (it must not affect the
|
|
72
|
+
// client's view).
|
|
73
|
+
const TOOL_STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
74
|
+
const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
|
|
75
|
+
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
76
|
+
// Phase 2: the persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
|
|
77
|
+
// aggregator connects to nothing and the curated-direct surface is empty - the server is
|
|
78
|
+
// behaviourally identical to Phase 1 until an upstream + expose entry is added.
|
|
79
|
+
const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
|
|
80
|
+
|
|
81
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
82
|
+
// The 2026-07-28 ("modern") era, as a pure module - era detection, validation, result decoration,
|
|
83
|
+
// server/discover and subscriptions/listen shapes. This server is DUAL-ERA: a request carrying
|
|
84
|
+
// modern per-request _meta is served under 2026-07-28 semantics; initialize selects THIS legacy
|
|
85
|
+
// path, byte-for-byte the 0.5.0 behaviour. modern.js needs the legacy version string only for
|
|
86
|
+
// server/discover's supportedVersions - seeded once here so it is never duplicated.
|
|
87
|
+
const modern = require('./modern');
|
|
88
|
+
modern.setLegacyVersion(PROTOCOL_VERSION);
|
|
89
|
+
// Identity comes from the OPTIONAL toolfunnel.json at the root (absent -> name "toolfunnel",
|
|
90
|
+
// version from package.json - serverInfo can never drift from the released version). The config
|
|
91
|
+
// seam is what lets a wrapped MCP introduce itself as ITSELF in the initialize handshake and
|
|
92
|
+
// /health. Loaded once at module init: clients cache serverInfo from initialize, so identity is
|
|
93
|
+
// deliberately NOT hot-reloaded (a mid-session identity swap would lie to connected clients).
|
|
94
|
+
const { loadServerConfig } = require('../core/server-config');
|
|
95
|
+
const SERVER_CONFIG = loadServerConfig(ROOT);
|
|
96
|
+
const SERVER_INFO = { name: SERVER_CONFIG.serverName, version: SERVER_CONFIG.serverVersion };
|
|
97
|
+
|
|
98
|
+
// ── Diagnostics -> stderr only (stdout is the JSON-RPC channel; never pollute it). ─────────────
|
|
99
|
+
function logErr(...parts) {
|
|
100
|
+
try {
|
|
101
|
+
process.stderr.write('[toolfunnel] ' + parts.join(' ') + '\n');
|
|
102
|
+
} catch (_e) {
|
|
103
|
+
/* never let logging throw */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
// Registry adapter - bridge the real Registry to what protocol.runTool consumes.
|
|
109
|
+
//
|
|
110
|
+
// protocol.runTool expects: registry.resolveExecution(name, args) -> { execute, toolName, args }
|
|
111
|
+
// The real Registry returns: resolveExecution(id, args) -> { type, run }
|
|
112
|
+
// - it looks up by `id` only (throws on unknown id),
|
|
113
|
+
// - returns `run` (not `execute`),
|
|
114
|
+
// - for a SHELL invoke it returns a DEFERRED descriptor instead of running (the gated runner
|
|
115
|
+
// is meant to own shell execution - registry.js keeps un-gated shell out of itself by design).
|
|
116
|
+
//
|
|
117
|
+
// The adapter therefore:
|
|
118
|
+
// - resolves the meta-tool `name` to a register id (accepting id OR display name),
|
|
119
|
+
// - wraps `run` into an `execute` thunk that returns a clean tool output,
|
|
120
|
+
// - for the shell-deferred case, performs the actual shell spawn INSIDE the execute thunk - so
|
|
121
|
+
// it only ever runs after gatedRun's PreToolUse gate has allowed it.
|
|
122
|
+
// list()/instructions() pass straight through (with the same name->id resolution for instructions).
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
function makeRegistryAdapter(registry, opts) {
|
|
125
|
+
// opts.toolStatePath: when set, list() filters DISABLED tools out (the enabled-filter for
|
|
126
|
+
// toolfunnel_list_tools). When absent (the Tool Manager's runOnce adapter, tests) list() is unfiltered.
|
|
127
|
+
const toolStatePath = (opts && opts.toolStatePath) || null;
|
|
128
|
+
// opts.getAggregator: an OPTIONAL live getter () => Aggregator|null. NEVER a captured instance -
|
|
129
|
+
// reloadExpose swaps build.aggregator, so the adapter must read it fresh each call. When absent the
|
|
130
|
+
// adapter is local-only, byte-for-byte its pre-slice-2 behaviour (preserves the runOnce adapter + tests).
|
|
131
|
+
const getAggregator = (opts && typeof opts.getAggregator === 'function') ? opts.getAggregator : null;
|
|
132
|
+
|
|
133
|
+
/** The live aggregator, or null. Defensive: a throwing/absent getter yields null (local-only). */
|
|
134
|
+
function liveAggregator() {
|
|
135
|
+
if (!getAggregator) return null;
|
|
136
|
+
try { const a = getAggregator(); return a && typeof a === 'object' ? a : null; } catch (_e) { return null; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolve a meta-tool name (which may be a display name) to a register id. */
|
|
140
|
+
function resolveId(nameOrId) {
|
|
141
|
+
if (typeof nameOrId !== 'string' || nameOrId.length === 0) return null;
|
|
142
|
+
if (registry.has(nameOrId)) return nameOrId; // it's already an id
|
|
143
|
+
// Fall back to matching the display name.
|
|
144
|
+
const hit = registry.list().find((b) => b.name === nameOrId);
|
|
145
|
+
return hit ? hit.id : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run a shell command, normalising to { ok, code, stdout, stderr }. ASYNC spawn - the old
|
|
150
|
+
* spawnSync blocked the event loop for the tool's whole duration, stalling every concurrent
|
|
151
|
+
* HTTP client (and pings) behind one long shell tool (0.5.0 debt). Its
|
|
152
|
+
* only caller is the gated async execute thunk, so the promise is awaited naturally; the
|
|
153
|
+
* spawn-failure contract (throw/reject with the same message) is unchanged.
|
|
154
|
+
*/
|
|
155
|
+
function runShell(command, args) {
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
let child;
|
|
158
|
+
try {
|
|
159
|
+
child = spawn(command, {
|
|
160
|
+
shell: true,
|
|
161
|
+
cwd: ROOT,
|
|
162
|
+
env: Object.assign({}, process.env, { TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args == null ? null : args) }),
|
|
163
|
+
windowsHide: true,
|
|
164
|
+
});
|
|
165
|
+
} catch (err) {
|
|
166
|
+
reject(new Error(`shell spawn failed: ${err.message}`));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let stdout = '';
|
|
170
|
+
let stderr = '';
|
|
171
|
+
let settled = false;
|
|
172
|
+
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
|
|
173
|
+
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
|
|
174
|
+
child.on('error', (err) => {
|
|
175
|
+
if (settled) return;
|
|
176
|
+
settled = true;
|
|
177
|
+
reject(new Error(`shell spawn failed: ${err.message}`));
|
|
178
|
+
});
|
|
179
|
+
child.on('close', (code) => {
|
|
180
|
+
if (settled) return;
|
|
181
|
+
settled = true;
|
|
182
|
+
const c = typeof code === 'number' ? code : -1;
|
|
183
|
+
resolve({ ok: c === 0, code: c, stdout, stderr });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
// Briefs only (protocol forwards { filter, category }). When a tool-state overlay is configured,
|
|
190
|
+
// DISABLED (✗) tools are dropped so toolfunnel_list_tools never surfaces them to the client -
|
|
191
|
+
// read FRESH each call so a UI toggle takes effect immediately, no host restart. HIDDEN (👁) is
|
|
192
|
+
// UI-only and is deliberately NOT consulted here (it must never change what the client sees).
|
|
193
|
+
list(listOpts) {
|
|
194
|
+
const o = listOpts || {};
|
|
195
|
+
let local = registry.list(o);
|
|
196
|
+
const state = toolStatePath ? loadToolState(toolStatePath) : null;
|
|
197
|
+
if (state) local = local.filter((b) => isToolEnabled(state, b.id));
|
|
198
|
+
|
|
199
|
+
const agg = liveAggregator();
|
|
200
|
+
if (!agg || typeof agg.leanToolDefinitions !== 'function') return local;
|
|
201
|
+
// Append the LEAN upstream tools (the full discovered set of connected upstreams), then:
|
|
202
|
+
// - drop any whose name is a local tool (local-register WINS - matches resolveExecution),
|
|
203
|
+
// - apply the SAME {filter,category} registry.list applied (parity), and
|
|
204
|
+
// - apply the SAME tool-state enable filter local tools get, so the lean list is CURATABLE
|
|
205
|
+
// per-tool: disable an upstream tool by its surfaced id, exactly like a local tool.
|
|
206
|
+
let upstream = [];
|
|
207
|
+
try {
|
|
208
|
+
upstream = agg.leanToolDefinitions()
|
|
209
|
+
.map((d) => ({ id: d.name, name: d.name, summary: d.description || '', category: `mcp:${d.upstream}` }))
|
|
210
|
+
.filter((b) => resolveId(b.id) === null)
|
|
211
|
+
.filter((b) => briefMatches(b, o))
|
|
212
|
+
.filter((b) => !state || isToolEnabled(state, b.id));
|
|
213
|
+
} catch (_e) {
|
|
214
|
+
upstream = []; // a throwing aggregator must never sink toolfunnel_list_tools - local-only fallback
|
|
215
|
+
}
|
|
216
|
+
return local.concat(upstream);
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// Full instructions for one tool (by id or display name). Registry.instructions throws on an
|
|
220
|
+
// unknown id; protocol.toolInstructions catches that and returns a clean error result.
|
|
221
|
+
instructions(nameOrId) {
|
|
222
|
+
const id = resolveId(nameOrId);
|
|
223
|
+
if (id) return registry.instructions(id);
|
|
224
|
+
// Not a local tool - synthesise instructions for a lean upstream tool from its discovered def.
|
|
225
|
+
const agg = liveAggregator();
|
|
226
|
+
if (!agg || typeof agg.leanToolDefinitions !== 'function') return null;
|
|
227
|
+
try {
|
|
228
|
+
const def = agg.leanToolDefinitions().find((d) => d.name === nameOrId);
|
|
229
|
+
return def ? renderUpstreamInstructions(def) : null; // null -> protocol's "no tool named ..."
|
|
230
|
+
} catch (_e) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// Build the { execute, toolName, args, mode } resolution protocol.runTool hands to gatedRun.
|
|
236
|
+
resolveExecution(nameOrId, args) {
|
|
237
|
+
const id = resolveId(nameOrId);
|
|
238
|
+
if (id) {
|
|
239
|
+
// LOCAL tool - byte-for-byte the pre-slice-2 path. A local name that resolves but isn't
|
|
240
|
+
// runnable returns null and does NOT fall through to upstream (local-register WINS: a local
|
|
241
|
+
// name never silently resolves to a remote upstream).
|
|
242
|
+
const entry = registry.getEntry(id); // { id, name, summary, category, instructions, invoke, mode? }
|
|
243
|
+
const desc = registry.resolveExecution(id, args); // { type, mode, run? } | reference: { type, mode, instructions }
|
|
244
|
+
if (!desc) return null;
|
|
245
|
+
|
|
246
|
+
// Reference mode: nothing executes here. Hand back the instructions (no execute thunk)
|
|
247
|
+
// so protocol.runTool takes the no-spawn path (execute() is never called). Note this is
|
|
248
|
+
// NOT ungated: protocol.runTool still fires PreToolUse on the instruction handoff, so a
|
|
249
|
+
// deny withholds the instructions. What's absent is server-side EXECUTION, not the gate.
|
|
250
|
+
if (desc.mode === 'reference') {
|
|
251
|
+
return {
|
|
252
|
+
mode: 'reference',
|
|
253
|
+
toolName: entry.name || id,
|
|
254
|
+
instructions: typeof desc.instructions === 'string' ? desc.instructions : (entry.instructions || ''),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof desc.run !== 'function') return null;
|
|
259
|
+
|
|
260
|
+
const execute = async () => {
|
|
261
|
+
const out = await desc.run();
|
|
262
|
+
// Shell invokes come back deferred - the registry hands shell execution to the gated path.
|
|
263
|
+
if (out && out.deferred === true && out.type === 'shell') {
|
|
264
|
+
return runShell(out.command, out.args);
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return { execute, toolName: entry.name || id, args: args == null ? {} : args, mode: desc.mode || 'gateway' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// No local tool by this name - try the LEAN upstream forward. resolveLeanExecution returns a
|
|
273
|
+
// resolution whose execute thunk lazy-(re)connects + unwraps; the gate matches fwd.toolName (the
|
|
274
|
+
// surfaced name). mode is 'gateway' so it goes THROUGH gatedRun - never the reference handoff.
|
|
275
|
+
const agg = liveAggregator();
|
|
276
|
+
if (!agg || typeof agg.resolveLeanExecution !== 'function') return null;
|
|
277
|
+
let fwd = null;
|
|
278
|
+
try { fwd = agg.resolveLeanExecution(nameOrId, args); } catch (_e) { fwd = null; }
|
|
279
|
+
if (!fwd || typeof fwd.execute !== 'function') return null;
|
|
280
|
+
return { execute: fwd.execute, toolName: fwd.toolName, args: args == null ? {} : args, mode: 'gateway' };
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Build the protocol + aggregator (the meta-tool logic + the curated-direct seam). ──────────
|
|
286
|
+
// All wiring failures are caught so the server still starts and reports a clean JSON-RPC error
|
|
287
|
+
// instead of dying - a dead server hangs the client (and the test).
|
|
288
|
+
//
|
|
289
|
+
// Returns an OBJECT, not just the protocol, because the Phase-2 curated-direct call path needs
|
|
290
|
+
// more than the protocol:
|
|
291
|
+
// { protocol, aggregator, engine, ctx }
|
|
292
|
+
// - protocol : the 4 meta-tools (unchanged Phase-1 logic) - callers needing just the
|
|
293
|
+
// protocol read it off `.protocol` (backward compat).
|
|
294
|
+
// - aggregator : the upstream-MCP connection + curated-expose set (built from expose.json).
|
|
295
|
+
// Default expose.json is EMPTY so it connects to nothing.
|
|
296
|
+
// - engine, ctx: threaded into gatedRun for the curated-direct path (a curated tool runs
|
|
297
|
+
// THROUGH the same PreToolUse gate as toolfunnel_run_tool - the safety invariant).
|
|
298
|
+
function buildProtocol() {
|
|
299
|
+
// Make the config home real before anything reads from it: seeds an external home's register/
|
|
300
|
+
// scripts/expose/hooks from the shipped defaults on first use. A no-op when home === package
|
|
301
|
+
// root; idempotent and never-overwriting afterwards. A failure falls through to loadRegistry's
|
|
302
|
+
// own clear error.
|
|
303
|
+
try { ensureConfigHome(ROOT); } catch (err) { logErr('config-home init failed:', (err && err.message) || String(err)); }
|
|
304
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
305
|
+
const loader = loadManifest(MANIFEST_PATH);
|
|
306
|
+
|
|
307
|
+
// The hook context every gated run carries (the common hook fields).
|
|
308
|
+
const ctx = {
|
|
309
|
+
session_id: `toolfunnel-mcp-${process.pid}`,
|
|
310
|
+
transcript_path: '',
|
|
311
|
+
cwd: ROOT,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// The LIVE build holder, created FIRST so both the adapter and the protocol read the CURRENT
|
|
315
|
+
// aggregator/engine after a hot-reload swaps them in place (reloadExpose mutates build.aggregator,
|
|
316
|
+
// reloadHooks mutates build.engine). main() threads this SAME object into the stdio loop and the
|
|
317
|
+
// watchers, so nothing below captures a stale instance.
|
|
318
|
+
// - registry : the raw Registry, so handleToolsList can build a full MCP def (with the
|
|
319
|
+
// entry's inputSchema) for a LOCAL tool promoted hot to the top-level surface.
|
|
320
|
+
// - toolStatePath : the per-tool overlay path, read FRESH per tools/list & tools/call so a UI
|
|
321
|
+
// hot/enabled toggle takes effect with no restart (the visibility MATRIX).
|
|
322
|
+
const build = { protocol: null, aggregator: null, engine: null, ctx, registry, toolStatePath: TOOL_STATE_PATH };
|
|
323
|
+
|
|
324
|
+
// Engine PROXY: protocol/gatedRun only need engine.fire. Delegating to build.engine means a
|
|
325
|
+
// reloadHooks swap gates the lean-forward (and local run_tool) path with the NEW engine, exactly
|
|
326
|
+
// like curated-direct (which reads build.engine live via deps). Without this the protocol would
|
|
327
|
+
// capture the engine at construction and a runtime hook change would gate one route, not the other.
|
|
328
|
+
const engineProxy = { fire: (event, hookCtx, extra) => build.engine.fire(event, hookCtx, extra) };
|
|
329
|
+
|
|
330
|
+
// The adapter reads the aggregator through a LIVE getter (never a captured instance).
|
|
331
|
+
const registryAdapter = makeRegistryAdapter(registry, {
|
|
332
|
+
toolStatePath: TOOL_STATE_PATH,
|
|
333
|
+
getAggregator: () => build.aggregator,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
build.protocol = makeProtocol({
|
|
337
|
+
registry: registryAdapter,
|
|
338
|
+
gatedRun,
|
|
339
|
+
engine: engineProxy,
|
|
340
|
+
ctx,
|
|
341
|
+
howto,
|
|
342
|
+
});
|
|
343
|
+
build.engine = new HookEngine(loader, { cwd: ROOT });
|
|
344
|
+
|
|
345
|
+
// Phase 2: the aggregator over expose.json (EMPTY by default -> nothing connects).
|
|
346
|
+
// loadExposeStore on a missing/empty file returns an empty store; Aggregator over an empty store
|
|
347
|
+
// advertises no curated-direct tools and connectAll() is an instant no-op.
|
|
348
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
349
|
+
build.aggregator = new Aggregator({
|
|
350
|
+
store, v3Root: ROOT,
|
|
351
|
+
// The WRAPPED upstream is exempt from the path-isolation guard (transparent-wrapper mode,
|
|
352
|
+
// by design). Fresh state read per connect, so a live wrap change counts.
|
|
353
|
+
wrapTargetProvider: () => {
|
|
354
|
+
try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state); } catch (_e) { return null; }
|
|
355
|
+
},
|
|
356
|
+
// Outbound identity per upstream: captured wrap mirror > configured clientName > built-in.
|
|
357
|
+
clientInfoProvider: clientInfoFor,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// /health downgrade flag: while a wrap is ACTIVE the /mcp surface impersonates the wrapped
|
|
361
|
+
// server, so the transport serves liveness-only health. Fresh read per call -
|
|
362
|
+
// a live wrap change counts, same as wrapTargetProvider above.
|
|
363
|
+
build.wrapActive = () => {
|
|
364
|
+
try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state) != null; } catch (_e) { return false; }
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// MODERN-ONLY POLICY flag for the transport (the legacy GET-SSE channel refuses on it) + the
|
|
368
|
+
// LOUD startup warning the opt-in demands: today every real client is legacy-era, so a flipped
|
|
369
|
+
// switch must never be quiet. The wrap interaction gets its own line - a wrapped legacy server
|
|
370
|
+
// stays reachable ONLY by modern clients while this is set.
|
|
371
|
+
build.serveLegacy = SERVER_CONFIG.serveLegacy !== false;
|
|
372
|
+
if (!build.serveLegacy) {
|
|
373
|
+
let wrapNote = '';
|
|
374
|
+
try {
|
|
375
|
+
const pt = getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state);
|
|
376
|
+
if (pt) wrapNote = ` A wrap ("${pt}") is active: LEGACY clients cannot reach the wrapped server through this gateway while modern-only is set.`;
|
|
377
|
+
} catch (_e) { /* state unreadable - the base warning still fires */ }
|
|
378
|
+
process.stderr.write('[toolfunnel] MODERN-ONLY: serveLegacy:false in toolfunnel.json - legacy-era clients are REFUSED ' +
|
|
379
|
+
'(initialize disabled, legacy requests get a clear error, the legacy SSE channel is off). ' +
|
|
380
|
+
'Every current mainstream client speaks legacy: only set this once your clients are 2026-07-28 era.' + wrapNote + '\n');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return build;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
387
|
+
// JSON-RPC 2.0 helpers.
|
|
388
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
389
|
+
const JSONRPC = '2.0';
|
|
390
|
+
// Standard JSON-RPC error codes.
|
|
391
|
+
const ERR = Object.freeze({
|
|
392
|
+
PARSE: -32700,
|
|
393
|
+
INVALID_REQUEST: -32600,
|
|
394
|
+
METHOD_NOT_FOUND: -32601,
|
|
395
|
+
INVALID_PARAMS: -32602,
|
|
396
|
+
INTERNAL: -32603,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
function makeResult(id, result) {
|
|
400
|
+
return { jsonrpc: JSONRPC, id, result };
|
|
401
|
+
}
|
|
402
|
+
function makeError(id, code, message, data) {
|
|
403
|
+
const error = { code, message };
|
|
404
|
+
if (data !== undefined) error.data = data;
|
|
405
|
+
// A null id is valid for errors where the request id couldn't be determined.
|
|
406
|
+
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── MCP method handlers. ──────────────────────────────────────────────────────────────────────
|
|
410
|
+
// `wrapId` (optional): when a passthrough WRAP is active, the wrapped upstream's OWN identity
|
|
411
|
+
// ({ protocolVersion, serverInfo, capabilities, instructions } from aggregator.wrappedIdentity).
|
|
412
|
+
// A wrap must be indistinguishable from connecting to the wrapped MCP directly, so we present ITS
|
|
413
|
+
// identity verbatim - name, version, capabilities, instructions - instead of ToolFunnel's own.
|
|
414
|
+
function handleInitialize(params, wrapId) {
|
|
415
|
+
// protocolVersion + serverInfo + capabilities { tools:{} } (see the architecture notes §7,
|
|
416
|
+
// mcp-server.test.js asserts a `tools` capability + a protocolVersion).
|
|
417
|
+
// Activity log (self-gating; no-op unless logging is enabled): the CLIENT-connected half of
|
|
418
|
+
// connect logging - the aggregator logs the upstream half. Wrapped though logger.log never throws.
|
|
419
|
+
try {
|
|
420
|
+
const info = params && params.clientInfo;
|
|
421
|
+
logger.log({
|
|
422
|
+
type: 'client',
|
|
423
|
+
event: 'connect',
|
|
424
|
+
client: info && typeof info.name === 'string' ? info.name : 'unknown',
|
|
425
|
+
protocolVersion: params && typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined,
|
|
426
|
+
});
|
|
427
|
+
} catch (_e) { /* never let logging affect the handshake */ }
|
|
428
|
+
|
|
429
|
+
// WRAP: present the wrapped upstream's OWN handshake, verbatim - a wrapped MCP must look like
|
|
430
|
+
// itself, not ToolFunnel. EXCEPT protocolVersion: initialize is the LEGACY path, and a legacy
|
|
431
|
+
// client handed a modern version (a wrapped MODERN upstream's identity carries 2026-07-28)
|
|
432
|
+
// would correctly disconnect. Clamp to a version this handshake's era can speak; identity
|
|
433
|
+
// fields (serverInfo/capabilities/instructions) stay verbatim.
|
|
434
|
+
if (wrapId) {
|
|
435
|
+
const upstreamVersion = typeof wrapId.protocolVersion === 'string' ? wrapId.protocolVersion : null;
|
|
436
|
+
const out = {
|
|
437
|
+
protocolVersion: (upstreamVersion && upstreamVersion !== modern.MODERN_PROTOCOL_VERSION)
|
|
438
|
+
? upstreamVersion : PROTOCOL_VERSION,
|
|
439
|
+
serverInfo: wrapId.serverInfo || SERVER_INFO,
|
|
440
|
+
capabilities: wrapId.capabilities || { tools: { listChanged: true } },
|
|
441
|
+
};
|
|
442
|
+
if (typeof wrapId.instructions === 'string' && wrapId.instructions) out.instructions = wrapId.instructions;
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
448
|
+
serverInfo: SERVER_INFO,
|
|
449
|
+
capabilities: {
|
|
450
|
+
// We are a tool host. `tools: {}` declares the capability. Phase 2 wires curated-direct
|
|
451
|
+
// hot-updates, so we now advertise listChanged:true - the server may emit
|
|
452
|
+
// notifications/tools/list_changed when the curated-direct expose set changes (see
|
|
453
|
+
// emitToolsListChanged). The mcp-server.test.js assertion only requires a `tools`
|
|
454
|
+
// capability key + a protocolVersion, both still present.
|
|
455
|
+
tools: { listChanged: true },
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function handleToolsList(protocol, aggregator, opts) {
|
|
461
|
+
// The TOP-LEVEL surface (tools/list - injected EVERY turn) is assembled from the per-tool `hot`
|
|
462
|
+
// axis of the visibility MATRIX, deduped by name with a fixed precedence (first writer wins):
|
|
463
|
+
//
|
|
464
|
+
// 1. META-TOOLS hot defaults TRUE - the 4 management tools; a meta-tool can be HIDDEN
|
|
465
|
+
// from the top level (state[name].hot === false).
|
|
466
|
+
// 2. LOCAL hot tools hot defaults FALSE - a register tool promoted hot (and enabled); its def
|
|
467
|
+
// carries the entry's inputSchema (or {type:object}).
|
|
468
|
+
// 3. CURATED-DIRECT the existing expose[] path (an enabled expose entry == top-level).
|
|
469
|
+
// 4. UPSTREAM hot tools hot defaults FALSE - a connected upstream tool promoted hot by its
|
|
470
|
+
// surfaced name (lean run semantics; not via expose[]).
|
|
471
|
+
//
|
|
472
|
+
// Precedence is local-register-wins over an upstream of the same name (matches the lean rule), and
|
|
473
|
+
// a META-tool can NEVER be shadowed by a local/upstream tool of the same name (safety). With the
|
|
474
|
+
// default EMPTY state + EMPTY expose.json this is exactly the 4 meta-tools - byte-identical to the
|
|
475
|
+
// pre-matrix surface. `opts` is OPTIONAL: a Phase-1 / bare-protocol caller (no registry/state) gets
|
|
476
|
+
// meta-tools (all hot) + curated-direct, the prior behaviour. NEVER throws.
|
|
477
|
+
const o = opts || {};
|
|
478
|
+
const registry = o.registry || null;
|
|
479
|
+
const statePath = typeof o.toolStatePath === 'string' && o.toolStatePath ? o.toolStatePath : null;
|
|
480
|
+
let state = {};
|
|
481
|
+
if (statePath) {
|
|
482
|
+
// A CORRUPT overlay (present but unparseable) is not silent: it warns. A wrap lives in this
|
|
483
|
+
// file, so a corrupt overlay would otherwise SILENTLY drop the wrap and leak the funnel surface
|
|
484
|
+
// (meta-tools included) to a wrapped client. We keep serving
|
|
485
|
+
// defaults (fail-open is right for the common non-wrapped case) but the operator now SEES it.
|
|
486
|
+
const res = loadToolStateResult(statePath);
|
|
487
|
+
state = res.state;
|
|
488
|
+
if (res.parseError) warnCorruptToolState(statePath);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── PASSTHROUGH (0.6.0): the transparent-wrapper mode ────────────────────────────────────────
|
|
492
|
+
// When tools.state.json carries `"passthrough": "<upstreamId>"`, the advertised surface is
|
|
493
|
+
// EXACTLY that upstream's tools (real schemas, implicitly hot) - no meta-tools, no locals, no
|
|
494
|
+
// curated-direct. handleToolsCall routes by the same rule, so the callable surface stays equal
|
|
495
|
+
// to the advertised one, and every call still fires the PreToolUse gate. The wrap survives an
|
|
496
|
+
// upstream that is down (empty list + a throttled warning) rather than falling back to the
|
|
497
|
+
// funnel surface - a wrapped gateway must never leak its own tools because a child was slow.
|
|
498
|
+
const passthroughId = getPassthrough(state);
|
|
499
|
+
if (passthroughId) {
|
|
500
|
+
return { tools: passthroughDefinitions(aggregator, state, passthroughId) };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const byName = new Map(); // name -> MCP tool def; first writer wins (the precedence above)
|
|
504
|
+
|
|
505
|
+
// The 4 meta-tool names are RESERVED: nothing else may be advertised under them, even when a meta
|
|
506
|
+
// is hidden (else a curated-direct/upstream tool aliased onto a meta name would be advertised yet
|
|
507
|
+
// uncallable - handleToolsCall routes a meta name to the meta gate first).
|
|
508
|
+
const metaNames = new Set(protocol.toolDefinitions().map((d) => d && d.name));
|
|
509
|
+
|
|
510
|
+
// 1. META-TOOLS - hot by default; hideable via state[name].hot === false (footgun: hiding all is
|
|
511
|
+
// the "ordinary tools as an MCP" pattern - warned below + at the write site).
|
|
512
|
+
for (const def of protocol.toolDefinitions()) {
|
|
513
|
+
if (def && isToolHot(state, def.name, true) && !byName.has(def.name)) byName.set(def.name, def);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 2. LOCAL hot tools - opt-in (hot:true) AND enabled. A real MCP def from the register entry.
|
|
517
|
+
if (registry) {
|
|
518
|
+
for (const def of localHotDefinitions(registry, state)) {
|
|
519
|
+
if (def && !byName.has(def.name)) byName.set(def.name, def);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 3. CURATED-DIRECT (expose[]) - unchanged. exposedToolDefinitions never throws, but guard anyway.
|
|
524
|
+
// Skip any def colliding with a RESERVED meta name (never advertise a phantom uncallable tool).
|
|
525
|
+
if (aggregator && typeof aggregator.exposedToolDefinitions === 'function') {
|
|
526
|
+
try {
|
|
527
|
+
const defs = aggregator.exposedToolDefinitions();
|
|
528
|
+
if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name)) byName.set(def.name, def);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
logErr('exposedToolDefinitions failed:', (err && err.message) || String(err));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 4. UPSTREAM hot tools - a connected upstream tool promoted hot by its surfaced name (and enabled),
|
|
535
|
+
// not already surfaced. Skip a name that is RESERVED (meta) or TAKEN by a local tool's id/display
|
|
536
|
+
// name - local-register-wins, AND it keeps the advertised name resolving to what actually runs
|
|
537
|
+
// (a local display-name collision would otherwise advertise the upstream but run the local tool).
|
|
538
|
+
if (aggregator && typeof aggregator.leanToolDefinitions === 'function') {
|
|
539
|
+
try {
|
|
540
|
+
for (const d of aggregator.leanToolDefinitions()) {
|
|
541
|
+
if (!d || byName.has(d.name) || metaNames.has(d.name) || localNameTaken(registry, d.name)) continue;
|
|
542
|
+
if (isToolHot(state, d.name, false) && isToolEnabled(state, d.name)) {
|
|
543
|
+
byName.set(d.name, { name: d.name, description: d.description || '', inputSchema: d.inputSchema || { type: 'object' } });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} catch (err) {
|
|
547
|
+
logErr('leanToolDefinitions (hot) failed:', (err && err.message) || String(err));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Footgun warning (server-side, throttled): if NO meta-tool is on the surface, in-band tool
|
|
552
|
+
// discovery/run is gone - recovery is the --ui console or hand-editing tools/tools.state.json.
|
|
553
|
+
const metaPresent = [...metaNames].some((n) => byName.has(n));
|
|
554
|
+
if (!metaPresent && !_noMetaWarned) {
|
|
555
|
+
logErr('WARNING: all meta-tools are hidden (hot:false) - no in-band tool discovery/run remains. Recover via `--ui` or by editing tools/tools.state.json.');
|
|
556
|
+
_noMetaWarned = true;
|
|
557
|
+
} else if (metaPresent) {
|
|
558
|
+
_noMetaWarned = false;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return { tools: [...byName.values()] };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Module flag so the all-metas-hidden warning logs ONCE per episode (not on every tools/list). */
|
|
565
|
+
let _noMetaWarned = false;
|
|
566
|
+
|
|
567
|
+
/** Throttled (once per episode) warning that tools.state.json is present but corrupt - so a dropped
|
|
568
|
+
* wrap / lost off-switch is never SILENT. Reset would need a process restart; that's fine. */
|
|
569
|
+
let _corruptStateWarned = false;
|
|
570
|
+
function warnCorruptToolState(statePath) {
|
|
571
|
+
if (_corruptStateWarned) return;
|
|
572
|
+
_corruptStateWarned = true;
|
|
573
|
+
logErr(`WARNING: ${statePath} is present but not valid JSON - ignoring it and serving DEFAULTS. ` +
|
|
574
|
+
'If a passthrough wrap or a per-tool disable was set, it is NOT in effect right now. Fix the file.');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The advertised surface under PASSTHROUGH: exactly the wrapped upstream's tools, presented
|
|
579
|
+
* under their ORIGINAL names (`d.tool`, not the collision-proof surfaced `<upstream>_<tool>`) -
|
|
580
|
+
* with ONE upstream, collisions are impossible, and a transparent wrapper must look like the
|
|
581
|
+
* MCP it wraps, tool names included. Each def carries its real discovered schema. An explicit
|
|
582
|
+
* enabled:false (keyed by the SURFACED name - the stable key the UI/tf_* tools write) still
|
|
583
|
+
* filters a tool out; the hot axis is ignored (the wrap makes everything implicitly hot). A down
|
|
584
|
+
* or unknown upstream yields an EMPTY list plus a throttled stderr warning - never a fallback to
|
|
585
|
+
* the funnel surface. NEVER throws.
|
|
586
|
+
*/
|
|
587
|
+
function passthroughDefinitions(aggregator, state, upstreamId) {
|
|
588
|
+
const out = [];
|
|
589
|
+
if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') {
|
|
590
|
+
warnPassthroughEmpty(upstreamId, 'no aggregator');
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
// The upstream's RAW discovered defs - a wrapped surface must advertise the tool exactly as
|
|
595
|
+
// the upstream does (annotations, title, outputSchema, future fields). The old projection to
|
|
596
|
+
// {name, description, inputSchema} made all 13 real server-everything tools differ from a
|
|
597
|
+
// direct connection (wrap-lab, 2026-07-17). leanToolDefinitions still drives the walk: it
|
|
598
|
+
// carries the SURFACED name (the stable enabled:false key) and the collision guard.
|
|
599
|
+
const rawByName = new Map();
|
|
600
|
+
if (typeof aggregator.toolsByUpstream === 'function') {
|
|
601
|
+
for (const t of (aggregator.toolsByUpstream()[upstreamId] || [])) {
|
|
602
|
+
if (t && typeof t.name === 'string') rawByName.set(t.name, t);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
for (const d of aggregator.leanToolDefinitions()) {
|
|
606
|
+
if (!d || d.upstream !== upstreamId) continue;
|
|
607
|
+
if (!isToolEnabled(state, d.name)) continue;
|
|
608
|
+
const original = typeof d.tool === 'string' && d.tool ? d.tool : d.name;
|
|
609
|
+
const raw = rawByName.get(original);
|
|
610
|
+
out.push(raw || {
|
|
611
|
+
name: original,
|
|
612
|
+
description: d.description || '',
|
|
613
|
+
inputSchema: d.inputSchema || { type: 'object' },
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
} catch (err) {
|
|
617
|
+
logErr('passthrough: leanToolDefinitions failed:', (err && err.message) || String(err));
|
|
618
|
+
}
|
|
619
|
+
if (out.length === 0) warnPassthroughEmpty(upstreamId, 'upstream not connected or has no tools');
|
|
620
|
+
else _passthroughWarned = false;
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Throttled (once per episode) warning when a wrapped gateway has nothing to advertise. */
|
|
625
|
+
let _passthroughWarned = false;
|
|
626
|
+
function warnPassthroughEmpty(upstreamId, why) {
|
|
627
|
+
if (_passthroughWarned) return;
|
|
628
|
+
_passthroughWarned = true;
|
|
629
|
+
logErr(`WARNING: passthrough wrap of upstream "${upstreamId}" advertises NO tools (${why}). ` +
|
|
630
|
+
'Check the upstream is attached + enabled in mcp/expose.json, or clear the wrap with `toolfunnel wrap --off`.');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Resolve a PASSTHROUGH call name (the ORIGINAL upstream tool name, as advertised) back to the
|
|
635
|
+
* SURFACED name the aggregator/register resolve internally. Returns the surfaced name, or null
|
|
636
|
+
* when the name is not an advertised wrapped tool (unknown, other upstream, or explicitly
|
|
637
|
+
* disabled - callable == advertised). The call-routing twin of passthroughDefinitions. The
|
|
638
|
+
* PreToolUse gate fires on the SURFACED name - stable whether the wrap is on or off, so a gate
|
|
639
|
+
* authored before wrapping keeps gating the same tool after. NEVER throws.
|
|
640
|
+
*/
|
|
641
|
+
function resolvePassthroughName(aggregator, state, upstreamId, name) {
|
|
642
|
+
if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') return null;
|
|
643
|
+
try {
|
|
644
|
+
const d = aggregator
|
|
645
|
+
.leanToolDefinitions()
|
|
646
|
+
.find((x) => x && x.upstream === upstreamId && ((x.tool || x.name) === name));
|
|
647
|
+
if (!d) return null;
|
|
648
|
+
return isToolEnabled(state, d.name) ? d.name : null;
|
|
649
|
+
} catch (_e) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Is `name` already a LOCAL register tool's id OR display name? Used to keep the lean-rule
|
|
655
|
+
* "local-register wins" consistent on the top-level surface (and so an advertised upstream name is
|
|
656
|
+
* never run as a local tool via the run path's display-name resolution). NEVER throws. */
|
|
657
|
+
function localNameTaken(registry, name) {
|
|
658
|
+
if (!registry) return false;
|
|
659
|
+
try {
|
|
660
|
+
return registry.list().some((b) => b && (b.id === name || b.name === name));
|
|
661
|
+
} catch (_e) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The MCP tool definitions for every LOCAL register tool promoted HOT (and enabled). Each is built
|
|
668
|
+
* from the register entry so a directly-callable top-level tool carries a real input schema:
|
|
669
|
+
* - name = the register `id` (an identifier-clean MCP tool name; the human `name` may carry
|
|
670
|
+
* spaces, which is a poor MCP tool name). The id is also what toolfunnel_run_tool
|
|
671
|
+
* resolves, and the PreToolUse gate fires on `entry.name || id` for BOTH the direct
|
|
672
|
+
* call and a run_tool call (the adapter's resolveExecution sets that toolName), so a
|
|
673
|
+
* gate authored via the UI - which keys the matcher on that same `entry.name || id`
|
|
674
|
+
* - gates both routes identically. No new gate-naming footgun vs run_tool today.
|
|
675
|
+
* - description = the entry summary (falls back to the human name, then the id).
|
|
676
|
+
* - inputSchema = the entry's optional `inputSchema` object, else a free-form {type:object}.
|
|
677
|
+
* NEVER throws.
|
|
678
|
+
* @param {object} registry the raw Registry
|
|
679
|
+
* @param {object} state the loaded tool-state overlay
|
|
680
|
+
* @returns {Array<{name:string, description:string, inputSchema:object}>}
|
|
681
|
+
*/
|
|
682
|
+
function localHotDefinitions(registry, state) {
|
|
683
|
+
const out = [];
|
|
684
|
+
let briefs;
|
|
685
|
+
try { briefs = registry.list(); } catch (_e) { return out; }
|
|
686
|
+
for (const b of briefs) {
|
|
687
|
+
if (!b || typeof b.id !== 'string') continue;
|
|
688
|
+
if (!isToolHot(state, b.id, false)) continue; // opt-in only (default off)
|
|
689
|
+
if (!isToolEnabled(state, b.id)) continue; // a disabled tool is never hot
|
|
690
|
+
let entry;
|
|
691
|
+
try { entry = registry.getEntry(b.id); } catch (_e) { continue; }
|
|
692
|
+
out.push(localToolDefinition(entry));
|
|
693
|
+
}
|
|
694
|
+
return out;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Build one top-level MCP def from a register entry. name = the id (clean identifier). description
|
|
698
|
+
* = summary || name || id. inputSchema = entry.inputSchema when an object, else free-form {type:object}. */
|
|
699
|
+
function localToolDefinition(entry) {
|
|
700
|
+
const schema = entry && entry.inputSchema && typeof entry.inputSchema === 'object' && !Array.isArray(entry.inputSchema)
|
|
701
|
+
? entry.inputSchema
|
|
702
|
+
: { type: 'object' };
|
|
703
|
+
const description = (entry && typeof entry.summary === 'string' && entry.summary)
|
|
704
|
+
? entry.summary
|
|
705
|
+
: ((entry && typeof entry.name === 'string' && entry.name) ? entry.name : entry.id);
|
|
706
|
+
return { name: entry.id, description, inputSchema: schema };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function handleToolsCall(protocol, aggregator, deps, params) {
|
|
710
|
+
const p = params || {};
|
|
711
|
+
const name = p.name;
|
|
712
|
+
const args = p.arguments;
|
|
713
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
714
|
+
// Shape the bad-params case as an MCP tools/call error result (isError) rather than a
|
|
715
|
+
// protocol-level JSON-RPC error, so the model sees a usable message.
|
|
716
|
+
return {
|
|
717
|
+
content: [{ type: 'text', text: 'tools/call requires a string "name".' }],
|
|
718
|
+
isError: true,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── Routing (see the architecture notes §2; the curated-direct invariant in §9; the MATRIX) ────
|
|
723
|
+
// 1. A meta-tool name -> protocol.dispatch (unchanged; a meta-tool can NEVER be shadowed).
|
|
724
|
+
// 2. ELSE a LOCAL tool promoted HOT, called directly -> run it exactly like toolfunnel_run_tool
|
|
725
|
+
// {name,args}: ONE path, reusing the PreToolUse gate + reference-mode + register resolution.
|
|
726
|
+
// Local-register wins over a curated-direct of the same name (the lean rule + list precedence).
|
|
727
|
+
// 3. ELSE a curated-direct tool (expose[]) -> run THROUGH gatedRun with transparent upstream-
|
|
728
|
+
// envelope passthrough. THE INVARIANT: it can NEVER reach the upstream without the gate.
|
|
729
|
+
// 4. ELSE an UPSTREAM tool promoted HOT (by surfaced name, not via expose[]), called directly ->
|
|
730
|
+
// run it through the gate with the upstream envelope passed through VERBATIM, exactly like
|
|
731
|
+
// route 3 - a hot upstream tool and a curated one must be indistinguishable to the caller.
|
|
732
|
+
// 5. ELSE -> protocol.dispatch, which returns the clean unknown-tool error.
|
|
733
|
+
// Only tools ACTUALLY advertised hot are directly callable (the advertised surface == the callable
|
|
734
|
+
// surface - a non-promoted tool called directly gets the clean unknown-tool error, not a silent run).
|
|
735
|
+
// Load the tool-state overlay ONCE for the routing checks - fresh per call so a UI toggle is live.
|
|
736
|
+
const d0 = deps || {};
|
|
737
|
+
let state = {};
|
|
738
|
+
if (d0.toolStatePath) { try { state = loadToolState(d0.toolStatePath); } catch (_e) { state = {}; } }
|
|
739
|
+
|
|
740
|
+
// 0. PASSTHROUGH: under a wrap the callable surface is EXACTLY the wrapped upstream's tools -
|
|
741
|
+
// the meta-tools and everything else are uncallable (the advertised surface is the callable
|
|
742
|
+
// surface, same invariant as the matrix). The call runs the UPSTREAM tool directly through the
|
|
743
|
+
// gate (runLeanForward), NOT via protocol.runTool - protocol.runTool's adapter is local-first,
|
|
744
|
+
// so a local register tool whose id collides with the wrapped upstream's surfaced name would
|
|
745
|
+
// silently execute INSTEAD of the upstream. A transparent
|
|
746
|
+
// wrap must run the upstream regardless of a local shadow; runLeanForward resolves straight off
|
|
747
|
+
// the aggregator, so the gate still fires on the surfaced name and the upstream always runs.
|
|
748
|
+
const passthroughId = getPassthrough(state);
|
|
749
|
+
// Bridge B RESUME: an MRTR retry carries requestState (+ inputResponses) - answer the
|
|
750
|
+
// upstream's held question and continue the SUSPENDED call. It never re-enters the gate:
|
|
751
|
+
// the gate fired when the original call started, and no new upstream call is issued.
|
|
752
|
+
// Checked BEFORE the passthrough guard: a retry after the wrap was CLEARED mid-suspension
|
|
753
|
+
// (A->null) must still reach the token machinery - whose identity check cancels the stranded
|
|
754
|
+
// upstream question and answers neutrally - instead of falling into funnel routing and
|
|
755
|
+
// leaking an "Unknown meta-tool" funnel tell mid-conversation. requestState
|
|
756
|
+
// is protocol-level MRTR vocabulary, never ordinary tool args, so this intercept is safe.
|
|
757
|
+
if (p.requestState !== undefined || p.inputResponses !== undefined) {
|
|
758
|
+
return await resumeElicit(p, d0.modernCaller === true, passthroughId, d0.cancelKey || null);
|
|
759
|
+
}
|
|
760
|
+
if (passthroughId) {
|
|
761
|
+
// The client calls the ORIGINAL tool name (as advertised); resolve it to the SURFACED name the
|
|
762
|
+
// aggregator + gate use (stable whether wrapped or not).
|
|
763
|
+
const surfaced = resolvePassthroughName(aggregator, state, passthroughId, name);
|
|
764
|
+
if (surfaced) {
|
|
765
|
+
// Forward the caller's request-scoped _meta minus the modern protocol trio - same
|
|
766
|
+
// strip-and-merge as forwardWrapped. progressToken lives here; dropping it killed progress
|
|
767
|
+
// for every wrapped call.
|
|
768
|
+
const meta = stripProtocolMeta(p._meta);
|
|
769
|
+
return await runLeanForward(aggregator, { engine: d0.engine, ctx: d0.ctx }, surfaced, args,
|
|
770
|
+
{ calledName: name, meta, cancelKey: d0.cancelKey || null, modernCaller: d0.modernCaller === true });
|
|
771
|
+
}
|
|
772
|
+
return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// 1. A meta-tool - but callable ONLY while it is on the advertised top-level surface (hot, default
|
|
776
|
+
// true). A meta HIDDEN via hot:false is dropped from tools/list AND becomes uncallable, so the
|
|
777
|
+
// callable surface == the advertised surface even for the meta-tools - the "ordinary tools as an
|
|
778
|
+
// MCP" lockdown is REAL, not cosmetic. (A meta-tool is still never SHADOWED by a same-named
|
|
779
|
+
// local/upstream tool: this check runs FIRST, so a hot meta always wins precedence.)
|
|
780
|
+
if (isMetaTool(protocol, name)) {
|
|
781
|
+
if (isToolHot(state, name, true)) {
|
|
782
|
+
return wrapProtocolResult(await protocol.dispatch(name, args || {}));
|
|
783
|
+
}
|
|
784
|
+
// Hidden meta-tool: not advertised, so not callable - the clean unknown-tool error (never run it).
|
|
785
|
+
return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (d0.registry && isPromotedLocal(d0.registry, state, name)) {
|
|
789
|
+
return wrapProtocolResult(await protocol.runTool({ name, args: args == null ? {} : args }));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (aggregator && typeof aggregator.isExposed === 'function' && aggregator.isExposed(name)) {
|
|
793
|
+
// The caller's non-protocol _meta rides the forward here too - the progressToken is the
|
|
794
|
+
// upstream-side keep-alive (beats are NOT fanned out to funnel clients; wrap mode relays them).
|
|
795
|
+
return await runCuratedDirect(aggregator, deps, name, args, stripProtocolMeta(p._meta));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (isPromotedUpstream(aggregator, d0.registry, state, name)) {
|
|
799
|
+
// VERBATIM envelope, exactly like curated-direct - routing this through protocol.runTool's
|
|
800
|
+
// lean adapter flattened multi-block content to one JSON-stringified text block and dropped
|
|
801
|
+
// structuredContent: identical advertised def, broken results.
|
|
802
|
+
return await runHotUpstreamDirect(aggregator, { engine: d0.engine, ctx: d0.ctx }, name, args,
|
|
803
|
+
stripProtocolMeta(p._meta));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Not a meta-tool, not promoted, not exposed -> let the protocol return its clean unknown-tool error.
|
|
807
|
+
return wrapProtocolResult(await protocol.dispatch(name, args || {}));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Is `name` a LOCAL register tool that is promoted HOT and enabled (so it's on the top-level surface
|
|
812
|
+
* AND therefore directly callable)? Resolves the advertised name (entry.name || id) back to the
|
|
813
|
+
* register entry, then checks the hot+enabled flags by the tool's id (the state key). NEVER throws.
|
|
814
|
+
*/
|
|
815
|
+
function isPromotedLocal(registry, state, name) {
|
|
816
|
+
const entry = localEntryByAdvertisedName(registry, name);
|
|
817
|
+
if (!entry) return false;
|
|
818
|
+
return isToolHot(state, entry.id, false) && isToolEnabled(state, entry.id);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Resolve a top-level call name back to its register brief. The advertised top-level name is the
|
|
822
|
+
* `id`; the human `name` is accepted too (forgiving callers). Returns the brief {id,name,...} or
|
|
823
|
+
* null. NEVER throws. */
|
|
824
|
+
function localEntryByAdvertisedName(registry, name) {
|
|
825
|
+
try {
|
|
826
|
+
const briefs = registry.list();
|
|
827
|
+
let hit = briefs.find((b) => b && b.id === name);
|
|
828
|
+
if (!hit) hit = briefs.find((b) => b && b.name === name);
|
|
829
|
+
return hit || null;
|
|
830
|
+
} catch (_e) {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Is `name` a connected UPSTREAM tool (by surfaced name) promoted HOT and enabled? Only reached for a
|
|
837
|
+
* name that is NOT a meta-tool, NOT a promoted local tool, and NOT curated-direct (those are routed
|
|
838
|
+
* first), so this is the "hot upstream, not via expose[]" case. NEVER throws.
|
|
839
|
+
*/
|
|
840
|
+
function isPromotedUpstream(aggregator, registry, state, name) {
|
|
841
|
+
if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') return false;
|
|
842
|
+
// local-register wins: a name that is a local tool's id/display name is NOT an upstream-hot route,
|
|
843
|
+
// even if an upstream surfaces that name (it would otherwise run the LOCAL tool via the run path's
|
|
844
|
+
// display-name resolution - advertised-upstream / executed-local). Matches handleToolsList step 4.
|
|
845
|
+
if (localNameTaken(registry, name)) return false;
|
|
846
|
+
try {
|
|
847
|
+
const d = aggregator.leanToolDefinitions().find((x) => x && x.name === name);
|
|
848
|
+
if (!d) return false;
|
|
849
|
+
return isToolHot(state, d.name, false) && isToolEnabled(state, d.name);
|
|
850
|
+
} catch (_e) {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** Is `name` one of the 4 lean meta-tools the protocol owns? */
|
|
856
|
+
function isMetaTool(protocol, name) {
|
|
857
|
+
// protocol.META_TOOLS is { LIST, INSTRUCTIONS, RUN, HOWTO } -> string values.
|
|
858
|
+
const meta = protocol && protocol.META_TOOLS;
|
|
859
|
+
if (!meta || typeof meta !== 'object') return false;
|
|
860
|
+
for (const key of Object.keys(meta)) {
|
|
861
|
+
if (meta[key] === name) return true;
|
|
862
|
+
}
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Run a CURATED-DIRECT tool through the hook gate. Resolve {execute} from the aggregator, then
|
|
868
|
+
* hand it to gatedRun with the threaded engine + ctx so PreToolUse fires on the DOWNSTREAM name
|
|
869
|
+
* (the gate's matcher target) BEFORE the upstream tool is ever invoked. The result is wrapped in
|
|
870
|
+
* the MCP tools/call envelope: isError when !ok, surfacing reason (a PreToolUse block) or error
|
|
871
|
+
* (a thrown upstream call). NEVER throws - a misbehaving aggregator/gate becomes a clean error
|
|
872
|
+
* result so the loop survives.
|
|
873
|
+
*/
|
|
874
|
+
/** The caller's request-scoped _meta minus the modern protocol keys - one strip shared by the
|
|
875
|
+
* wrap forward and both funnel forward paths. Returns undefined when nothing survives. */
|
|
876
|
+
function stripProtocolMeta(m) {
|
|
877
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) return undefined;
|
|
878
|
+
const out = {};
|
|
879
|
+
for (const k of Object.keys(m)) {
|
|
880
|
+
if (!k.startsWith('io.modelcontextprotocol/')) out[k] = m[k];
|
|
881
|
+
}
|
|
882
|
+
return Object.keys(out).length ? out : undefined;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function runCuratedDirect(aggregator, deps, name, args, meta) {
|
|
886
|
+
const d = deps || {};
|
|
887
|
+
// resolveExposedExecution -> { execute, toolName, upstream } | null. It can change between the
|
|
888
|
+
// isExposed() check and here only if the expose set mutated mid-call; guard for null.
|
|
889
|
+
let resolution = null;
|
|
890
|
+
try {
|
|
891
|
+
resolution = aggregator.resolveExposedExecution(name, args, meta);
|
|
892
|
+
} catch (err) {
|
|
893
|
+
return {
|
|
894
|
+
content: [{ type: 'text', text: `curated tool "${name}" failed to resolve: ${(err && err.message) || String(err)}` }],
|
|
895
|
+
isError: true,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
if (!resolution || typeof resolution.execute !== 'function') {
|
|
899
|
+
// Raced away (or never really runnable) - surface a clean error rather than reaching upstream.
|
|
900
|
+
return {
|
|
901
|
+
content: [{ type: 'text', text: `curated tool "${name}" is no longer runnable` }],
|
|
902
|
+
isError: true,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// The gate matches on the DOWNSTREAM name (resolution.toolName === the `as` name). gatedRun is
|
|
907
|
+
// contracted never to throw, but we still try/catch so a wiring bug can't crash the loop.
|
|
908
|
+
let result;
|
|
909
|
+
try {
|
|
910
|
+
result = await gatedRun({
|
|
911
|
+
engine: d.engine,
|
|
912
|
+
ctx: d.ctx,
|
|
913
|
+
toolName: resolution.toolName || name,
|
|
914
|
+
args: args == null ? {} : args,
|
|
915
|
+
execute: resolution.execute,
|
|
916
|
+
});
|
|
917
|
+
} catch (err) {
|
|
918
|
+
return {
|
|
919
|
+
content: [{ type: 'text', text: `gated execution of "${name}" failed: ${(err && err.message) || String(err)}` }],
|
|
920
|
+
isError: true,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Gate-level failure (a PreToolUse block, or a thrown upstream call): surface the most
|
|
925
|
+
// informative message - a block carries `reason`, a thrown call carries `error`.
|
|
926
|
+
if (!(result && result.ok === true)) {
|
|
927
|
+
const message = firstString(
|
|
928
|
+
result && result.error && (result.error.message || String(result.error)),
|
|
929
|
+
result && result.reason,
|
|
930
|
+
'curated tool call failed'
|
|
931
|
+
);
|
|
932
|
+
return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Success: result.output IS the upstream's own tools/call envelope ({ content, isError }).
|
|
936
|
+
// Pass it THROUGH transparently so the client receives the upstream's real content blocks
|
|
937
|
+
// (text / image / etc.) AND its isError flag - rather than the whole envelope JSON-stringified
|
|
938
|
+
// into one text block (which also used to swallow the upstream's isError). runCuratedDirect only
|
|
939
|
+
// ever runs FORWARDED upstream tools, so result.output is always an MCP envelope here.
|
|
940
|
+
const out = result.output;
|
|
941
|
+
if (out && typeof out === 'object' && Array.isArray(out.content)) {
|
|
942
|
+
const shaped = { content: out.content, isError: out.isError === true };
|
|
943
|
+
// structuredContent rides along - callTool preserves it from the upstream; dropping it HERE
|
|
944
|
+
// silently stripped modern upstreams' structured results on the curated-direct path while
|
|
945
|
+
// the wrap path returned them verbatim.
|
|
946
|
+
if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
|
|
947
|
+
return shaped;
|
|
948
|
+
}
|
|
949
|
+
// Defensive fallback: a non-standard upstream payload - wrap it as text so the client still gets
|
|
950
|
+
// a well-formed envelope rather than an empty/invalid content array.
|
|
951
|
+
return { content: [{ type: 'text', text: stringifyContent(out) }], isError: false };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Run a HOT-PROMOTED upstream tool (matrix route 4 - on the top-level surface WITHOUT an
|
|
956
|
+
* expose[] entry) through the hook gate with the upstream's envelope passed through VERBATIM -
|
|
957
|
+
* multi-block content, isError, structuredContent - exactly like curated-direct. The lean
|
|
958
|
+
* adapter's unwrap flattened all of that into one JSON-stringified text block.
|
|
959
|
+
* resolveRawExecution is the envelope-verbatim resolver (nothing about it is wrap-specific);
|
|
960
|
+
* gatedRun still fires PreToolUse on the surfaced name BEFORE the upstream is invoked. Error
|
|
961
|
+
* text is funnel-honest (this is funnel mode - no invisibility requirement). NEVER throws.
|
|
962
|
+
*/
|
|
963
|
+
async function runHotUpstreamDirect(aggregator, deps, name, args, meta) {
|
|
964
|
+
const d = deps || {};
|
|
965
|
+
let resolution = null;
|
|
966
|
+
try {
|
|
967
|
+
if (aggregator && typeof aggregator.resolveRawExecution === 'function') {
|
|
968
|
+
resolution = aggregator.resolveRawExecution(name, args, meta);
|
|
969
|
+
}
|
|
970
|
+
} catch (err) {
|
|
971
|
+
return {
|
|
972
|
+
content: [{ type: 'text', text: `tool "${name}" failed to resolve: ${(err && err.message) || String(err)}` }],
|
|
973
|
+
isError: true,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
if (!resolution || typeof resolution.execute !== 'function') {
|
|
977
|
+
// Raced away (tools changed / upstream dropped) - clean unknown-tool error, never a silent run.
|
|
978
|
+
return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
|
|
979
|
+
}
|
|
980
|
+
let result;
|
|
981
|
+
try {
|
|
982
|
+
result = await gatedRun({
|
|
983
|
+
engine: d.engine,
|
|
984
|
+
ctx: d.ctx,
|
|
985
|
+
toolName: resolution.toolName || name,
|
|
986
|
+
args: args == null ? {} : args,
|
|
987
|
+
execute: resolution.execute,
|
|
988
|
+
});
|
|
989
|
+
} catch (err) {
|
|
990
|
+
return {
|
|
991
|
+
content: [{ type: 'text', text: `gated execution of "${name}" failed: ${(err && err.message) || String(err)}` }],
|
|
992
|
+
isError: true,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
if (!(result && result.ok === true)) {
|
|
996
|
+
const message = firstString(
|
|
997
|
+
result && result.error && (result.error.message || String(result.error)),
|
|
998
|
+
result && result.reason,
|
|
999
|
+
'tool call failed'
|
|
1000
|
+
);
|
|
1001
|
+
return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
|
|
1002
|
+
}
|
|
1003
|
+
// Success: result.output IS the upstream's own tools/call envelope - pass it through, with
|
|
1004
|
+
// structuredContent riding along (same shaping as runCuratedDirect).
|
|
1005
|
+
const out = result.output;
|
|
1006
|
+
if (out && typeof out === 'object' && Array.isArray(out.content)) {
|
|
1007
|
+
const shaped = { content: out.content, isError: out.isError === true };
|
|
1008
|
+
if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
|
|
1009
|
+
return shaped;
|
|
1010
|
+
}
|
|
1011
|
+
// Defensive fallback: a non-standard upstream payload still returns a well-formed envelope.
|
|
1012
|
+
return { content: [{ type: 'text', text: stringifyContent(out) }], isError: false };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* runLeanForward - run a wrapped upstream tool (by surfaced name) through the gate DIRECTLY off
|
|
1017
|
+
* the aggregator, bypassing the local-first register adapter. Used by passthrough so a local
|
|
1018
|
+
* register tool sharing the surfaced name can never shadow the wrapped upstream's tool. The
|
|
1019
|
+
* execution is RAW (resolveRawExecution): the upstream's tools/call envelope - multi-block
|
|
1020
|
+
* content, isError, structuredContent - returns VERBATIM, exactly as a direct connection would
|
|
1021
|
+
* deliver it. Only a gate block or a transport failure synthesises an envelope. NEVER throws.
|
|
1022
|
+
* Synthetic error text is NEUTRAL and keyed to the name the CLIENT called: the surfaced
|
|
1023
|
+
* `<upstream>_<tool>` form and internal error strings ("Aggregator.ensureConnected: ...") are both
|
|
1024
|
+
* naked wrap tells. A PreToolUse gate block keeps its reason - the gate is deliberate
|
|
1025
|
+
* operator policy, not an internal leak.
|
|
1026
|
+
* @param {object} aggregator
|
|
1027
|
+
* @param {{engine?:object, ctx?:object}} deps
|
|
1028
|
+
* @param {string} surfaced the surfaced upstream tool name (`<upstream>_<tool>` or its `as`)
|
|
1029
|
+
* @param {any} args
|
|
1030
|
+
* @param {{calledName?:string, meta?:object}} [opts] the client-called name (for error text) +
|
|
1031
|
+
* the request-scoped non-protocol _meta to forward (progressToken et al.)
|
|
1032
|
+
* @returns {Promise<object>} the upstream's verbatim result, or a synthetic { content, isError }
|
|
1033
|
+
*/
|
|
1034
|
+
async function runLeanForward(aggregator, deps, surfaced, args, opts) {
|
|
1035
|
+
const d = deps || {};
|
|
1036
|
+
const o = opts || {};
|
|
1037
|
+
const called = typeof o.calledName === 'string' && o.calledName ? o.calledName : surfaced;
|
|
1038
|
+
if (!aggregator || typeof aggregator.resolveRawExecution !== 'function') {
|
|
1039
|
+
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1040
|
+
}
|
|
1041
|
+
// Cancel translation for the TOOL path: register the upstream rpc id
|
|
1042
|
+
// under the caller's (connection, client id) the moment the forward is issued, exactly like
|
|
1043
|
+
// forwardWrapped - a wrapped tools/call is the DOMINANT long-running-cancel case and was the
|
|
1044
|
+
// one hole left in the map. Registered post-gate: a cancel landing during the gate phase is a
|
|
1045
|
+
// best-effort drop (micro-window, same accepted class as the rpc-issue microtask window).
|
|
1046
|
+
// The rpc id is ALSO captured locally: a Bridge B suspension must extend that request's
|
|
1047
|
+
// timeout (a human answers at human speed; the dead-upstream clock must not kill the hold).
|
|
1048
|
+
let upstreamRpcId = null;
|
|
1049
|
+
const track = {
|
|
1050
|
+
set rpcId(v) {
|
|
1051
|
+
upstreamRpcId = v;
|
|
1052
|
+
if (o.cancelKey) inflightForwards.set(o.cancelKey, v);
|
|
1053
|
+
},
|
|
1054
|
+
};
|
|
1055
|
+
let resolution = null;
|
|
1056
|
+
try {
|
|
1057
|
+
resolution = aggregator.resolveRawExecution(surfaced, args, o.meta, track);
|
|
1058
|
+
} catch (_err) {
|
|
1059
|
+
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1060
|
+
}
|
|
1061
|
+
if (!resolution || typeof resolution.execute !== 'function') {
|
|
1062
|
+
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1063
|
+
}
|
|
1064
|
+
// Bridge B: the upstream may send `elicitation/create` mid-call and hold the call open. Race
|
|
1065
|
+
// the real forward against an elicit INSIDE the gated execute, so the gate + PostToolUse fire
|
|
1066
|
+
// exactly once around whichever wins. An elicit win resolves gatedRun with a sentinel; the real
|
|
1067
|
+
// upstream promise keeps running and is either suspended (modern caller -> MRTR) or completed
|
|
1068
|
+
// after an auto-decline (legacy caller - no backwards-request relay yet; see KNOWN_BUGS).
|
|
1069
|
+
// The sentinel handed back through gatedRun is SANITISED: PostToolUse hooks JSON-stringify
|
|
1070
|
+
// tool_response onto hook stdin, and the full elicit object carries the live McpClient -
|
|
1071
|
+
// transport guts + the upstream's env (API keys). Hooks see only the question; the real
|
|
1072
|
+
// elicit object (with the client to answer on) rides this closure.
|
|
1073
|
+
let realPromise = null;
|
|
1074
|
+
let elicitCaught = null;
|
|
1075
|
+
const racedExecute = async () => {
|
|
1076
|
+
realPromise = resolution.execute();
|
|
1077
|
+
const winner = await raceElicit(resolution.upstream, realPromise);
|
|
1078
|
+
if (winner.kind === 'elicit') {
|
|
1079
|
+
elicitCaught = winner.value;
|
|
1080
|
+
return { __tfElicit: { elicitation: winner.value.params } };
|
|
1081
|
+
}
|
|
1082
|
+
return winner.value;
|
|
1083
|
+
};
|
|
1084
|
+
let result;
|
|
1085
|
+
try {
|
|
1086
|
+
result = await gatedRun({
|
|
1087
|
+
engine: d.engine,
|
|
1088
|
+
ctx: d.ctx,
|
|
1089
|
+
toolName: resolution.toolName || surfaced, // the gate matches the surfaced name (stable)
|
|
1090
|
+
args: args == null ? {} : args,
|
|
1091
|
+
execute: racedExecute,
|
|
1092
|
+
});
|
|
1093
|
+
} catch (_err) {
|
|
1094
|
+
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1095
|
+
} finally {
|
|
1096
|
+
if (o.cancelKey) inflightForwards.delete(o.cancelKey); // settled - cancels can no longer target it
|
|
1097
|
+
}
|
|
1098
|
+
if (result && result.ok === true && result.output && result.output.__tfElicit && elicitCaught) {
|
|
1099
|
+
const elicit = elicitCaught;
|
|
1100
|
+
if (o.modernCaller === true) {
|
|
1101
|
+
return suspendForElicit(elicit, realPromise, resolution.upstream, called, upstreamRpcId);
|
|
1102
|
+
}
|
|
1103
|
+
// Legacy caller: it cannot receive a backwards request, and the relay is future work -
|
|
1104
|
+
// DECLINE so the upstream's held call completes, then serve its actual outcome.
|
|
1105
|
+
logErr(`bridge-b: legacy client cannot receive elicitation - auto-declined for "${called}"`);
|
|
1106
|
+
try { elicit.client.respondToServer(elicit.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
|
|
1107
|
+
try {
|
|
1108
|
+
const out = await realPromise;
|
|
1109
|
+
result = { ok: true, blocked: false, output: out };
|
|
1110
|
+
} catch (_err) {
|
|
1111
|
+
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
// Success -> the upstream's envelope, VERBATIM (never re-shaped: re-stringifying multi-block or
|
|
1115
|
+
// structured results both destroys data and is a wrap tell).
|
|
1116
|
+
if (result && result.ok === true && result.output && typeof result.output === 'object' &&
|
|
1117
|
+
Array.isArray(result.output.content)) {
|
|
1118
|
+
return result.output;
|
|
1119
|
+
}
|
|
1120
|
+
// A gate BLOCK carries its reason ONLY when it is deliberate operator policy. An INTERNAL
|
|
1121
|
+
// fail-closed (engine wiring/crash - gatedRun marks it internal:true) holds gateway strings:
|
|
1122
|
+
// neutralise those too.
|
|
1123
|
+
if (result && result.ok !== true && typeof result.reason === 'string' && result.reason &&
|
|
1124
|
+
result.internal !== true) {
|
|
1125
|
+
return wrapProtocolResult(result);
|
|
1126
|
+
}
|
|
1127
|
+
if (result && result.ok === true) return wrapProtocolResult(result); // malformed envelope - legacy shaping
|
|
1128
|
+
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Wrap a protocol dispatch result ({ ok, output, error?, reason? }) in the MCP tools/call
|
|
1133
|
+
* envelope. Extracted so the meta-tool path and the unknown-tool path share one shaping.
|
|
1134
|
+
*/
|
|
1135
|
+
function wrapProtocolResult(result) {
|
|
1136
|
+
const isError = !(result && result.ok === true);
|
|
1137
|
+
// On error surface the most informative message (a PreToolUse block carries `reason`, a thrown
|
|
1138
|
+
// tool carries `error`); on success serialise the output payload.
|
|
1139
|
+
let payload;
|
|
1140
|
+
if (isError) {
|
|
1141
|
+
payload = firstString(
|
|
1142
|
+
// gatedRun returns `error` as an Error INSTANCE on a thrown tool - extract its message
|
|
1143
|
+
// (firstString only takes strings, so a bare Error would be dropped -> generic text).
|
|
1144
|
+
// Mirrors runCuratedDirect's error shaping so the two paths report identically.
|
|
1145
|
+
result && result.error && (result.error.message || String(result.error)),
|
|
1146
|
+
result && result.reason,
|
|
1147
|
+
'tool call failed'
|
|
1148
|
+
);
|
|
1149
|
+
} else if (result && result.output !== undefined) {
|
|
1150
|
+
payload = result.output;
|
|
1151
|
+
} else if (result && result.mode === 'reference') {
|
|
1152
|
+
// A reference HANDOFF carries no `output` - its `instructions` ARE the payload. Surface them
|
|
1153
|
+
// (with the handoff message) so a reference tool returns usable text whether reached via
|
|
1154
|
+
// toolfunnel_run_tool OR called directly after being promoted hot (the matrix). Falls back to
|
|
1155
|
+
// the whole result object if neither field is a string.
|
|
1156
|
+
payload = firstString(result.instructions, result.message) || result;
|
|
1157
|
+
} else {
|
|
1158
|
+
payload = result && result.output; // undefined -> stringifyContent guards it to a string
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
content: [{ type: 'text', text: stringifyContent(payload) }],
|
|
1163
|
+
isError,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Return the first argument that is a non-empty string (used to pick the best error message). */
|
|
1168
|
+
function firstString(...candidates) {
|
|
1169
|
+
for (const c of candidates) {
|
|
1170
|
+
if (typeof c === 'string' && c.length > 0) return c;
|
|
1171
|
+
}
|
|
1172
|
+
return '';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** Serialise a protocol payload into a text block. Strings pass through; everything else -> JSON.
|
|
1176
|
+
* NEVER returns a non-string: JSON.stringify(undefined) / a function yields `undefined`, so guard it
|
|
1177
|
+
* (an MCP content block's `text` must be a string - an undefined text is a malformed envelope). */
|
|
1178
|
+
function stringifyContent(value) {
|
|
1179
|
+
if (typeof value === 'string') return value;
|
|
1180
|
+
try {
|
|
1181
|
+
const s = JSON.stringify(value);
|
|
1182
|
+
return typeof s === 'string' ? s : String(value);
|
|
1183
|
+
} catch (_e) {
|
|
1184
|
+
return String(value);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Filter predicate replicating Registry.list's brief filtering (src/tools/registry.js:211-228) so
|
|
1190
|
+
* LEAN upstream briefs filter IDENTICALLY to local briefs: exact `category` match + case-insensitive
|
|
1191
|
+
* substring `filter` over `id name summary`. Kept in lockstep with registry.list (a parity test covers
|
|
1192
|
+
* it); if registry.list's filter semantics ever change, change this too.
|
|
1193
|
+
*/
|
|
1194
|
+
function briefMatches(b, opts) {
|
|
1195
|
+
const { filter, category } = opts || {};
|
|
1196
|
+
if (category && b.category !== category) return false;
|
|
1197
|
+
if (typeof filter === 'string' && filter.length) {
|
|
1198
|
+
const hay = `${b.id} ${b.name} ${b.summary || ''}`.toLowerCase();
|
|
1199
|
+
if (!hay.includes(filter.toLowerCase())) return false;
|
|
1200
|
+
}
|
|
1201
|
+
return true;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Render a lean upstream tool's instructions as a STRING (homogeneous with registry.instructions),
|
|
1206
|
+
* synthesised from its discovered def { name, description, inputSchema, upstream, tool }: the exact
|
|
1207
|
+
* toolfunnel_run_tool invocation, provenance, description, the input schema + required fields, and a
|
|
1208
|
+
* note that the call passes the same PreToolUse gate as any local tool.
|
|
1209
|
+
*/
|
|
1210
|
+
function renderUpstreamInstructions(def) {
|
|
1211
|
+
const lines = [];
|
|
1212
|
+
lines.push(`${def.name} - forwarded from upstream MCP "${def.upstream}" (upstream tool: ${def.tool}).`);
|
|
1213
|
+
if (def.description) lines.push('', def.description);
|
|
1214
|
+
lines.push('', 'Run it through the gateway with:', ` toolfunnel_run_tool { "name": "${def.name}", "args": { ... } }`);
|
|
1215
|
+
lines.push('', 'Every call passes the same PreToolUse gate as a local tool (a hook may deny it).');
|
|
1216
|
+
let schemaStr;
|
|
1217
|
+
try { schemaStr = JSON.stringify(def.inputSchema || { type: 'object' }, null, 2); } catch (_e) { schemaStr = '{ "type": "object" }'; }
|
|
1218
|
+
lines.push('', 'Input schema:', schemaStr);
|
|
1219
|
+
const req = def.inputSchema && Array.isArray(def.inputSchema.required) ? def.inputSchema.required : [];
|
|
1220
|
+
if (req.length) lines.push('', 'Required: ' + req.join(', '));
|
|
1221
|
+
return lines.join('\n');
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Normalise the first argument of handleMessage / createStdioLoop into the canonical Phase-2
|
|
1226
|
+
* build shape { protocol, aggregator, engine, ctx }. Backward compat: a caller may still pass a
|
|
1227
|
+
* bare protocol (Phase-1 callers / older tests) - we wrap it with a null aggregator + no deps so
|
|
1228
|
+
* the meta-tool path is identical and the curated-direct path is simply never taken (isExposed
|
|
1229
|
+
* is unreachable without an aggregator). A protocol is detected by its dispatch() method.
|
|
1230
|
+
* @param {object} arg either { protocol, aggregator, engine, ctx } or a bare protocol
|
|
1231
|
+
* @returns {{ protocol:object, aggregator:(object|null), engine:(object|null), ctx:(object|null) }}
|
|
1232
|
+
*/
|
|
1233
|
+
function normaliseBuild(arg) {
|
|
1234
|
+
const a = arg || {};
|
|
1235
|
+
if (a && typeof a.dispatch === 'function') {
|
|
1236
|
+
// It's a bare protocol - Phase-1 shape (no register/state -> no hot promotion; meta + curated only).
|
|
1237
|
+
return { protocol: a, aggregator: null, engine: null, ctx: null, registry: null, toolStatePath: null };
|
|
1238
|
+
}
|
|
1239
|
+
return {
|
|
1240
|
+
protocol: a.protocol,
|
|
1241
|
+
aggregator: a.aggregator || null,
|
|
1242
|
+
engine: a.engine || null,
|
|
1243
|
+
ctx: a.ctx || null,
|
|
1244
|
+
// The MATRIX inputs: the raw register (for a hot LOCAL tool's full MCP def) + the state overlay
|
|
1245
|
+
// path (read fresh per call). Absent -> the surface is meta + curated-direct, the prior behaviour.
|
|
1246
|
+
registry: a.registry || null,
|
|
1247
|
+
toolStatePath: a.toolStatePath || null,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1252
|
+
// Dispatch ONE parsed JSON-RPC message. Returns a response object to send, or null for
|
|
1253
|
+
// notifications / messages that take no reply. NEVER throws.
|
|
1254
|
+
//
|
|
1255
|
+
// `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
|
|
1256
|
+
// still accepted for backward compat - see normaliseBuild). The aggregator + engine + ctx are
|
|
1257
|
+
// threaded into tools/list and tools/call so the curated-direct surface is advertised and runs
|
|
1258
|
+
// THROUGH the PreToolUse gate.
|
|
1259
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1260
|
+
async function handleMessage(build, msg, connKey) {
|
|
1261
|
+
// connKey: OPTIONAL stable per-connection identity from the transport. stdio (one client per
|
|
1262
|
+
// process) passes a constant; HTTP passes nothing - each POST is a fresh connection, so no
|
|
1263
|
+
// cross-POST identity exists in the modern (sessionless) era. Cancel translation under a wrap
|
|
1264
|
+
// is only safe WITH an identity: JSON-RPC ids are per-client, and two clients both at id=3
|
|
1265
|
+
// must never cancel each other's calls.
|
|
1266
|
+
const { protocol, aggregator, engine, ctx, registry, toolStatePath } = normaliseBuild(build);
|
|
1267
|
+
|
|
1268
|
+
// Basic envelope validation - a non-object or wrong jsonrpc is an Invalid Request.
|
|
1269
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
|
|
1270
|
+
return makeError(null, ERR.INVALID_REQUEST, 'Invalid Request: expected a JSON-RPC object');
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
|
|
1274
|
+
const id = hasId ? msg.id : null;
|
|
1275
|
+
const method = msg.method;
|
|
1276
|
+
|
|
1277
|
+
// MODERN-ONLY POLICY (serveLegacy:false in toolfunnel.json - explicit hardening opt-in):
|
|
1278
|
+
// refuse every LEGACY-shaped request with a clear error naming the policy. Exemptions:
|
|
1279
|
+
// `server/discover` stays answerable meta-less (it is the negotiation endpoint - both eras
|
|
1280
|
+
// may knock to learn what this gateway speaks), and modern-SHAPED-but-invalid requests fall
|
|
1281
|
+
// through so the standard validator returns its precise -32602/-32022 instead of a blanket
|
|
1282
|
+
// refusal. Legacy NOTIFICATIONS are dropped silently - a notification never gets a response.
|
|
1283
|
+
if (SERVER_CONFIG.serveLegacy === false && !modern.isModernRequest(msg) &&
|
|
1284
|
+
method !== 'server/discover') {
|
|
1285
|
+
if (!hasId) return null;
|
|
1286
|
+
const text = method === 'initialize'
|
|
1287
|
+
? 'modern-only gateway (serveLegacy:false): the legacy initialize handshake is disabled - connect with the 2026-07-28 protocol (per-request _meta)'
|
|
1288
|
+
: 'modern-only gateway (serveLegacy:false): legacy-era requests are refused - send the 2026-07-28 per-request _meta';
|
|
1289
|
+
return makeError(id, -32020, text);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// A message with no method but with an id is a response/garbage to us - acknowledge nothing.
|
|
1293
|
+
if (typeof method !== 'string') {
|
|
1294
|
+
if (hasId) return makeError(id, ERR.INVALID_REQUEST, 'Invalid Request: missing "method"');
|
|
1295
|
+
return null; // notification-shaped junk: ignore
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
try {
|
|
1299
|
+
// ── WRAP identity (computed once) ─────────────────────────────────────────────────────────
|
|
1300
|
+
// Under a passthrough wrap, the handshake + non-tool methods present the WRAPPED upstream, so
|
|
1301
|
+
// a client cannot tell it is wrapped (the design requirement: "no one should know it's wrapped,
|
|
1302
|
+
// save the ToolFunnel dependency"). ptId = the wrapped upstream id (null when not wrapped);
|
|
1303
|
+
// wrapId = its captured identity (null when not wrapped OR not yet connected).
|
|
1304
|
+
let ptId = null;
|
|
1305
|
+
if (toolStatePath) {
|
|
1306
|
+
try {
|
|
1307
|
+
const stateRes = loadToolStateResult(toolStatePath);
|
|
1308
|
+
if (stateRes.parseError) warnCorruptToolState(toolStatePath); // corrupt file must WARN on every path, not just tools/list
|
|
1309
|
+
ptId = getPassthrough(stateRes.state);
|
|
1310
|
+
} catch (_e) { ptId = null; }
|
|
1311
|
+
}
|
|
1312
|
+
// A wrap retarget (A->B, A->null, null->A) invalidates the captured identity mirror - it belongs
|
|
1313
|
+
// to the OLD wrap context; presenting it to a different upstream mirrors the wrong client
|
|
1314
|
+
//Same-value passes clear nothing.
|
|
1315
|
+
if (ptId !== wrapMirrorPtId) { wrapMirrorClientInfo = null; wrapMirrorPtId = ptId; }
|
|
1316
|
+
if (aggregator) {
|
|
1317
|
+
// Keep the chatter bridge live with the wrap state. Through the setter: a target CHANGE
|
|
1318
|
+
// clears the recorded subscriptions (old wrap context); same-value
|
|
1319
|
+
// re-arms (every message) clear nothing.
|
|
1320
|
+
if (typeof aggregator.setWrapChatterUpstream === 'function') aggregator.setWrapChatterUpstream(ptId);
|
|
1321
|
+
else aggregator.wrapChatterUpstream = ptId;
|
|
1322
|
+
}
|
|
1323
|
+
let wrapId = (ptId && aggregator && typeof aggregator.wrappedIdentity === 'function')
|
|
1324
|
+
? aggregator.wrappedIdentity(ptId) : null;
|
|
1325
|
+
if (ptId) {
|
|
1326
|
+
// Identity continuity: cache the last-known wrapped identity so an upstream death or a
|
|
1327
|
+
// reconnect window never answers a handshake as ToolFunnel (identity leak + visible swap).
|
|
1328
|
+
// Never-connected + handshake -> refuse neutrally rather than leak the funnel.
|
|
1329
|
+
if (wrapId) lastWrappedIdentity.set(ptId, wrapId);
|
|
1330
|
+
else wrapId = lastWrappedIdentity.get(ptId) || null;
|
|
1331
|
+
// No identity at all (never connected this process): refuse neutrally - for the handshake
|
|
1332
|
+
// AND for any modern request, whose result decoration would otherwise carry ToolFunnel's
|
|
1333
|
+
// serverInfo (modern clients are stateless, tools/list without a prior
|
|
1334
|
+
// discover is normal - the leak window is real). Legacy non-handshake results carry no
|
|
1335
|
+
// identity fields, so they pass.
|
|
1336
|
+
if (!wrapId && (method === 'initialize' || method === 'server/discover' || modern.isModernRequest(msg))) {
|
|
1337
|
+
return hasId ? makeError(id, ERR.INTERNAL, 'request failed') : null;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// ── Dual-era routing ─────────────────────────────────────────────────────────────────────
|
|
1342
|
+
// server/discover is answered in EITHER era: it is the spec's era probe (a dual-era client
|
|
1343
|
+
// uses it on stdio to detect what the server speaks), and the RC leaves the meta-less-probe
|
|
1344
|
+
// case undefined - answering is the honest choice (an error would read as "legacy-only").
|
|
1345
|
+
// A modern-shaped discover still validates its _meta first. Under a wrap it presents the
|
|
1346
|
+
// wrapped server's identity (but keeps OUR supportedVersions - modern-compliance is the point).
|
|
1347
|
+
if (method === 'server/discover') {
|
|
1348
|
+
if (!hasId) return null;
|
|
1349
|
+
if (modern.isModernRequest(msg)) {
|
|
1350
|
+
const vErr = modern.validateModernRequest(msg);
|
|
1351
|
+
if (vErr) return makeError(id, vErr.code, vErr.message, vErr.data);
|
|
1352
|
+
}
|
|
1353
|
+
return makeResult(id, handleServerDiscover(wrapId));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ── WRAP passthrough for NON-tool methods ─────────────────────────────────────────────────
|
|
1357
|
+
// Under a wrap, any method ToolFunnel does not own itself (resources/*, prompts/*, logging/*,
|
|
1358
|
+
// completion/*, ...) is FORWARDED verbatim to the wrapped upstream, so a wrapped server behaves
|
|
1359
|
+
// exactly as it would connected directly (its advertised capabilities are honest). ToolFunnel
|
|
1360
|
+
// keeps ownership of: the handshake (initialize/discover - identity), the tool surface
|
|
1361
|
+
// (tools/list + tools/call - GATED, and re-presented under original names), the change stream
|
|
1362
|
+
// (subscriptions/listen), and ping. Tool calls NEVER forward here - they stay gated.
|
|
1363
|
+
if (ptId && !WRAP_OWNED_METHODS.has(method)) {
|
|
1364
|
+
return await forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// A request carrying modern per-request _meta -> the 2026-07-28 path. `initialize` ALWAYS
|
|
1368
|
+
// selects legacy semantics (the spec's dual-era server rule), even if it carries _meta.
|
|
1369
|
+
if (method !== 'initialize' && modern.isModernRequest(msg)) {
|
|
1370
|
+
return await handleModernMessage(
|
|
1371
|
+
{ protocol, aggregator, engine, ctx, registry, toolStatePath },
|
|
1372
|
+
msg, hasId, id, method, wrapId, connKey
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
switch (method) {
|
|
1377
|
+
case 'initialize': {
|
|
1378
|
+
// CLIENT IDENTITY MIRRORING (wrap, STDIO only - HTTP is sessionless/multi-client, the
|
|
1379
|
+
// configured identity applies there). Capture the real downstream client's clientInfo;
|
|
1380
|
+
// if the live wrapped connection introduced itself differently, reconnect it INLINE -
|
|
1381
|
+
// the client is waiting on the handshake anyway. Honest cost bound: one full reconnect
|
|
1382
|
+
// inside the handshake - up to ~3 s for the era re-probe on an upstream that ignores
|
|
1383
|
+
// server/discover, plus the initialize wait on a wedged one (the old
|
|
1384
|
+
// "+1-2s once" claim understated it). Once per client session, and only when the
|
|
1385
|
+
// presented identity differs.
|
|
1386
|
+
// Best-effort: any failure answers from the cached identity; the mirror must never
|
|
1387
|
+
// break the handshake.
|
|
1388
|
+
if (ptId && connKey === 'stdio' && aggregator) {
|
|
1389
|
+
const ci = msg.params && msg.params.clientInfo;
|
|
1390
|
+
if (ci && typeof ci === 'object' && typeof ci.name === 'string' && ci.name.length) {
|
|
1391
|
+
// Carry the FULL clientInfo through (title etc. - the wrap's serverInfo direction
|
|
1392
|
+
// learned this the hard way: a name/version whitelist eats spec fields). Only
|
|
1393
|
+
// name/version are normalised; the comparison below uses just those two.
|
|
1394
|
+
wrapMirrorClientInfo = Object.assign({}, ci, {
|
|
1395
|
+
name: ci.name,
|
|
1396
|
+
version: typeof ci.version === 'string' ? ci.version : '0.0.0',
|
|
1397
|
+
});
|
|
1398
|
+
try {
|
|
1399
|
+
const live = await aggregator.ensureConnected(ptId, { allowConnect: false })
|
|
1400
|
+
.catch(() => null); // not connected right now -> reconnect() below presents the mirror
|
|
1401
|
+
const presented = live && live.clientInfo;
|
|
1402
|
+
if (!presented || presented.name !== wrapMirrorClientInfo.name ||
|
|
1403
|
+
presented.version !== wrapMirrorClientInfo.version) {
|
|
1404
|
+
// reconnect() settles any in-flight connect before discarding - never join a
|
|
1405
|
+
// connect this path just poisoned.
|
|
1406
|
+
await aggregator.reconnect(ptId);
|
|
1407
|
+
}
|
|
1408
|
+
// The (re)connect refreshed the upstream identity - recompute wrapId from it.
|
|
1409
|
+
const freshId = typeof aggregator.wrappedIdentity === 'function'
|
|
1410
|
+
? aggregator.wrappedIdentity(ptId) : null;
|
|
1411
|
+
if (freshId) { wrapId = freshId; lastWrappedIdentity.set(ptId, freshId); }
|
|
1412
|
+
} catch (_e) { /* mirror is best-effort - wrapId (cached identity) still answers */ }
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return makeResult(id, handleInitialize(msg.params, wrapId));
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
case 'initialized':
|
|
1419
|
+
case 'notifications/initialized':
|
|
1420
|
+
// Client -> server notification that init handshake is complete. No reply.
|
|
1421
|
+
return null;
|
|
1422
|
+
|
|
1423
|
+
case 'tools/list':
|
|
1424
|
+
return makeResult(id, handleToolsList(protocol, aggregator, { registry, toolStatePath }));
|
|
1425
|
+
|
|
1426
|
+
case 'tools/call': {
|
|
1427
|
+
// cancelKey: lets a wrapped tools/call register its upstream rpc id for cancel
|
|
1428
|
+
// translation, same (connection, client id) contract as forwardWrapped.
|
|
1429
|
+
const cancelKey = (connKey !== undefined && hasId) ? `${connKey}:${id}` : null;
|
|
1430
|
+
const result = await handleToolsCall(protocol, aggregator,
|
|
1431
|
+
{ engine, ctx, registry, toolStatePath, cancelKey, modernCaller: false }, msg.params);
|
|
1432
|
+
// Observability (in-memory counters; never throws). Count every tools/call by name + whether
|
|
1433
|
+
// it errored (an isError envelope = a tool failure OR a PreToolUse denial). Single chokepoint
|
|
1434
|
+
// -> covers stdio AND the HTTP transport, which both route through handleMessage.
|
|
1435
|
+
metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
|
|
1436
|
+
return makeResult(id, result);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
case 'ping':
|
|
1440
|
+
// MCP keep-alive: empty result.
|
|
1441
|
+
return makeResult(id, {});
|
|
1442
|
+
|
|
1443
|
+
default:
|
|
1444
|
+
// Unknown notification (no id) -> silently ignore. Unknown request -> method-not-found.
|
|
1445
|
+
if (!hasId) {
|
|
1446
|
+
if (method.startsWith('notifications/')) return null;
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
return makeError(id, ERR.METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
1450
|
+
}
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
// Any unexpected internal failure becomes a JSON-RPC error (request) or is swallowed
|
|
1453
|
+
// (notification) - the loop never crashes on one bad message.
|
|
1454
|
+
logErr('handler error for', method + ':', (err && err.stack) || String(err));
|
|
1455
|
+
if (hasId) {
|
|
1456
|
+
return makeError(id, ERR.INTERNAL, `Internal error handling "${method}"`, (err && err.message) || String(err));
|
|
1457
|
+
}
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* handleServerDiscover - the server/discover result (modern era; servers MUST implement it).
|
|
1464
|
+
* Capabilities mirror what initialize advertises so the two eras never drift.
|
|
1465
|
+
*
|
|
1466
|
+
* `wrapId` (optional): under a passthrough WRAP, present the wrapped upstream's serverInfo +
|
|
1467
|
+
* capabilities + instructions verbatim (invisible wrap) - but keep OUR supportedVersions (both
|
|
1468
|
+
* eras): the wrap's value is that a legacy MCP becomes modern-compliant, so a modern client
|
|
1469
|
+
* SHOULD see modern support. Identity from the upstream; protocol reach from ToolFunnel.
|
|
1470
|
+
*/
|
|
1471
|
+
function handleServerDiscover(wrapId) {
|
|
1472
|
+
if (wrapId) {
|
|
1473
|
+
return modern.discoverResult(
|
|
1474
|
+
wrapId.serverInfo || SERVER_INFO,
|
|
1475
|
+
wrapId.capabilities || { tools: { listChanged: true } },
|
|
1476
|
+
typeof wrapId.instructions === 'string' ? wrapId.instructions : undefined
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
return modern.discoverResult(
|
|
1480
|
+
SERVER_INFO,
|
|
1481
|
+
{ tools: { listChanged: true } },
|
|
1482
|
+
'ToolFunnel is a dual-era MCP gateway: it speaks the ' + modern.MODERN_PROTOCOL_VERSION +
|
|
1483
|
+
' revision (per-request _meta, server/discover, subscriptions/listen) and the ' +
|
|
1484
|
+
PROTOCOL_VERSION + ' revision (initialize handshake) on the same endpoint. ' +
|
|
1485
|
+
'Every tool call passes a server-side policy gate before it runs.'
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* The methods ToolFunnel OWNS even under a wrap - everything else is forwarded to the wrapped
|
|
1491
|
+
* upstream (see forwardWrapped). The handshake (identity), the gated tool surface, the change
|
|
1492
|
+
* stream, and ping stay ours; resources/prompts/logging/completion/etc. pass straight through.
|
|
1493
|
+
*/
|
|
1494
|
+
const WRAP_OWNED_METHODS = new Set([
|
|
1495
|
+
'initialize', 'initialized', 'notifications/initialized',
|
|
1496
|
+
'tools/list', 'tools/call', 'server/discover', 'subscriptions/listen', 'ping',
|
|
1497
|
+
]);
|
|
1498
|
+
|
|
1499
|
+
/** Forwarded methods whose modern result is a CacheableResult (MUST carry ttlMs + cacheScope per
|
|
1500
|
+
* spec). tools/list is NOT here - it's ToolFunnel-owned and decorated with its own hints. */
|
|
1501
|
+
const CACHEABLE_FORWARD_METHODS = new Set([
|
|
1502
|
+
'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list',
|
|
1503
|
+
]);
|
|
1504
|
+
|
|
1505
|
+
/** Last-known wrapped identity per upstream id - survives upstream deaths and reconnect windows
|
|
1506
|
+
* so the wrap NEVER answers a handshake as ToolFunnel (identity leak). Keyed by passthrough id;
|
|
1507
|
+
* entries are tiny and bounded by the number of upstreams ever wrapped in-process. */
|
|
1508
|
+
const lastWrappedIdentity = new Map();
|
|
1509
|
+
|
|
1510
|
+
/** CLIENT IDENTITY MIRRORING (wrap, stdio): the real downstream client's clientInfo, captured at
|
|
1511
|
+
* its initialize handshake. While set, the wrapped upstream is (re)connected presenting THIS
|
|
1512
|
+
* identity instead of ToolFunnel's - the outbound half of two-way wrap invisibility for
|
|
1513
|
+
* legacy/stdio downstream clients (modern clients send no initialize; HTTP is multi-client -
|
|
1514
|
+
* both keep the configured identity). */
|
|
1515
|
+
let wrapMirrorClientInfo = null;
|
|
1516
|
+
/** The passthrough id the mirror was captured under - a retarget clears the mirror (see the
|
|
1517
|
+
* transition check in handleMessage). */
|
|
1518
|
+
let wrapMirrorPtId = null;
|
|
1519
|
+
|
|
1520
|
+
/** The identity to PRESENT to `upstreamId` (Aggregator clientInfoProvider - read fresh at each
|
|
1521
|
+
* connect). Chain: wrap target with a captured mirror -> the mirror; configured clientName ->
|
|
1522
|
+
* the configured identity; else null (McpClient's built-in). NEVER throws. */
|
|
1523
|
+
function clientInfoFor(upstreamId) {
|
|
1524
|
+
try {
|
|
1525
|
+
const pt = getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state);
|
|
1526
|
+
if (pt && pt === upstreamId && wrapMirrorClientInfo) return wrapMirrorClientInfo;
|
|
1527
|
+
} catch (_e) { /* state unreadable -> fall through to the configured identity */ }
|
|
1528
|
+
if (SERVER_CONFIG.clientName) {
|
|
1529
|
+
return { name: SERVER_CONFIG.clientName, version: SERVER_CONFIG.clientVersion || '0.0.0' };
|
|
1530
|
+
}
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1535
|
+
// BRIDGE B - legacy elicitation ↔ modern MRTR (the stateful adapter).
|
|
1536
|
+
//
|
|
1537
|
+
// A legacy upstream may send a SERVER-INITIATED `elicitation/create` mid-tool-call and hold the
|
|
1538
|
+
// call open until it is answered. A modern client cannot receive backwards requests (stateless
|
|
1539
|
+
// era): instead its tools/call RETURNS `resultType:"input_required"` with the question(s) and an
|
|
1540
|
+
// opaque `requestState` token, and the client RETRIES the call with `inputResponses` + the token.
|
|
1541
|
+
// ToolFunnel holds the suspended legacy call in the middle. Design + verified MRTR schema:
|
|
1542
|
+
// toolfunnel_060_design.md §BRIDGE B (spec draft/basic/patterns/mrtr).
|
|
1543
|
+
//
|
|
1544
|
+
// Correlation (the id-translation discipline from day one): a legacy
|
|
1545
|
+
// elicit carries NO originating-call marker, so it can only be bound to an in-flight wrapped call
|
|
1546
|
+
// when EXACTLY ONE such call is in flight for that upstream (always true on stdio - the chain
|
|
1547
|
+
// serialises; HTTP concurrency can produce >1, in which case the elicit is DECLINED rather than
|
|
1548
|
+
// guessed - a wrong binding would hand one client's question to another client's call).
|
|
1549
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1550
|
+
|
|
1551
|
+
/** Suspended elicitations: requestState token -> the held call + how to answer the upstream.
|
|
1552
|
+
* Tokens are unguessable (128-bit random); state never leaves this process, so the token needs
|
|
1553
|
+
* no integrity protection - a tampered/unknown token is simply not in the map. */
|
|
1554
|
+
const pendingElicits = new Map(); // token -> { upstreamId, elicitId, client, executePromise, calledName, createdAt, timer }
|
|
1555
|
+
const ELICIT_TTL_MS = 5 * 60 * 1000; // an unanswered suspension is cancelled upstream + dropped
|
|
1556
|
+
|
|
1557
|
+
/** In-flight wrapped calls willing to receive an elicit, per upstream id. Each entry is a
|
|
1558
|
+
* deliver() that hands the elicit to that call's race. Set size ≠ 1 -> no safe binding. */
|
|
1559
|
+
const elicitWaiters = new Map(); // upstreamId -> Set<{ deliver: (elicit) => void }>
|
|
1560
|
+
|
|
1561
|
+
function registerElicitWaiter(upstreamId, waiter) {
|
|
1562
|
+
let set = elicitWaiters.get(upstreamId);
|
|
1563
|
+
if (!set) { set = new Set(); elicitWaiters.set(upstreamId, set); }
|
|
1564
|
+
set.add(waiter);
|
|
1565
|
+
return () => {
|
|
1566
|
+
set.delete(waiter);
|
|
1567
|
+
if (set.size === 0) elicitWaiters.delete(upstreamId);
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* The aggregator's onUpstreamServerRequest handler. Bridges `elicitation/create` from the WRAPPED
|
|
1573
|
+
* upstream to the one in-flight wrapped call; everything else (sampling/createMessage, roots/list
|
|
1574
|
+
* - future work) is answered -32601, and an unbindable elicit is DECLINED so the upstream's held
|
|
1575
|
+
* call resolves instead of hanging. NEVER throws.
|
|
1576
|
+
*/
|
|
1577
|
+
function handleUpstreamServerRequest(build, upstreamId, msg, client) {
|
|
1578
|
+
try {
|
|
1579
|
+
if (!msg || msg.id === undefined || msg.id === null) return;
|
|
1580
|
+
if (msg.method === 'ping') {
|
|
1581
|
+
// A server-initiated ping is a keepalive, not a capability - answer it (an error reply
|
|
1582
|
+
// could read as a FAILED ping and kill a session that used to survive).
|
|
1583
|
+
client.respondToServer(msg.id, {});
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (msg.method !== 'elicitation/create') {
|
|
1587
|
+
client.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
// Only the WRAPPED upstream's elicitations bridge (the funnel has no client to ask).
|
|
1591
|
+
let ptId = null;
|
|
1592
|
+
try {
|
|
1593
|
+
ptId = build && build.toolStatePath ? getPassthrough(loadToolStateResult(build.toolStatePath).state) : null;
|
|
1594
|
+
} catch (_e) { ptId = null; }
|
|
1595
|
+
// The binding invariant: "exactly one wrapped call in flight" must mean it in
|
|
1596
|
+
// GROUND TRUTH, not just one tools/call race waiter. A SUSPENDED call's upstream request is
|
|
1597
|
+
// still open (it may re-elicit), and a raw non-tool forward (resources/read, prompts/get -
|
|
1598
|
+
// both legal elicit triggers) holds an open upstream call with NO waiter. Any of those
|
|
1599
|
+
// in-flight -> the elicit's origin is ambiguous -> DECLINE rather than hand one caller's
|
|
1600
|
+
// question to another caller's call.
|
|
1601
|
+
let suspendedHere = false;
|
|
1602
|
+
for (const e of pendingElicits.values()) {
|
|
1603
|
+
if (e.upstreamId === upstreamId) { suspendedHere = true; break; }
|
|
1604
|
+
}
|
|
1605
|
+
const rawInFlight = (inflightRawForwards.get(upstreamId) || 0) > 0;
|
|
1606
|
+
const waiters = ptId === upstreamId ? elicitWaiters.get(upstreamId) : null;
|
|
1607
|
+
if (!waiters || waiters.size !== 1 || suspendedHere || rawInFlight) {
|
|
1608
|
+
// No call to bind to (or an ambiguous set) - decline, so the upstream's held call completes.
|
|
1609
|
+
client.respondToServer(msg.id, { action: 'decline' });
|
|
1610
|
+
logErr('bridge-b: declined elicitation from "' + upstreamId + '" (' +
|
|
1611
|
+
(ptId !== upstreamId ? 'not the wrapped upstream'
|
|
1612
|
+
: suspendedHere ? 'a suspension is already pending'
|
|
1613
|
+
: rawInFlight ? 'a non-tool forward is in flight (ambiguous origin)'
|
|
1614
|
+
: (waiters ? 'ambiguous concurrent calls' : 'no in-flight wrapped call')) + ')');
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const waiter = waiters.values().next().value;
|
|
1618
|
+
waiter.deliver({ elicitId: msg.id, params: msg.params || {}, client });
|
|
1619
|
+
} catch (_e) {
|
|
1620
|
+
try { client.respondToServerError(msg.id, -32603, 'request failed'); } catch (_e2) { /* ignore */ }
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* Race a held upstream promise against an incoming elicitation for that upstream. Registers a
|
|
1626
|
+
* single-use waiter (a SECOND elicit during the same round is auto-declined - one question per
|
|
1627
|
+
* round is what MRTR's retry loop models); always unregisters. Returns { kind:'result'|'elicit',
|
|
1628
|
+
* value }. Rejects if the upstream promise rejects first.
|
|
1629
|
+
*/
|
|
1630
|
+
async function raceElicit(upstreamId, promise) {
|
|
1631
|
+
let resolveElicit;
|
|
1632
|
+
const elicitP = new Promise((res) => { resolveElicit = res; });
|
|
1633
|
+
let delivered = false;
|
|
1634
|
+
const unregister = registerElicitWaiter(upstreamId, {
|
|
1635
|
+
deliver: (e) => {
|
|
1636
|
+
if (delivered) {
|
|
1637
|
+
try { e.client.respondToServer(e.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
delivered = true;
|
|
1641
|
+
resolveElicit(e);
|
|
1642
|
+
},
|
|
1643
|
+
});
|
|
1644
|
+
try {
|
|
1645
|
+
return await Promise.race([
|
|
1646
|
+
promise.then((v) => ({ kind: 'result', value: v })),
|
|
1647
|
+
elicitP.then((e) => ({ kind: 'elicit', value: e })),
|
|
1648
|
+
]);
|
|
1649
|
+
} finally {
|
|
1650
|
+
unregister();
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/** Resume a suspended elicitation (the MRTR retry): answer the upstream's held question with the
|
|
1655
|
+
* client's inputResponse, then await the held call - which may elicit AGAIN (multi-round) or
|
|
1656
|
+
* finish. Token misses get a clean, neutral error. NEVER throws. */
|
|
1657
|
+
async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
|
|
1658
|
+
const p = params || {};
|
|
1659
|
+
const token = typeof p.requestState === 'string' ? p.requestState : null;
|
|
1660
|
+
const entry = token && modernCaller === true ? pendingElicits.get(token) : null;
|
|
1661
|
+
if (!entry) {
|
|
1662
|
+
// Unknown/expired/foreign token - or a legacy caller, which cannot do MRTR at all.
|
|
1663
|
+
return { content: [{ type: 'text', text: 'unknown or expired requestState' }], isError: true };
|
|
1664
|
+
}
|
|
1665
|
+
pendingElicits.delete(token);
|
|
1666
|
+
try { clearTimeout(entry.timer); } catch (_e) { /* ignore */ }
|
|
1667
|
+
// The wrap may have been RETARGETED mid-suspension (A->B). Serving A's result under B's
|
|
1668
|
+
// identity is a mixed-identity response - refuse, and cancel A's held question so its call
|
|
1669
|
+
// resolves.
|
|
1670
|
+
if (currentPtId !== entry.upstreamId) {
|
|
1671
|
+
try { entry.client.respondToServer(entry.elicitId, { action: 'cancel' }); } catch (_e) { /* ignore */ }
|
|
1672
|
+
return { content: [{ type: 'text', text: 'unknown or expired requestState' }], isError: true };
|
|
1673
|
+
}
|
|
1674
|
+
// The resume may block for the held call's remaining work: re-extend its timeout (a resume at
|
|
1675
|
+
// TTL-minus-seconds must not lose the result to the original clock) and make
|
|
1676
|
+
// the RETRY id cancellable against the held upstream call.
|
|
1677
|
+
if (entry.rpcId !== null && typeof entry.client.extendRequestTimeout === 'function') {
|
|
1678
|
+
entry.client.extendRequestTimeout(entry.rpcId, ELICIT_TTL_MS + 30000);
|
|
1679
|
+
}
|
|
1680
|
+
if (cancelKey && entry.rpcId !== null) inflightForwards.set(cancelKey, entry.rpcId);
|
|
1681
|
+
// One question per round (r0) -> one response. The client's InputResponse passes VERBATIM as
|
|
1682
|
+
// the upstream's ElicitResult ({ action, content? }); a malformed response declines - never
|
|
1683
|
+
// invent an "accept" the user did not give.
|
|
1684
|
+
const responses = p.inputResponses && typeof p.inputResponses === 'object' && !Array.isArray(p.inputResponses)
|
|
1685
|
+
? p.inputResponses : {};
|
|
1686
|
+
const keys = Object.keys(responses);
|
|
1687
|
+
const r = keys.length ? responses[keys[0]] : null;
|
|
1688
|
+
const elicitResult = (r && typeof r === 'object' && typeof r.action === 'string') ? r : { action: 'decline' };
|
|
1689
|
+
try { entry.client.respondToServer(entry.elicitId, elicitResult); } catch (_e) { /* ignore */ }
|
|
1690
|
+
let winner;
|
|
1691
|
+
try {
|
|
1692
|
+
winner = await raceElicit(entry.upstreamId, entry.executePromise);
|
|
1693
|
+
} catch (_err) {
|
|
1694
|
+
return { content: [{ type: 'text', text: `tool "${entry.calledName}" failed` }], isError: true };
|
|
1695
|
+
} finally {
|
|
1696
|
+
if (cancelKey) inflightForwards.delete(cancelKey); // settled (or re-suspended) - retry cancel window over
|
|
1697
|
+
}
|
|
1698
|
+
if (winner.kind === 'elicit') {
|
|
1699
|
+
return suspendForElicit(winner.value, entry.executePromise, entry.upstreamId, entry.calledName, entry.rpcId);
|
|
1700
|
+
}
|
|
1701
|
+
const out = winner.value;
|
|
1702
|
+
if (out && typeof out === 'object' && Array.isArray(out.content)) return out; // verbatim envelope
|
|
1703
|
+
return { content: [{ type: 'text', text: `tool "${entry.calledName}" failed` }], isError: true };
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** Suspend a wrapped call that hit an elicitation: mint the token, hold the still-running
|
|
1707
|
+
* execute promise, extend the held request's timeout to the suspension window (a human answers
|
|
1708
|
+
* at human speed), arm the TTL, and shape the modern InputRequiredResult. */
|
|
1709
|
+
function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId) {
|
|
1710
|
+
const token = require('node:crypto').randomBytes(16).toString('base64url');
|
|
1711
|
+
executePromise.catch(() => { /* held in the background - its rejection surfaces on resume */ });
|
|
1712
|
+
if (rpcId !== null && rpcId !== undefined && typeof elicit.client.extendRequestTimeout === 'function') {
|
|
1713
|
+
// +grace beyond the TTL: the TTL path CANCELS the question upstream, and the upstream still
|
|
1714
|
+
// needs time to complete/fail its held call before the waiter finally dies.
|
|
1715
|
+
elicit.client.extendRequestTimeout(rpcId, ELICIT_TTL_MS + 30000);
|
|
1716
|
+
}
|
|
1717
|
+
const entry = {
|
|
1718
|
+
upstreamId,
|
|
1719
|
+
elicitId: elicit.elicitId,
|
|
1720
|
+
client: elicit.client,
|
|
1721
|
+
executePromise,
|
|
1722
|
+
calledName,
|
|
1723
|
+
rpcId: rpcId === undefined ? null : rpcId,
|
|
1724
|
+
createdAt: Date.now(),
|
|
1725
|
+
timer: setTimeout(() => {
|
|
1726
|
+
// Unclaimed suspension: cancel the upstream's question so its held call resolves, drop the
|
|
1727
|
+
// token. A later retry with this token gets the clean unknown-token error.
|
|
1728
|
+
pendingElicits.delete(token);
|
|
1729
|
+
try { elicit.client.respondToServer(elicit.elicitId, { action: 'cancel' }); } catch (_e) { /* ignore */ }
|
|
1730
|
+
logErr('bridge-b: elicitation "' + token.slice(0, 8) + '..." expired unanswered (' + calledName + ')');
|
|
1731
|
+
}, ELICIT_TTL_MS),
|
|
1732
|
+
};
|
|
1733
|
+
if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
1734
|
+
pendingElicits.set(token, entry);
|
|
1735
|
+
// The MRTR result: the upstream's question under a minted key + the resume token. One
|
|
1736
|
+
// translation applies: MRTR's ElicitRequest params REQUIRE `mode:"form"`, which pre-MRTR
|
|
1737
|
+
// legacy upstreams never send - default-inject it when absent (the caller's own
|
|
1738
|
+
// mode, if any, wins). Everything else passes verbatim.
|
|
1739
|
+
const params = Object.assign({ mode: 'form' }, elicit.params);
|
|
1740
|
+
return {
|
|
1741
|
+
resultType: 'input_required',
|
|
1742
|
+
inputRequests: { r0: { method: 'elicitation/create', params } },
|
|
1743
|
+
requestState: token,
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* armWrapChatter - sync the aggregator's wrapChatterUpstream from the on-disk wrap state.
|
|
1749
|
+
* handleMessage re-arms per message, but three paths bypass it: startup (notifications before the
|
|
1750
|
+
* first client message), a reloadExpose swap, and a listen-first modern client (the listen branch
|
|
1751
|
+
* is transport-owned). Each of those calls this so the N3 cross-upstream filter is never unarmed.
|
|
1752
|
+
* NEVER throws.
|
|
1753
|
+
*/
|
|
1754
|
+
function armWrapChatter(build) {
|
|
1755
|
+
try {
|
|
1756
|
+
if (!build || !build.aggregator) return;
|
|
1757
|
+
const statePath = build.toolStatePath;
|
|
1758
|
+
// Route through the aggregator's setter - a passthrough TARGET CHANGE clears the recorded
|
|
1759
|
+
// resource subscriptions (they belong to the old wrap context).
|
|
1760
|
+
const arm = (v) => (typeof build.aggregator.setWrapChatterUpstream === 'function'
|
|
1761
|
+
? build.aggregator.setWrapChatterUpstream(v)
|
|
1762
|
+
: (build.aggregator.wrapChatterUpstream = v));
|
|
1763
|
+
if (!statePath) { arm(null); return; }
|
|
1764
|
+
arm(getPassthrough(loadToolStateResult(statePath).state));
|
|
1765
|
+
// F2 companion: hot-promoted upstream tools put their upstream on the top-level surface with
|
|
1766
|
+
// no expose[] entry - give the aggregator a live predicate so death/recovery of such an
|
|
1767
|
+
// upstream still fires list_changed. Reads the state file fresh (a UI toggle is live), keyed
|
|
1768
|
+
// by the standard `<upstream>_<tool>` surfaced shape; the reserved wrap key is a string, not
|
|
1769
|
+
// a tool entry, so it never matches.
|
|
1770
|
+
build.aggregator.topLevelUpstreamExtra = (upstreamId) => {
|
|
1771
|
+
try {
|
|
1772
|
+
const st = loadToolStateResult(statePath).state || {};
|
|
1773
|
+
const prefix = String(upstreamId) + '_';
|
|
1774
|
+
return Object.keys(st).some((k) =>
|
|
1775
|
+
k !== 'passthrough' && k.startsWith(prefix) && st[k] && st[k].hot === true);
|
|
1776
|
+
} catch (_e) { return false; }
|
|
1777
|
+
};
|
|
1778
|
+
} catch (_e) { /* never let arming break the caller */ }
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** In-flight forwarded requests under a wrap: CLIENT JSON-RPC id -> the McpClient rpc id the
|
|
1782
|
+
* forward was re-issued under. The two id spaces are disjoint, so a client's
|
|
1783
|
+
* notifications/cancelled must be TRANSLATED before relay - relaying its requestId verbatim
|
|
1784
|
+
* would cancel the wrong upstream call. Entries live only for the duration
|
|
1785
|
+
* of the forward (deleted in the finally). Gated tools/call is NOT tracked here - full
|
|
1786
|
+
* id-translation for the tool path arrives with Bridge B's correlation machinery. */
|
|
1787
|
+
const inflightForwards = new Map();
|
|
1788
|
+
|
|
1789
|
+
/** In-flight RAW (non-tool) forwards per upstream id - part of Bridge B's binding invariant: a
|
|
1790
|
+
* resources/read or prompts/get holds an open upstream call that may legally elicit, so while
|
|
1791
|
+
* one is in flight an elicit's origin is ambiguous and must be declined. */
|
|
1792
|
+
const inflightRawForwards = new Map();
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* forwardWrapped - relay a non-tool method to the wrapped upstream and shape the reply for the
|
|
1796
|
+
* caller's era. Strips the modern per-request `_meta` before forwarding (the legacy upstream never
|
|
1797
|
+
* saw modern params); a modern caller's result is decorated (resultType + the WRAPPED server's
|
|
1798
|
+
* serverInfo in `_meta`, never ToolFunnel's - the wrap must stay invisible). A notification (no id)
|
|
1799
|
+
* is dropped (we do not relay unknown client notifications). NEVER throws.
|
|
1800
|
+
*/
|
|
1801
|
+
async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey) {
|
|
1802
|
+
if (!hasId) {
|
|
1803
|
+
// A client NOTIFICATION under a wrap (notifications/cancelled, notifications/progress ...)
|
|
1804
|
+
// must still reach the upstream - a directly-connected server would receive it, and eating
|
|
1805
|
+
// notifications/cancelled leaves the upstream running a call the client abandoned.
|
|
1806
|
+
// Fire-and-forget; notifications are never answered.
|
|
1807
|
+
if (typeof msg.method === 'string' && msg.method.startsWith('notifications/') &&
|
|
1808
|
+
aggregator && typeof aggregator.notifyRaw === 'function') {
|
|
1809
|
+
if (msg.method === 'notifications/cancelled') {
|
|
1810
|
+
// params.requestId lives in the CLIENT's id space; the forward ran under the McpClient's
|
|
1811
|
+
// own id. TRANSLATE via the in-flight map - an untranslatable cancel is DROPPED, because
|
|
1812
|
+
// relaying it verbatim could cancel a DIFFERENT in-flight call. The
|
|
1813
|
+
// map is scoped by connKey: without a connection identity (HTTP - sessionless modern era,
|
|
1814
|
+
// every POST is a stranger) translation is impossible and the cancel is dropped whole.
|
|
1815
|
+
if (connKey !== undefined && msg.params && msg.params.requestId !== undefined) {
|
|
1816
|
+
const rpcId = inflightForwards.get(`${connKey}:${msg.params.requestId}`);
|
|
1817
|
+
if (rpcId !== undefined) {
|
|
1818
|
+
aggregator.notifyRaw(ptId, msg.method, Object.assign({}, msg.params, { requestId: rpcId }));
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1823
|
+
aggregator.notifyRaw(ptId, msg.method, msg.params);
|
|
1824
|
+
}
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
if (!aggregator || typeof aggregator.forwardRaw !== 'function') {
|
|
1828
|
+
// Neutral text - naming the wrap or the upstream id here would announce the funnel.
|
|
1829
|
+
return makeError(id, ERR.INTERNAL, 'request failed');
|
|
1830
|
+
}
|
|
1831
|
+
let params = msg.params;
|
|
1832
|
+
if (params && typeof params === 'object' && !Array.isArray(params) &&
|
|
1833
|
+
params._meta && typeof params._meta === 'object' && !Array.isArray(params._meta)) {
|
|
1834
|
+
// Strip ONLY the modern protocol keys from _meta. The rest - progressToken, trace keys, app
|
|
1835
|
+
// keys - is legitimate in BOTH eras and a direct connection would deliver it verbatim.
|
|
1836
|
+
// (McpClient.request() re-injects the trio, merged, when the upstream itself is modern.)
|
|
1837
|
+
const meta = Object.assign({}, params._meta);
|
|
1838
|
+
let changed = false;
|
|
1839
|
+
for (const k of Object.keys(meta)) {
|
|
1840
|
+
if (k.startsWith('io.modelcontextprotocol/')) { delete meta[k]; changed = true; }
|
|
1841
|
+
}
|
|
1842
|
+
if (changed) {
|
|
1843
|
+
params = Object.assign({}, params);
|
|
1844
|
+
if (Object.keys(meta).length) params._meta = meta; else delete params._meta;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
let raw;
|
|
1848
|
+
// Track the forward's upstream rpc id LIVE (the setter fires synchronously at issue time inside
|
|
1849
|
+
// McpClient._request) so a concurrent notifications/cancelled can be translated (N1). Keyed by
|
|
1850
|
+
// (connection, client id) - raw client ids collide across connections (focused review).
|
|
1851
|
+
const mapKey = connKey !== undefined ? `${connKey}:${id}` : null;
|
|
1852
|
+
const track = mapKey === null ? undefined : { set rpcId(v) { inflightForwards.set(mapKey, v); } };
|
|
1853
|
+
inflightRawForwards.set(ptId, (inflightRawForwards.get(ptId) || 0) + 1); // Bridge B binding invariant
|
|
1854
|
+
try {
|
|
1855
|
+
raw = await aggregator.forwardRaw(ptId, msg.method, params, track);
|
|
1856
|
+
} catch (err) {
|
|
1857
|
+
// Relay the upstream's OWN error verbatim - code, message, data. Re-wrapping loses fidelity
|
|
1858
|
+
// AND announces the wrap (the old text carried `wrapped upstream "<id>"`; one probe of any
|
|
1859
|
+
// unimplemented method exposed the funnel). Only a pure transport failure (no rpcError on
|
|
1860
|
+
// the rejection) gets a synthetic error - neutral: no wrap, no id, no McpClient prefix.
|
|
1861
|
+
if (err && err.rpcError && typeof err.rpcError === 'object') {
|
|
1862
|
+
return makeError(id, err.rpcError.code, err.rpcError.message, err.rpcError.data);
|
|
1863
|
+
}
|
|
1864
|
+
return makeError(id, ERR.INTERNAL, 'request failed');
|
|
1865
|
+
} finally {
|
|
1866
|
+
if (mapKey !== null) inflightForwards.delete(mapKey); // settled - cancels can no longer target it
|
|
1867
|
+
const n = (inflightRawForwards.get(ptId) || 1) - 1;
|
|
1868
|
+
if (n <= 0) inflightRawForwards.delete(ptId); else inflightRawForwards.set(ptId, n);
|
|
1869
|
+
}
|
|
1870
|
+
// A legacy client's DIRECT resources/subscribe rides this forward - record it for replay. The
|
|
1871
|
+
// wrap hides upstream deaths behind silent background reconnects, so it inherits the duty of
|
|
1872
|
+
// re-arming the subscription on the fresh process: a direct
|
|
1873
|
+
// connection's client would have SEEN the death and re-subscribed itself.
|
|
1874
|
+
if (aggregator && aggregator._subscribedUris instanceof Set &&
|
|
1875
|
+
params && typeof params.uri === 'string') {
|
|
1876
|
+
if (msg.method === 'resources/subscribe') aggregator._subscribedUris.add(params.uri);
|
|
1877
|
+
else if (msg.method === 'resources/unsubscribe') aggregator._subscribedUris.delete(params.uri);
|
|
1878
|
+
}
|
|
1879
|
+
// A modern caller needs a modern-shaped result (resultType + serverInfo). Present the WRAPPED
|
|
1880
|
+
// server's serverInfo so the decoration is not a ToolFunnel tell. The CacheableResult surfaces
|
|
1881
|
+
// (resources/list|read, resources/templates/list, prompts/list) MUST carry ttlMs + cacheScope
|
|
1882
|
+
// per spec - the legacy upstream can't supply them, so we add a conservative default
|
|
1883
|
+
if (modern.isModernRequest(msg)) {
|
|
1884
|
+
const si = (wrapId && wrapId.serverInfo) || SERVER_INFO;
|
|
1885
|
+
const cacheHints = CACHEABLE_FORWARD_METHODS.has(msg.method) ? modern.CACHE_HINTS.toolsList : undefined;
|
|
1886
|
+
return makeResult(id, modern.decorateResult(raw, si, cacheHints));
|
|
1887
|
+
}
|
|
1888
|
+
return makeResult(id, raw); // legacy caller - verbatim upstream result
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* handleModernMessage - dispatch ONE request under 2026-07-28 semantics. The tool surface and
|
|
1893
|
+
* the gate are the SAME handlers the legacy path uses (handleToolsList / handleToolsCall) - the
|
|
1894
|
+
* modern era only changes the envelope: validated per-request _meta in, `resultType` +
|
|
1895
|
+
* `_meta[serverInfo]` (+ ttlMs/cacheScope on the CacheableResult surfaces) out. Methods the
|
|
1896
|
+
* modern revision removed (ping, logging/setLevel, the initialized notification) fall through
|
|
1897
|
+
* to method-not-found / ignored, per spec. NEVER throws (the caller's try/catch backstops it).
|
|
1898
|
+
*/
|
|
1899
|
+
async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKey) {
|
|
1900
|
+
const { protocol, aggregator, engine, ctx, registry, toolStatePath } = build;
|
|
1901
|
+
|
|
1902
|
+
const vErr = modern.validateModernRequest(msg);
|
|
1903
|
+
if (vErr) return hasId ? makeError(id, vErr.code, vErr.message, vErr.data) : null;
|
|
1904
|
+
|
|
1905
|
+
// Under a wrap, the serverInfo in every decorated result must be the WRAPPED server's, not
|
|
1906
|
+
// ToolFunnel's (the wrap is invisible). Also latch whether a wrap is active, to suppress the
|
|
1907
|
+
// legacyShim _meta tell (it would reveal the wrapping - the invisibility requirement).
|
|
1908
|
+
const wrapped = !!wrapId;
|
|
1909
|
+
const resultServerInfo = (wrapId && wrapId.serverInfo) || SERVER_INFO;
|
|
1910
|
+
|
|
1911
|
+
switch (method) {
|
|
1912
|
+
case 'tools/list':
|
|
1913
|
+
// A notification (no id) MUST NOT get a reply (JSON-RPC 2.0). Every case guards on hasId.
|
|
1914
|
+
if (!hasId) return null;
|
|
1915
|
+
return makeResult(
|
|
1916
|
+
id,
|
|
1917
|
+
modern.decorateResult(
|
|
1918
|
+
handleToolsList(protocol, aggregator, { registry, toolStatePath }),
|
|
1919
|
+
resultServerInfo,
|
|
1920
|
+
modern.CACHE_HINTS.toolsList
|
|
1921
|
+
)
|
|
1922
|
+
);
|
|
1923
|
+
|
|
1924
|
+
case 'tools/call': {
|
|
1925
|
+
const result = await handleToolsCall(
|
|
1926
|
+
protocol, aggregator,
|
|
1927
|
+
// cancelKey: same cancel-translation contract as the legacy path.
|
|
1928
|
+
// modernCaller: a modern client can receive input_required + retry (Bridge B / MRTR).
|
|
1929
|
+
{ engine, ctx, registry, toolStatePath, modernCaller: true,
|
|
1930
|
+
cancelKey: (connKey !== undefined && hasId) ? `${connKey}:${id}` : null },
|
|
1931
|
+
msg.params
|
|
1932
|
+
);
|
|
1933
|
+
// Same observability chokepoint as the legacy path - both eras count in one place.
|
|
1934
|
+
metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
|
|
1935
|
+
if (!hasId) return null; // notification-shaped call: executed (side effects), but never replied to
|
|
1936
|
+
const decorated = modern.decorateResult(result, resultServerInfo);
|
|
1937
|
+
// Legacy-shim transparency for MODERN clients: when the call ACTUALLY forwarded to a PINNED
|
|
1938
|
+
// legacy upstream, say so in result _meta - metadata, never content. NOT under a wrap: a wrap
|
|
1939
|
+
// must be invisible, and this tag would reveal the wrapping (the invisibility requirement). Gated on
|
|
1940
|
+
// !isError so a PreToolUse deny / unknown-tool error is not falsely tagged as "forwarded".
|
|
1941
|
+
if (!wrapped && aggregator && typeof aggregator.legacyShimInfo === 'function' && result && result.isError !== true) {
|
|
1942
|
+
try {
|
|
1943
|
+
const shim = aggregator.legacyShimInfo(msg.params && msg.params.name);
|
|
1944
|
+
if (shim) decorated._meta['io.toolfunnel/legacyShim'] = shim;
|
|
1945
|
+
} catch (_e) { /* decoration only - never affects the result */ }
|
|
1946
|
+
}
|
|
1947
|
+
return makeResult(id, decorated);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
case 'subscriptions/listen':
|
|
1951
|
+
// Transport-owned: the ack + the notification stream need the transport's writer, and the
|
|
1952
|
+
// final result is only sent at close. Both transports intercept BEFORE handleMessage;
|
|
1953
|
+
// reaching here means a wiring gap - answer honestly rather than hang the client.
|
|
1954
|
+
return hasId
|
|
1955
|
+
? makeError(id, ERR.INTERNAL, 'subscriptions/listen must be handled by the transport')
|
|
1956
|
+
: null;
|
|
1957
|
+
|
|
1958
|
+
default:
|
|
1959
|
+
// ping / logging/setLevel / resources / prompts are removed-or-absent in the modern era of
|
|
1960
|
+
// this tools-only server: unknown request -> method-not-found; unknown notification -> ignored.
|
|
1961
|
+
return hasId ? makeError(id, ERR.METHOD_NOT_FOUND, `Method not found: ${method}`) : null;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* emitToolsListChanged(send) - write the MCP `notifications/tools/list_changed` JSON-RPC
|
|
1967
|
+
* notification (NO id - it is a notification, not a request) via the provided `send` fn. The
|
|
1968
|
+
* server emits this when the curated-direct expose set hot-updates so a CLI that honours
|
|
1969
|
+
* listChanged re-fetches tools/list (see the architecture notes §1 "no restart for register
|
|
1970
|
+
* changes"; §7 the host runs the server). `send` is injected so this is unit-testable with a spy
|
|
1971
|
+
* and reused by both the stdio loop and (later) the host.
|
|
1972
|
+
*
|
|
1973
|
+
* @param {(obj: object) => void} send the transport's writer (stdio loop's send, or a spy)
|
|
1974
|
+
* @returns {void}
|
|
1975
|
+
*/
|
|
1976
|
+
function emitToolsListChanged(send) {
|
|
1977
|
+
if (typeof send !== 'function') return; // never throw - a missing transport is a no-op
|
|
1978
|
+
send({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1982
|
+
// HOT-RELOAD - pick up on-disk config changes with NO restart, NO user intervention.
|
|
1983
|
+
//
|
|
1984
|
+
// The tf_* management tools (and the UI) write expose.json / the hooks files from a SEPARATE
|
|
1985
|
+
// process, so the running gateway's in-memory aggregator + hook engine never saw the change - that
|
|
1986
|
+
// was the root cause of "you have to restart to attach an MCP / change a hook". These reloaders
|
|
1987
|
+
// re-read the on-disk config and SWAP build.aggregator / build.engine IN PLACE. handleMessage reads
|
|
1988
|
+
// the build object fresh on every message, so the next tools/list or tools/call uses the new state.
|
|
1989
|
+
// startConfigWatchers wires them to fs.watch so the gateway heals/updates itself automatically.
|
|
1990
|
+
// Everything here is contracted NEVER to throw - a bad reload keeps the last-good state.
|
|
1991
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* Re-read expose.json, rebuild + reconnect the aggregator, swap it into `build`, then emit
|
|
1995
|
+
* notifications/tools/list_changed so a listChanged-honouring client (Claude Code does) refreshes
|
|
1996
|
+
* its curated-direct surface. The new aggregator is connected BEFORE the old one is closed so there
|
|
1997
|
+
* is never a window with no upstreams. NEVER throws.
|
|
1998
|
+
*
|
|
1999
|
+
* @param {{aggregator?:object}} build the live build object (mutated in place)
|
|
2000
|
+
* @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
|
|
2001
|
+
* @returns {Promise<void>}
|
|
2002
|
+
*/
|
|
2003
|
+
async function reloadExpose(build, send) {
|
|
2004
|
+
// NEVER-throws contract. A null/absent build means the caller (e.g. the HTTP host mid-teardown,
|
|
2005
|
+
// which nulls its build) has moved on - do nothing rather than deref null. (reloadHooks/
|
|
2006
|
+
// reloadRegister already guard this; expose was the one unguarded reloader - a proven crash.)
|
|
2007
|
+
if (!build || typeof build !== 'object') return;
|
|
2008
|
+
let store;
|
|
2009
|
+
try {
|
|
2010
|
+
store = loadExposeStore(EXPOSE_PATH);
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
logErr('reloadExpose: cannot read expose.json (keeping current upstreams):', (err && err.message) || String(err));
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
const next = new Aggregator({
|
|
2016
|
+
store, v3Root: ROOT,
|
|
2017
|
+
wrapTargetProvider: () => {
|
|
2018
|
+
try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state); } catch (_e) { return null; }
|
|
2019
|
+
},
|
|
2020
|
+
// Outbound identity per upstream: captured wrap mirror > configured clientName > built-in.
|
|
2021
|
+
clientInfoProvider: clientInfoFor,
|
|
2022
|
+
});
|
|
2023
|
+
next.onToolsChanged = () => emitToolsListChanged(send); // re-wire the background-reconnect -> list_changed signal
|
|
2024
|
+
// Identity-continuity invalidation: the lastWrappedIdentity cache exists to bridge
|
|
2025
|
+
// RECONNECT windows of the SAME server - but if this reload REPLACED an upstream entry (same id,
|
|
2026
|
+
// different command/args/env) or removed it, serving the previous server's identity during the
|
|
2027
|
+
// new one's connect window presents the WRONG server. Drop cache entries whose upstream config
|
|
2028
|
+
// changed; a pure reconnect (config untouched) keeps its bridge.
|
|
2029
|
+
try {
|
|
2030
|
+
const prevStore = build.aggregator && build.aggregator._store;
|
|
2031
|
+
for (const cachedId of Array.from(lastWrappedIdentity.keys())) {
|
|
2032
|
+
const before = prevStore && typeof prevStore.getUpstream === 'function' ? prevStore.getUpstream(cachedId) : null;
|
|
2033
|
+
const after = store && typeof store.getUpstream === 'function' ? store.getUpstream(cachedId) : null;
|
|
2034
|
+
const shape = (u) => u ? JSON.stringify({ c: u.command, a: u.args, e: u.env }) : null;
|
|
2035
|
+
if (!after || shape(before) !== shape(after)) lastWrappedIdentity.delete(cachedId);
|
|
2036
|
+
}
|
|
2037
|
+
} catch (_e) { /* invalidation is best-effort; a stale entry self-heals on connect */ }
|
|
2038
|
+
// Carry the notification bridge to the new instance - the hook closes over the TRANSPORT
|
|
2039
|
+
// (loop.notify / pushToListeners), not the aggregator, so the function transfers verbatim.
|
|
2040
|
+
// Without this every expose.json hot-reload (including a live tf_mcp_add) silently killed
|
|
2041
|
+
// Bridge A.
|
|
2042
|
+
const prevAgg = build.aggregator;
|
|
2043
|
+
if (prevAgg && typeof prevAgg.onUpstreamNotification === 'function') {
|
|
2044
|
+
next.onUpstreamNotification = prevAgg.onUpstreamNotification;
|
|
2045
|
+
}
|
|
2046
|
+
// Bridge B carryover - same contract as the notification bridge above: the handler closes over
|
|
2047
|
+
// the stable build object, so it transfers verbatim onto the swapped-in aggregator.
|
|
2048
|
+
if (prevAgg && typeof prevAgg.onUpstreamServerRequest === 'function') {
|
|
2049
|
+
next.onUpstreamServerRequest = prevAgg.onUpstreamServerRequest;
|
|
2050
|
+
}
|
|
2051
|
+
// Arm the wrap scope on the NEW aggregator BEFORE connectAll - _connectOne's subscription
|
|
2052
|
+
// replay checks wrapChatterUpstream, and an unarmed (null) scope replayed the URIs onto EVERY
|
|
2053
|
+
// subscribe-capable upstream during the reload window. Must precede the
|
|
2054
|
+
// carryover below: arming through the setter clears the set on a target change.
|
|
2055
|
+
armWrapChatter({ aggregator: next, toolStatePath: build.toolStatePath });
|
|
2056
|
+
// Carry the agreed resource-subscription set BEFORE connectAll, so the new aggregator's
|
|
2057
|
+
// _connectOne replays them onto the fresh upstream processes (a hot reload used
|
|
2058
|
+
// to silently kill every agreed subscription channel).
|
|
2059
|
+
if (prevAgg && prevAgg._subscribedUris instanceof Set && prevAgg._subscribedUris.size) {
|
|
2060
|
+
next._subscribedUris = new Set(prevAgg._subscribedUris);
|
|
2061
|
+
}
|
|
2062
|
+
let res = null;
|
|
2063
|
+
try {
|
|
2064
|
+
res = await next.connectAll();
|
|
2065
|
+
if (res && Array.isArray(res.failed) && res.failed.length > 0) {
|
|
2066
|
+
logErr('reloadExpose: ' + res.failed.length + ' upstream(s) failed:',
|
|
2067
|
+
res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
|
|
2068
|
+
}
|
|
2069
|
+
} catch (err) {
|
|
2070
|
+
logErr('reloadExpose: connectAll threw (ignored):', (err && err.message) || String(err));
|
|
2071
|
+
}
|
|
2072
|
+
// The swap/close/notify is guarded too: even a surprise (a build torn down DURING the async
|
|
2073
|
+
// connectAll above) must be a caught no-op, not an escaped rejection that crashes the host.
|
|
2074
|
+
try {
|
|
2075
|
+
const prev = build.aggregator;
|
|
2076
|
+
build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
|
|
2077
|
+
armWrapChatter(build); // the fresh aggregator's N3 filter must not wait for a client message
|
|
2078
|
+
if (prev && typeof prev.closeAll === 'function') {
|
|
2079
|
+
try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
|
|
2080
|
+
}
|
|
2081
|
+
emitToolsListChanged(send);
|
|
2082
|
+
} catch (err) {
|
|
2083
|
+
logErr('reloadExpose: swap failed (keeping last-good):', (err && err.message) || String(err));
|
|
2084
|
+
try { await next.closeAll(); } catch (_e) { /* orphan-avoidance; never throw */ }
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
// Activity log (self-gating): mark the reload trigger; the per-upstream connect/disconnect lines
|
|
2088
|
+
// (from the aggregator) carry the detail. Together they record the reconnect.
|
|
2089
|
+
logger.log({
|
|
2090
|
+
type: 'mcp',
|
|
2091
|
+
event: 'reload',
|
|
2092
|
+
connected: res && Array.isArray(res.connected) ? res.connected : [],
|
|
2093
|
+
failed: res && Array.isArray(res.failed) ? res.failed.map((f) => f.id) : [],
|
|
2094
|
+
});
|
|
2095
|
+
logErr('reloadExpose: expose.json reloaded - upstreams reconnected');
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/**
|
|
2099
|
+
* Re-read tools.register.json and swap the ONE live Registry's index IN PLACE (Registry.reload -
|
|
2100
|
+
* last-good on any failure). In-place mutation is the point: the Registry instance is CAPTURED by
|
|
2101
|
+
* the protocol adapter at build time, so swapping internals means every reader - the lean
|
|
2102
|
+
* meta-tools, run_tool resolution, and the top-level hot surface - sees the new entries on its
|
|
2103
|
+
* next call with no rewiring. Emits list_changed: a hot-promoted local tool's definition may have
|
|
2104
|
+
* appeared, changed, or gone. NEVER throws.
|
|
2105
|
+
*
|
|
2106
|
+
* @param {{registry?:object}} build
|
|
2107
|
+
* @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
|
|
2108
|
+
* @returns {void}
|
|
2109
|
+
*/
|
|
2110
|
+
function reloadRegister(build, send) {
|
|
2111
|
+
try {
|
|
2112
|
+
if (build && build.registry && typeof build.registry.reload === 'function' && build.registry.reload()) {
|
|
2113
|
+
emitToolsListChanged(send);
|
|
2114
|
+
logErr('reloadRegister: tools.register.json reloaded');
|
|
2115
|
+
} else {
|
|
2116
|
+
logErr('reloadRegister: reload failed (bad JSON / invalid entry / mid-write?) - keeping the last-good register');
|
|
2117
|
+
}
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
logErr('reloadRegister: unexpected failure (keeping current register):', (err && err.message) || String(err));
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Re-read the hooks manifest + state overlay and swap a fresh HookEngine into `build`. The next
|
|
2125
|
+
* gated call (toolfunnel_run_tool / curated-direct) uses the new engine, so an added/changed/toggled
|
|
2126
|
+
* hook takes effect with no restart. NEVER throws (a bad manifest keeps the current engine).
|
|
2127
|
+
*
|
|
2128
|
+
* @param {{engine?:object}} build
|
|
2129
|
+
* @returns {void}
|
|
2130
|
+
*/
|
|
2131
|
+
function reloadHooks(build) {
|
|
2132
|
+
try {
|
|
2133
|
+
const loader = loadManifest(MANIFEST_PATH);
|
|
2134
|
+
build.engine = new HookEngine(loader, { cwd: ROOT });
|
|
2135
|
+
logErr('reloadHooks: hooks manifest/state reloaded');
|
|
2136
|
+
} catch (err) {
|
|
2137
|
+
logErr('reloadHooks: cannot reload hooks (keeping current engine):', (err && err.message) || String(err));
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/**
|
|
2142
|
+
* Watch the config and hot-reload on change. TWO mechanisms, belt-and-braces:
|
|
2143
|
+
* 1. fs.watch on the DIRECTORIES - fast, event-driven, filtered by basename.
|
|
2144
|
+
* 2. fs.watchFile POLLING on each specific config file - the reliable fallback.
|
|
2145
|
+
* The directory watch alone is NOT enough: fs.watch is event-based and on Windows frequently
|
|
2146
|
+
* MISSES the atomic temp+rename writes the stores use (the target's inode is replaced without a
|
|
2147
|
+
* usable change event on the directory), so a `tf_hook_set` / UI toggle could fail to hot-reload
|
|
2148
|
+
* until restart. fs.watchFile polls each file's stat, so it catches every create/modify (including
|
|
2149
|
+
* atomic rename) on every platform. Both paths feed the SAME debounced schedulers, so a change
|
|
2150
|
+
* that trips both still collapses to one reload. Debounced (a temp+rename burst -> one reload),
|
|
2151
|
+
* with a re-entrancy guard so a mid-flight expose reload can't overlap itself. A watch failure (an
|
|
2152
|
+
* fs that supports neither) is logged and ignored - the server still runs, just without
|
|
2153
|
+
* auto-reload. NEVER throws.
|
|
2154
|
+
*
|
|
2155
|
+
* @param {object} build the live build object (mutated by the reloaders)
|
|
2156
|
+
* @param {(obj:object)=>void} send the stdio writer
|
|
2157
|
+
* @returns {void}
|
|
2158
|
+
*/
|
|
2159
|
+
function startConfigWatchers(buildArg, send) {
|
|
2160
|
+
// buildArg may be the build OBJECT (stdio: one stable object, mutated in place) OR a GETTER
|
|
2161
|
+
// () => build. The HTTP host REBUILDS its build (reload()/stop() reassign it), so it passes a
|
|
2162
|
+
// getter - the reloaders then always target the LIVE build and a rebuild can never desync the
|
|
2163
|
+
// watchers. Backward-compatible: a plain object is wrapped in a getter, so stdio is unchanged.
|
|
2164
|
+
const getBuild = typeof buildArg === 'function' ? buildArg : () => buildArg;
|
|
2165
|
+
const DEBOUNCE_MS = 150;
|
|
2166
|
+
const mcpDir = path.dirname(EXPOSE_PATH);
|
|
2167
|
+
const hooksDir = path.dirname(MANIFEST_PATH);
|
|
2168
|
+
const openWatchers = []; // fs.watch handles, closed by the returned stop()
|
|
2169
|
+
const polledFiles = []; // fs.watchFile paths, unwatched by the returned stop()
|
|
2170
|
+
|
|
2171
|
+
let exposeTimer = null;
|
|
2172
|
+
let reloadingExpose = false;
|
|
2173
|
+
function scheduleExpose() {
|
|
2174
|
+
if (exposeTimer) clearTimeout(exposeTimer);
|
|
2175
|
+
exposeTimer = setTimeout(async () => {
|
|
2176
|
+
exposeTimer = null;
|
|
2177
|
+
if (reloadingExpose) { scheduleExpose(); return; } // a reload is mid-flight; re-arm
|
|
2178
|
+
reloadingExpose = true;
|
|
2179
|
+
// reloadExpose is contracted never to throw, but a debounced setTimeout callback has no
|
|
2180
|
+
// caller to catch an escaped rejection - so guard here too (an unhandled rejection would
|
|
2181
|
+
// crash the host on default Node). Belt and braces.
|
|
2182
|
+
try { await reloadExpose(getBuild(), send); }
|
|
2183
|
+
catch (e) { logErr('scheduleExpose: reload rejected (should not):', (e && e.message) || String(e)); }
|
|
2184
|
+
finally { reloadingExpose = false; }
|
|
2185
|
+
}, DEBOUNCE_MS);
|
|
2186
|
+
if (exposeTimer && typeof exposeTimer.unref === 'function') exposeTimer.unref(); // don't pin the loop
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
let hooksTimer = null;
|
|
2190
|
+
function scheduleHooks() {
|
|
2191
|
+
if (hooksTimer) clearTimeout(hooksTimer);
|
|
2192
|
+
hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(getBuild()); }, DEBOUNCE_MS);
|
|
2193
|
+
if (hooksTimer && typeof hooksTimer.unref === 'function') hooksTimer.unref();
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
let registerTimer = null;
|
|
2197
|
+
function scheduleRegister() {
|
|
2198
|
+
if (registerTimer) clearTimeout(registerTimer);
|
|
2199
|
+
registerTimer = setTimeout(() => { registerTimer = null; reloadRegister(getBuild(), send); }, DEBOUNCE_MS);
|
|
2200
|
+
if (registerTimer && typeof registerTimer.unref === 'function') registerTimer.unref();
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// tools.state.json needs NO reload (it is read fresh per tools/list & tools/call) - but a
|
|
2204
|
+
// hot/hidden/enabled toggle CHANGES the top-level surface, so a connected client must be told
|
|
2205
|
+
// to re-fetch. Notification only.
|
|
2206
|
+
let stateTimer = null;
|
|
2207
|
+
function scheduleStateNotify() {
|
|
2208
|
+
if (stateTimer) clearTimeout(stateTimer);
|
|
2209
|
+
stateTimer = setTimeout(() => {
|
|
2210
|
+
stateTimer = null;
|
|
2211
|
+
// A LIVE wrap-set may target an upstream this process refused at boot (path-isolation -
|
|
2212
|
+
// the wrap exemption is read per CONNECT, not retroactively). Kick a background connect
|
|
2213
|
+
// for the wrap target if it isn't connected; fire-and-forget, NEVER throws (the
|
|
2214
|
+
// transparent-wrapper ruling).
|
|
2215
|
+
try {
|
|
2216
|
+
const b = getBuild();
|
|
2217
|
+
const agg = b && b.aggregator;
|
|
2218
|
+
const ptId = b && b.toolStatePath
|
|
2219
|
+
? getPassthrough(loadToolStateResult(b.toolStatePath).state) : null;
|
|
2220
|
+
if (agg && ptId && typeof agg.ensureConnected === 'function') {
|
|
2221
|
+
Promise.resolve(agg.ensureConnected(ptId)).catch(() => { /* background; reconnect owns retries */ });
|
|
2222
|
+
}
|
|
2223
|
+
} catch (_e) { /* a state-notify must never die on the kick */ }
|
|
2224
|
+
emitToolsListChanged(send);
|
|
2225
|
+
}, DEBOUNCE_MS);
|
|
2226
|
+
if (stateTimer && typeof stateTimer.unref === 'function') stateTimer.unref();
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
try {
|
|
2230
|
+
const w = fs.watch(mcpDir, (_event, filename) => {
|
|
2231
|
+
// null filename (some platforms don't report it) -> reload to be safe; else only expose.json.
|
|
2232
|
+
if (!filename || path.basename(String(filename)) === 'expose.json') scheduleExpose();
|
|
2233
|
+
});
|
|
2234
|
+
// unref so an FSWatcher never keeps the process alive after the client closes stdin - the
|
|
2235
|
+
// gateway must still exit naturally when its transport closes (it did before this feature).
|
|
2236
|
+
if (w && typeof w.unref === 'function') w.unref();
|
|
2237
|
+
openWatchers.push(w);
|
|
2238
|
+
logErr('startConfigWatchers: watching', mcpDir, 'for expose.json changes');
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
logErr('startConfigWatchers: cannot watch', mcpDir + ':', (err && err.message) || String(err));
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
try {
|
|
2244
|
+
const w = fs.watch(hooksDir, (_event, filename) => {
|
|
2245
|
+
const base = filename ? path.basename(String(filename)) : '';
|
|
2246
|
+
if (!filename || base === 'hooks.manifest.json' || base === 'hooks.state.json') scheduleHooks();
|
|
2247
|
+
});
|
|
2248
|
+
if (w && typeof w.unref === 'function') w.unref();
|
|
2249
|
+
openWatchers.push(w);
|
|
2250
|
+
logErr('startConfigWatchers: watching', hooksDir, 'for hook changes');
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
logErr('startConfigWatchers: cannot watch', hooksDir + ':', (err && err.message) || String(err));
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// The register + state overlay live in tools/. Register edits (tf_tool_add from its child
|
|
2256
|
+
// process, the UI, a hand edit) were the ONE config surface with no watcher - a running gateway
|
|
2257
|
+
// served its startup snapshot until restart, which broke the "add a tool live" story the moment
|
|
2258
|
+
// the server outlived the edit. State toggles only need the list_changed nudge (read-fresh).
|
|
2259
|
+
const toolsDir = path.dirname(REGISTER_PATH);
|
|
2260
|
+
try {
|
|
2261
|
+
const w = fs.watch(toolsDir, (_event, filename) => {
|
|
2262
|
+
const base = filename ? path.basename(String(filename)) : '';
|
|
2263
|
+
if (!filename || base === 'tools.register.json') scheduleRegister();
|
|
2264
|
+
if (!filename || base === 'tools.state.json') scheduleStateNotify();
|
|
2265
|
+
});
|
|
2266
|
+
if (w && typeof w.unref === 'function') w.unref();
|
|
2267
|
+
openWatchers.push(w);
|
|
2268
|
+
logErr('startConfigWatchers: watching', toolsDir, 'for register/state changes');
|
|
2269
|
+
} catch (err) {
|
|
2270
|
+
logErr('startConfigWatchers: cannot watch', toolsDir + ':', (err && err.message) || String(err));
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// ── Reliable fallback: fs.watchFile POLLS each config file's stat, so it catches every
|
|
2274
|
+
// create/modify - including the atomic temp+rename writes that fs.watch misses on Windows.
|
|
2275
|
+
// A 1s interval is ample for config (no sub-second reload need); the schedulers debounce, so a
|
|
2276
|
+
// change caught by BOTH the directory watch and the file poll still yields a single reload.
|
|
2277
|
+
// The state overlays (hooks.state.json / tools.state.json) may not exist yet - watchFile fires
|
|
2278
|
+
// when they first appear (a tf_hook_set / tf_tool_set toggle CREATES them), which is the case
|
|
2279
|
+
// the directory watch was dropping.
|
|
2280
|
+
const pollWatch = (file, schedule) => {
|
|
2281
|
+
try {
|
|
2282
|
+
// Retain the listener ref so stop() can unwatchFile(file, listener) - a bare
|
|
2283
|
+
// unwatchFile(file) would remove ALL listeners on that path, clobbering a second host/watcher
|
|
2284
|
+
// set in the same process (parallel tests, an embedder running two hosts).
|
|
2285
|
+
const listener = (curr, prev) => {
|
|
2286
|
+
if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) schedule();
|
|
2287
|
+
};
|
|
2288
|
+
const sw = fs.watchFile(file, { interval: 1000 }, listener);
|
|
2289
|
+
if (sw && typeof sw.unref === 'function') sw.unref(); // never pin the event loop
|
|
2290
|
+
polledFiles.push({ file, listener });
|
|
2291
|
+
} catch (err) {
|
|
2292
|
+
logErr('startConfigWatchers: cannot poll-watch', file + ':', (err && err.message) || String(err));
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
2295
|
+
pollWatch(EXPOSE_PATH, scheduleExpose);
|
|
2296
|
+
pollWatch(MANIFEST_PATH, scheduleHooks);
|
|
2297
|
+
pollWatch(path.join(hooksDir, 'hooks.state.json'), scheduleHooks);
|
|
2298
|
+
pollWatch(REGISTER_PATH, scheduleRegister);
|
|
2299
|
+
pollWatch(path.join(toolsDir, 'tools.state.json'), scheduleStateNotify);
|
|
2300
|
+
logErr('startConfigWatchers: fs.watchFile polling fallback armed (Windows-safe)');
|
|
2301
|
+
|
|
2302
|
+
// Teardown handle. The stdio path never calls it (the process dies when stdin closes and the
|
|
2303
|
+
// watchers are all unref'd); the HTTP host calls it in stop() so a started->stopped host leaves
|
|
2304
|
+
// no dangling directory watchers or file pollers.
|
|
2305
|
+
return function stopConfigWatchers() {
|
|
2306
|
+
// Clear any PENDING debounced reload so a change caught just before teardown can't fire a
|
|
2307
|
+
// reload against a torn-down build after the caller has moved on. (It would be a caught no-op,
|
|
2308
|
+
// but a truly-stopped watcher must not schedule work.)
|
|
2309
|
+
for (const t of [exposeTimer, hooksTimer, registerTimer, stateTimer]) {
|
|
2310
|
+
if (t) { try { clearTimeout(t); } catch (_e) { /* ignore */ } }
|
|
2311
|
+
}
|
|
2312
|
+
for (const w of openWatchers) {
|
|
2313
|
+
try { if (w && typeof w.close === 'function') w.close(); } catch (_e) { /* ignore */ }
|
|
2314
|
+
}
|
|
2315
|
+
for (const p of polledFiles) {
|
|
2316
|
+
try { fs.unwatchFile(p.file, p.listener); } catch (_e) { /* ignore */ }
|
|
2317
|
+
}
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
2322
|
+
// stdin framing reader - accepts BOTH Content-Length framing and newline-delimited JSON.
|
|
2323
|
+
// We write newline-delimited JSON on stdout.
|
|
2324
|
+
//
|
|
2325
|
+
// `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
|
|
2326
|
+
// accepted too - see normaliseBuild). It is threaded straight into handleMessage so tools/list
|
|
2327
|
+
// advertises the curated-direct surface and tools/call gates curated-direct calls.
|
|
2328
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
2329
|
+
function createStdioLoop(build) {
|
|
2330
|
+
let buf = Buffer.alloc(0);
|
|
2331
|
+
// Serialise message handling so responses are written in request order even though handlers
|
|
2332
|
+
// are async (avoids interleaving partial writes on stdout).
|
|
2333
|
+
let chain = Promise.resolve();
|
|
2334
|
+
|
|
2335
|
+
// Requests waiting on (or running in) the chain, keyed by client id - the pre-cancellation
|
|
2336
|
+
// registry. A notifications/cancelled processed out-of-band marks a still-queued
|
|
2337
|
+
// target here; its chain link then skips processing and answers nothing (per spec). One client
|
|
2338
|
+
// per pipe, so raw client ids are unique keys.
|
|
2339
|
+
const queuedRequests = new Map();
|
|
2340
|
+
|
|
2341
|
+
// Modern subscriptions/listen registrations. stdio has ONE client, so era-correct notification
|
|
2342
|
+
// delivery is: any active modern subscription -> tagged copies only (the client declared itself
|
|
2343
|
+
// modern by subscribing); none -> the raw legacy notification, byte-for-byte the 0.5.0 shape.
|
|
2344
|
+
const modernSubs = [];
|
|
2345
|
+
|
|
2346
|
+
// Era latch: once the pipe's client has sent ONE modern-_meta request, it is a modern client -
|
|
2347
|
+
// and a modern client that opened no listen stream gets NOTHING unsolicited (in its era nothing
|
|
2348
|
+
// arrives outside a subscription). Only a never-modern (legacy) pipe gets raw notifications.
|
|
2349
|
+
let sawModern = false;
|
|
2350
|
+
|
|
2351
|
+
function send(obj) {
|
|
2352
|
+
if (obj == null) return;
|
|
2353
|
+
let line;
|
|
2354
|
+
try {
|
|
2355
|
+
line = JSON.stringify(obj);
|
|
2356
|
+
} catch (_e) {
|
|
2357
|
+
line = JSON.stringify(makeError(null, ERR.INTERNAL, 'failed to serialise response'));
|
|
2358
|
+
}
|
|
2359
|
+
process.stdout.write(line + '\n');
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* Era-aware notification writer for the hot-reload watchers + the aggregator's tools-changed
|
|
2364
|
+
* signal. tools/list_changed fans out to active modern subscriptions (tagged, filtered on the
|
|
2365
|
+
* agreed toolsListChanged) or falls back to the raw legacy notification; anything else passes
|
|
2366
|
+
* straight through to send().
|
|
2367
|
+
*/
|
|
2368
|
+
function notify(n) {
|
|
2369
|
+
if (!n || typeof n.method !== 'string') return;
|
|
2370
|
+
// Era rule: NO modern subscriber -> raw notification (a legacy client on the pipe gets the legacy
|
|
2371
|
+
// shape - tools/list_changed, resources/updated, etc.). At least one modern subscriber -> tagged
|
|
2372
|
+
// copies ONLY to those whose AGREED filter includes this notification's channel; a modern client
|
|
2373
|
+
// that declined a channel must NOT get it (the ack's agreed
|
|
2374
|
+
// subset is the contract). Bridges upstream change-notifications (resources/prompts/tools) too.
|
|
2375
|
+
if (modernSubs.length === 0) return sawModern ? undefined : send(n);
|
|
2376
|
+
for (const s of modernSubs) {
|
|
2377
|
+
if (modern.notificationMatchesFilter(n, s.agreed)) send(modern.tagNotification(n, s.id));
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function enqueue(msg) {
|
|
2382
|
+
// Era latch - only a VALID modern request latches (an otherwise-legacy client that once sent
|
|
2383
|
+
// a malformed _meta must not lose its raw notifications forever). initialize
|
|
2384
|
+
// never latches: routing serves it as LEGACY regardless of decoration (a dual-era client that
|
|
2385
|
+
// stamps its trio on the handshake would otherwise lose raw notifications for the pipe's
|
|
2386
|
+
// lifetime while being served legacy semantics).
|
|
2387
|
+
if (msg && msg.method !== 'initialize' &&
|
|
2388
|
+
modern.isModernRequest(msg) && !modern.validateModernRequest(msg)) sawModern = true;
|
|
2389
|
+
|
|
2390
|
+
// notifications/cancelled leaves the chain - chaining it queued a pipelined
|
|
2391
|
+
// cancel BEHIND the very forward it was trying to cancel: by the time it ran, the forward
|
|
2392
|
+
// had settled and the in-flight entry was gone (the whole translation machinery was
|
|
2393
|
+
// unreachable on the real wire; live-repro'd). A cancel produces no response, so immediate
|
|
2394
|
+
// processing cannot disorder the response stream. ONLY cancels jump the queue: cancellation
|
|
2395
|
+
// is the one notification with hard timing semantics - everything else stays chained so the
|
|
2396
|
+
// upstream sees the client's wire order untouched.
|
|
2397
|
+
const hasId = msg && msg.id !== undefined && msg.id !== null;
|
|
2398
|
+
if (msg && msg.method === 'notifications/cancelled' && !hasId) {
|
|
2399
|
+
// A cancel whose target request is still QUEUED (its chain link not started) marks it:
|
|
2400
|
+
// the link skips processing and sends NO reply (spec: a cancelled request is never
|
|
2401
|
+
// answered). Nothing is relayed upstream - the forward never started, so the upstream
|
|
2402
|
+
// never saw the request. `started` closes the mid-processing race: once the link runs,
|
|
2403
|
+
// the cancel falls through to handleMessage, whose in-flight map translates + relays.
|
|
2404
|
+
if (msg.params && msg.params.requestId !== undefined) {
|
|
2405
|
+
const queued = queuedRequests.get(msg.params.requestId);
|
|
2406
|
+
if (queued) {
|
|
2407
|
+
// Started or not: a cancelled request receives NO response (spec). For a STARTED
|
|
2408
|
+
// forward the upstream goes silent after the translated cancel - our own eventual
|
|
2409
|
+
// settle/timeout envelope must not become a response to the cancelled id
|
|
2410
|
+
//the send site drops it via this flag.
|
|
2411
|
+
queued.cancelled = true;
|
|
2412
|
+
if (!queued.started) return; // never started -> nothing was relayed; nothing to translate
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
Promise.resolve(handleMessage(build, msg, 'stdio'))
|
|
2416
|
+
.then((r) => { if (r != null) send(r); }) // a cancel answers nothing; belt-and-braces
|
|
2417
|
+
.catch((err) => logErr('out-of-band cancel failed:', (err && err.message) || String(err)));
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// Requests register for pre-cancellation while they wait their turn on the chain. The listen
|
|
2422
|
+
// request is transport-owned + long-lived (its "reply" is a stream) - cancel does not apply.
|
|
2423
|
+
const isListen = msg && msg.method === 'subscriptions/listen' && modern.isModernRequest(msg);
|
|
2424
|
+
const reg = (hasId && !isListen) ? { started: false, cancelled: false } : null;
|
|
2425
|
+
if (reg) queuedRequests.set(msg.id, reg);
|
|
2426
|
+
|
|
2427
|
+
chain = chain.then(async () => {
|
|
2428
|
+
if (reg) {
|
|
2429
|
+
reg.started = true;
|
|
2430
|
+
if (reg.cancelled) { queuedRequests.delete(msg.id); return; } // pre-cancelled: no reply (spec)
|
|
2431
|
+
}
|
|
2432
|
+
try {
|
|
2433
|
+
// subscriptions/listen (modern) is TRANSPORT-owned: the ack and every later notification
|
|
2434
|
+
// ride this same stdout pipe, and the final result is written when stdin closes (see the
|
|
2435
|
+
// 'end' handler). Everything else routes through handleMessage unchanged.
|
|
2436
|
+
if (msg && msg.method === 'subscriptions/listen' && modern.isModernRequest(msg)) {
|
|
2437
|
+
const vErr = modern.validateModernRequest(msg);
|
|
2438
|
+
if (vErr) {
|
|
2439
|
+
if (hasId) send(makeError(msg.id, vErr.code, vErr.message, vErr.data));
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
if (!hasId) return; // a listen with no id can never be acknowledged or closed - ignore
|
|
2443
|
+
armWrapChatter(build); // a listen-FIRST client bypasses handleMessage's per-message arming
|
|
2444
|
+
const { agreed } = modern.normaliseListenFilter(msg.params);
|
|
2445
|
+
// resourceSubscriptions is only agreed when some upstream can DELIVER per-URI updates
|
|
2446
|
+
// (modern era emits spontaneously; a subscribe-capable legacy upstream gets the
|
|
2447
|
+
// subscribes forwarded). An ack must never promise a dead channel.
|
|
2448
|
+
if (agreed.resourceSubscriptions) {
|
|
2449
|
+
const agg = build && build.aggregator;
|
|
2450
|
+
if (agg && typeof agg.canHonourResourceSubscriptions === 'function' && agg.canHonourResourceSubscriptions()) {
|
|
2451
|
+
agg.subscribeResources(agreed.resourceSubscriptions);
|
|
2452
|
+
} else {
|
|
2453
|
+
delete agreed.resourceSubscriptions;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
// Replace-on-same-id: a client re-sending listen with the same id (a retry after a missed
|
|
2457
|
+
// ack) must not accumulate a duplicate registration -> doubled notifications forever
|
|
2458
|
+
//Overwrite the existing entry; otherwise append.
|
|
2459
|
+
const existing = modernSubs.findIndex((s) => s.id === msg.id);
|
|
2460
|
+
if (existing !== -1) modernSubs[existing] = { id: msg.id, agreed };
|
|
2461
|
+
else modernSubs.push({ id: msg.id, agreed });
|
|
2462
|
+
send(modern.listenAck(msg.id, agreed));
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
// 'stdio' - the pipe has exactly ONE client for the process lifetime, so a constant
|
|
2466
|
+
// connection key is a true identity (enables safe cancel translation under a wrap).
|
|
2467
|
+
const response = await handleMessage(build, msg, 'stdio');
|
|
2468
|
+
// Cancelled while in flight -> the client gets NO response for that id (spec); whatever
|
|
2469
|
+
// settled - the upstream's late result or our timeout envelope - is dropped here
|
|
2470
|
+
if (reg && reg.cancelled) return;
|
|
2471
|
+
send(response);
|
|
2472
|
+
} finally {
|
|
2473
|
+
if (reg) queuedRequests.delete(msg.id); // settled (or skipped) - pre-cancel can no longer apply
|
|
2474
|
+
}
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// Try to pull one Content-Length-framed message off the front of `buf`. Returns true if it
|
|
2479
|
+
// consumed one (and enqueued it), false if there isn't a complete header-framed message yet.
|
|
2480
|
+
function tryHeaderFramed() {
|
|
2481
|
+
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
2482
|
+
if (headerEnd === -1) return false;
|
|
2483
|
+
const header = buf.slice(0, headerEnd).toString('utf8');
|
|
2484
|
+
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
|
2485
|
+
if (!m) return false; // header block without a Content-Length -> not this framing
|
|
2486
|
+
const len = parseInt(m[1], 10);
|
|
2487
|
+
const bodyStart = headerEnd + 4;
|
|
2488
|
+
if (buf.length < bodyStart + len) return false; // body not all here yet
|
|
2489
|
+
const body = buf.slice(bodyStart, bodyStart + len).toString('utf8');
|
|
2490
|
+
buf = buf.slice(bodyStart + len);
|
|
2491
|
+
let obj = null;
|
|
2492
|
+
try {
|
|
2493
|
+
obj = JSON.parse(body);
|
|
2494
|
+
} catch (_e) {
|
|
2495
|
+
send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON body'));
|
|
2496
|
+
return true;
|
|
2497
|
+
}
|
|
2498
|
+
enqueue(obj);
|
|
2499
|
+
return true;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// Try to pull one newline-delimited JSON message off the front of `buf`. Returns true if it
|
|
2503
|
+
// consumed a line (whether or not it parsed), false if no complete line is buffered.
|
|
2504
|
+
function tryLineFramed() {
|
|
2505
|
+
const nl = buf.indexOf(0x0a); // '\n'
|
|
2506
|
+
if (nl === -1) return false;
|
|
2507
|
+
const line = buf.slice(0, nl).toString('utf8').trim();
|
|
2508
|
+
buf = buf.slice(nl + 1);
|
|
2509
|
+
if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
|
|
2510
|
+
// A leftover header line ("Content-Length: ...") that wasn't consumed by tryHeaderFramed means
|
|
2511
|
+
// the body hasn't arrived; but since we always try header framing FIRST, a stray header line
|
|
2512
|
+
// here is junk - ignore non-JSON lines rather than erroring on framing artifacts.
|
|
2513
|
+
if (line[0] !== '{' && line[0] !== '[') return true;
|
|
2514
|
+
let obj = null;
|
|
2515
|
+
try {
|
|
2516
|
+
obj = JSON.parse(line);
|
|
2517
|
+
} catch (_e) {
|
|
2518
|
+
send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON line'));
|
|
2519
|
+
return true;
|
|
2520
|
+
}
|
|
2521
|
+
enqueue(obj);
|
|
2522
|
+
return true;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function drain() {
|
|
2526
|
+
// Header framing first (it counts exact bytes and is unambiguous); fall back to line framing.
|
|
2527
|
+
// Loop until neither can make progress.
|
|
2528
|
+
for (;;) {
|
|
2529
|
+
if (tryHeaderFramed()) continue;
|
|
2530
|
+
if (tryLineFramed()) continue;
|
|
2531
|
+
break;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
process.stdin.on('data', (chunk) => {
|
|
2536
|
+
buf = Buffer.concat([buf, chunk]);
|
|
2537
|
+
drain();
|
|
2538
|
+
});
|
|
2539
|
+
process.stdin.on('end', () => {
|
|
2540
|
+
// Flush any complete trailing message, then gracefully close each modern listen subscription
|
|
2541
|
+
// (the spec ends a subscription with a final RESULT to the listen id) before the process exits.
|
|
2542
|
+
drain();
|
|
2543
|
+
for (const sub of modernSubs.splice(0)) {
|
|
2544
|
+
try { send(modern.listenClose(sub.id)); } catch (_e) { /* pipe may already be gone */ }
|
|
2545
|
+
}
|
|
2546
|
+
});
|
|
2547
|
+
process.stdin.on('error', (err) => {
|
|
2548
|
+
logErr('stdin error:', (err && err.message) || String(err));
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
// Begin reading.
|
|
2552
|
+
process.stdin.resume();
|
|
2553
|
+
|
|
2554
|
+
// Expose the writer so the entry point can wire hot-reload watchers (they emit
|
|
2555
|
+
// notifications/tools/list_changed through this same send). `notify` is the era-aware wrapper
|
|
2556
|
+
// the watchers should prefer - it fans tools/list_changed out to modern subscriptions.
|
|
2557
|
+
return { send, notify };
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
2561
|
+
// Entry point.
|
|
2562
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
2563
|
+
async function main() {
|
|
2564
|
+
// The Phase-2 build object { protocol, aggregator, engine, ctx }.
|
|
2565
|
+
let build;
|
|
2566
|
+
try {
|
|
2567
|
+
build = buildProtocol();
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
// If wiring failed (e.g. a missing register/manifest), still start a server that answers every
|
|
2570
|
+
// request with a clean internal error - so the client gets a message instead of a hang. The
|
|
2571
|
+
// degraded build has a null aggregator (no curated-direct surface) but a working meta-tool
|
|
2572
|
+
// path that reports the wiring failure.
|
|
2573
|
+
logErr('FATAL wiring error:', (err && err.stack) || String(err));
|
|
2574
|
+
const message = `MCP server failed to initialise: ${(err && err.message) || String(err)}`;
|
|
2575
|
+
const protocol = makeProtocol({
|
|
2576
|
+
registry: { list: () => { throw new Error(message); }, instructions: () => { throw new Error(message); }, resolveExecution: () => null },
|
|
2577
|
+
gatedRun,
|
|
2578
|
+
howto: () => { throw new Error(message); },
|
|
2579
|
+
});
|
|
2580
|
+
build = { protocol, aggregator: null, engine: null, ctx: null };
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Connect every ENABLED upstream BEFORE the loop starts so tools/list advertises the curated
|
|
2584
|
+
// surface from the first request. connectAll() NEVER throws (per-upstream failures land in
|
|
2585
|
+
// failed[]) and with the default EMPTY expose.json it is an instant no-op - so this never
|
|
2586
|
+
// delays or crashes startup. await-safe either way.
|
|
2587
|
+
if (build.aggregator && typeof build.aggregator.connectAll === 'function') {
|
|
2588
|
+
try {
|
|
2589
|
+
const res = await build.aggregator.connectAll();
|
|
2590
|
+
if (res && Array.isArray(res.failed) && res.failed.length > 0) {
|
|
2591
|
+
logErr('aggregator.connectAll: ' + res.failed.length + ' upstream(s) failed:',
|
|
2592
|
+
res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
|
|
2593
|
+
}
|
|
2594
|
+
} catch (err) {
|
|
2595
|
+
// connectAll is contracted not to throw; guard anyway so startup never dies on it.
|
|
2596
|
+
logErr('aggregator.connectAll threw (ignored):', (err && err.message) || String(err));
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
armWrapChatter(build); // N3 filter armed from boot - not only from the first client message
|
|
2600
|
+
|
|
2601
|
+
// Keep the process alive on the stdio loop; never let an unhandled rejection kill it.
|
|
2602
|
+
process.on('uncaughtException', (err) => logErr('uncaughtException:', (err && err.stack) || String(err)));
|
|
2603
|
+
process.on('unhandledRejection', (err) => logErr('unhandledRejection:', (err && (err.stack || err.message)) || String(err)));
|
|
2604
|
+
|
|
2605
|
+
const loop = createStdioLoop(build);
|
|
2606
|
+
|
|
2607
|
+
// Wire the aggregator's out-of-band tool-set-change signal (a background reconnect recovering - or
|
|
2608
|
+
// finally losing - an upstream) to notifications/tools/list_changed, so the client refreshes when a
|
|
2609
|
+
// dead upstream heals itself. Set on the live instance; reloadExpose re-wires the swapped-in one.
|
|
2610
|
+
// loop.notify is the era-aware writer: it fans the notification out to modern subscriptions
|
|
2611
|
+
// (tagged) or falls back to the raw legacy shape - same for the watchers below.
|
|
2612
|
+
if (build.aggregator) build.aggregator.onToolsChanged = () => emitToolsListChanged(loop.notify);
|
|
2613
|
+
// Bridge an upstream's server-initiated change-notifications (resources/prompts/tools list_changed,
|
|
2614
|
+
// resources/updated) to the era-aware notify path - so a modern client's subscriptions/listen
|
|
2615
|
+
// stream receives them, tagged + filtered. This is what makes wrapping a resource/prompt-bearing
|
|
2616
|
+
// legacy MCP transparent for a modern client (2026-07-16).
|
|
2617
|
+
if (build.aggregator) build.aggregator.onUpstreamNotification = (uid, n) => {
|
|
2618
|
+
// Under a wrap, ONLY the wrapped upstream's notifications reach the client - another attached
|
|
2619
|
+
// upstream's events are ones the impersonated server would never emit: a transparency tell
|
|
2620
|
+
// and a cross-server information leak. Funnel mode (no wrap) fans all.
|
|
2621
|
+
const wrapOnly = build.aggregator && build.aggregator.wrapChatterUpstream;
|
|
2622
|
+
if (wrapOnly && uid !== wrapOnly) return;
|
|
2623
|
+
loop.notify(n);
|
|
2624
|
+
};
|
|
2625
|
+
// Bridge B: server-initiated upstream requests (elicitation/create) route to the bridge; it
|
|
2626
|
+
// binds them to the one in-flight wrapped call or declines. `build` is the stable stdio object.
|
|
2627
|
+
if (build.aggregator) build.aggregator.onUpstreamServerRequest =
|
|
2628
|
+
(uid, m, c) => handleUpstreamServerRequest(build, uid, m, c);
|
|
2629
|
+
|
|
2630
|
+
// Hot-reload: pick up expose.json / hook changes written by the tf_* tools or the UI (a separate
|
|
2631
|
+
// process) with NO restart and NO user intervention - the missing primitive that previously made
|
|
2632
|
+
// a live MCP attach or a hook change need a bounce. Auto is the default; the meta-tools/UI button
|
|
2633
|
+
// are just optional manual levers on top of this.
|
|
2634
|
+
startConfigWatchers(build, loop.notify);
|
|
2635
|
+
|
|
2636
|
+
logErr('ready -', SERVER_INFO.name, SERVER_INFO.version, '(pid', process.pid + ')');
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// Run only when invoked directly (so the module can also be required by tests). main() is async
|
|
2640
|
+
// (it awaits aggregator.connectAll); a startup rejection is logged to stderr, never thrown so the
|
|
2641
|
+
// process can't die unhandled before the loop is even up.
|
|
2642
|
+
if (require.main === module) {
|
|
2643
|
+
Promise.resolve()
|
|
2644
|
+
.then(main)
|
|
2645
|
+
.catch((err) => logErr('FATAL startup error:', (err && err.stack) || String(err)));
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
module.exports = {
|
|
2649
|
+
// Exported for unit tests / reuse (the transport is thin; these are the seams).
|
|
2650
|
+
buildProtocol,
|
|
2651
|
+
makeRegistryAdapter,
|
|
2652
|
+
handleMessage,
|
|
2653
|
+
handleInitialize,
|
|
2654
|
+
// The stdio entry point - an external bin/ can require this module and call main() to start the
|
|
2655
|
+
// server (build -> connect upstreams -> run the stdio loop). main() is async and takes no args.
|
|
2656
|
+
main,
|
|
2657
|
+
// Phase-2 + MATRIX handlers: handleToolsList(protocol, aggregator, { registry?, toolStatePath? })
|
|
2658
|
+
// and handleToolsCall(protocol, aggregator, { engine, ctx, registry?, toolStatePath? }, params).
|
|
2659
|
+
handleToolsList,
|
|
2660
|
+
handleToolsCall,
|
|
2661
|
+
// N3 filter arming - the HTTP transport calls this at start/reload and in its listen branch.
|
|
2662
|
+
armWrapChatter,
|
|
2663
|
+
// Bridge B: the HTTP transport wires this as the aggregator's onUpstreamServerRequest handler.
|
|
2664
|
+
handleUpstreamServerRequest,
|
|
2665
|
+
// Matrix helpers (exported for unit tests / reuse).
|
|
2666
|
+
localHotDefinitions,
|
|
2667
|
+
localToolDefinition,
|
|
2668
|
+
isPromotedLocal,
|
|
2669
|
+
isPromotedUpstream,
|
|
2670
|
+
// Phase-2: emit the hot-update notification (no-id notifications/tools/list_changed) via send.
|
|
2671
|
+
emitToolsListChanged,
|
|
2672
|
+
// Hot-reload: re-read on-disk config live (no restart). Wired to fs.watch in main().
|
|
2673
|
+
reloadExpose,
|
|
2674
|
+
reloadHooks,
|
|
2675
|
+
startConfigWatchers,
|
|
2676
|
+
// Helpers exported for tests / reuse.
|
|
2677
|
+
isMetaTool,
|
|
2678
|
+
runCuratedDirect,
|
|
2679
|
+
wrapProtocolResult,
|
|
2680
|
+
normaliseBuild,
|
|
2681
|
+
stringifyContent,
|
|
2682
|
+
firstString,
|
|
2683
|
+
// Dual-era (2026-07-28) handlers - the modern module itself is at ./modern.
|
|
2684
|
+
handleServerDiscover,
|
|
2685
|
+
handleModernMessage,
|
|
2686
|
+
PROTOCOL_VERSION,
|
|
2687
|
+
SERVER_INFO,
|
|
2688
|
+
};
|