toolfunnel 0.6.0 → 0.7.1
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 +228 -231
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -372
- package/docs/KNOWN_BUGS.md +80 -99
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -70
- 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 +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -350
- package/package.json +3 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -122
- package/src/extend/configure.md +88 -87
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1246
- package/src/mcp/expose-store.js +577 -460
- package/src/mcp/gated-run.js +210 -201
- package/src/mcp/http-transport.js +1563 -1311
- package/src/mcp/mcp-client.js +1070 -907
- package/src/mcp/modern.js +406 -372
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -2688
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1174
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1348
- 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 +120 -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 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
|
@@ -1,1311 +1,1563 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* http-transport.js - the HTTP/SSE transport
|
|
5
|
-
*
|
|
6
|
-
* The SAME hand-rolled MCP protocol as the stdio `server.js`, served over HTTP/SSE so a
|
|
7
|
-
* LONG-LIVED host process can HOST the server and any CLI client connects to it over
|
|
8
|
-
* `localhost:<port>` - instead of the CLI spawning a short-lived stdio child.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* It is THIN. It reuses `server.js` for ALL JSON-RPC routing:
|
|
12
|
-
* - the 4 lean meta-tools (toolfunnel_list_tools / toolfunnel_tool_instructions / toolfunnel_run_tool / toolfunnel_howto),
|
|
13
|
-
* - the curated-direct call path THROUGH the PreToolUse gate (the safety invariant).
|
|
14
|
-
* This file owns ONLY the wire: parse an HTTP request -> `handleMessage(build, msg)` ->
|
|
15
|
-
* shape the HTTP response. The protocol logic is identical to stdio precisely because
|
|
16
|
-
* protocol.js + the server's handleMessage are transport-free.
|
|
17
|
-
*
|
|
18
|
-
* Endpoints (bind 127.0.0.1 only; non-loopback Host headers
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* -
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* -
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* -
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*/
|
|
407
|
-
function
|
|
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
|
-
if (typeof
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
*
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
} catch (_e) {
|
|
1126
|
-
/*
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
//
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* http-transport.js - the HTTP/SSE transport.
|
|
5
|
+
*
|
|
6
|
+
* The SAME hand-rolled MCP protocol as the stdio `server.js`, served over HTTP/SSE so a
|
|
7
|
+
* LONG-LIVED host process can HOST the server and any CLI client connects to it over
|
|
8
|
+
* `localhost:<port>` - instead of the CLI spawning a short-lived stdio child. The point:
|
|
9
|
+
* the host owns the server, not a console.
|
|
10
|
+
*
|
|
11
|
+
* It is THIN. It reuses `server.js` for ALL JSON-RPC routing:
|
|
12
|
+
* - the 4 lean meta-tools (toolfunnel_list_tools / toolfunnel_tool_instructions / toolfunnel_run_tool / toolfunnel_howto),
|
|
13
|
+
* - the curated-direct call path THROUGH the PreToolUse gate (the safety invariant).
|
|
14
|
+
* This file owns ONLY the wire: parse an HTTP request -> `handleMessage(build, msg)` ->
|
|
15
|
+
* shape the HTTP response. The protocol logic is identical to stdio precisely because
|
|
16
|
+
* protocol.js + the server's handleMessage are transport-free.
|
|
17
|
+
*
|
|
18
|
+
* Endpoints (bind 127.0.0.1 only; with auth off, non-loopback Host headers AND present
|
|
19
|
+
* non-loopback Origin headers are rejected defensively - see isLoopbackOrigin for the policy):
|
|
20
|
+
* - POST /mcp : one JSON-RPC request -> 200 application/json (a result) | 202 no body
|
|
21
|
+
* (a notification, handleMessage returned null) | a -32700 parse-error
|
|
22
|
+
* object at HTTP 200 for bad JSON. An oversized body is rejected with the
|
|
23
|
+
* SAME clean -32700 at HTTP 200: a Content-Length over the cap is refused
|
|
24
|
+
* up-front (before the body is read) with `Connection: close`; a chunked
|
|
25
|
+
* over-cap body is bounded by readBody's streaming cap. NEVER crashes.
|
|
26
|
+
* - GET /mcp : the server->client SSE stream (Accept: text/event-stream). This is the
|
|
27
|
+
* current Streamable-HTTP standard: a SINGLE /mcp endpoint serves POST
|
|
28
|
+
* (client->server messages) AND GET (the server->client SSE stream the host
|
|
29
|
+
* pushes notifications/tools/list_changed down). A CLI (type:"http")
|
|
30
|
+
* connects on this path.
|
|
31
|
+
* - GET /mcp/sse : a WORKING ALIAS for the same SSE stream (the older HTTP+SSE shape,
|
|
32
|
+
* deprecated but still supported). Identical behaviour to GET /mcp.
|
|
33
|
+
* - GET /health : 200 application/json with health() (a synchronous snapshot).
|
|
34
|
+
* - anything else : 404 JSON.
|
|
35
|
+
*
|
|
36
|
+
* SAFETY CONTRACT (mirrors the rest of src/mcp/):
|
|
37
|
+
* - start() rejects cleanly on EADDRINUSE (caller decides) - it does NOT crash the process.
|
|
38
|
+
* - stop() is idempotent and NEVER throws (ends SSE clients, closes the server, closes the
|
|
39
|
+
* aggregator). It also clears + unrefs the keep-alive timers so the process can exit.
|
|
40
|
+
* - A bad/oversized/garbage HTTP request becomes a clean response, never an unhandled throw.
|
|
41
|
+
* - All SSE keep-alive timers are unref()-ed so a live stream never blocks process exit.
|
|
42
|
+
*
|
|
43
|
+
* CommonJS only. Node BUILT-INS only (node:http) - no new npm dep, no MCP SDK.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const http = require('node:http');
|
|
47
|
+
|
|
48
|
+
const serverModule = require('./server');
|
|
49
|
+
const { handleMessage, handleBatch } = serverModule;
|
|
50
|
+
// The 2026-07-28 ("modern") era shapes - era detection, header validation, subscriptions/listen.
|
|
51
|
+
// This transport is DUAL-ERA: modern requests get strict header enforcement + the listen stream;
|
|
52
|
+
// legacy requests (initialize-handshake clients, the GET /mcp SSE stream) are byte-for-byte 0.5.0.
|
|
53
|
+
const modern = require('./modern');
|
|
54
|
+
|
|
55
|
+
// OPTIONAL OAuth 2.1 resource-server validation. These modules are zero-runtime-dep themselves;
|
|
56
|
+
// the actual jose import is lazy + inside resource-server.js, pulled in only when auth is enabled.
|
|
57
|
+
const authConfig = require('../auth/config');
|
|
58
|
+
const { createValidator, protectedResourceMetadata, isJoseInstalled } = require('../auth/resource-server');
|
|
59
|
+
const metrics = require('../core/metrics');
|
|
60
|
+
const logger = require('../core/logger');
|
|
61
|
+
|
|
62
|
+
const PROTOCOL_VERSION = serverModule.PROTOCOL_VERSION;
|
|
63
|
+
const SERVER_INFO = serverModule.SERVER_INFO;
|
|
64
|
+
|
|
65
|
+
// ── Tunables ──────────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
67
|
+
const DEFAULT_PORT = 9998;
|
|
68
|
+
// SSE keep-alive comment interval. The browser/CLI EventSource treats a stalled stream as dead;
|
|
69
|
+
// a periodic ":" comment line keeps the pipe warm without emitting a real event. ~25s is well
|
|
70
|
+
// under typical 30-60s proxy idle timeouts. The timer is unref()-ed so it never blocks exit.
|
|
71
|
+
const SSE_KEEPALIVE_MS = 25000;
|
|
72
|
+
// Hard cap on a POSTed JSON-RPC body so a pathological/huge request can't exhaust memory. A
|
|
73
|
+
// JSON-RPC tools/call payload is tiny; 4 MiB is generous headroom. Over the cap -> -32700.
|
|
74
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
75
|
+
// Hard cap on JSON-RPC batch members (0.7.0). MAX_BODY_BYTES alone is no bound on WORK: a
|
|
76
|
+
// tools/call member is ~100 bytes, so 4 MiB buys tens of thousands of gated invocations - each
|
|
77
|
+
// spawning hook children and a tool child - executed serially while the socket is held. 64 is well
|
|
78
|
+
// past any real client's batching and far short of a denial of service.
|
|
79
|
+
const MAX_BATCH_MEMBERS = 64;
|
|
80
|
+
// RFC 9728 Protected Resource Metadata path - served UNAUTHENTICATED (it is the discovery document
|
|
81
|
+
// that tells a client which authorization server to use), and ONLY when auth is enabled.
|
|
82
|
+
const WELL_KNOWN_PRM = '/.well-known/oauth-protected-resource';
|
|
83
|
+
|
|
84
|
+
// Standard JSON-RPC error codes (subset used here - same values as server.js).
|
|
85
|
+
const JSONRPC = '2.0';
|
|
86
|
+
const ERR = Object.freeze({
|
|
87
|
+
PARSE: -32700,
|
|
88
|
+
INVALID_REQUEST: -32600,
|
|
89
|
+
INTERNAL: -32603,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a JSON-RPC error object with a null id (used for transport-level failures - a parse
|
|
94
|
+
* error has no determinable request id). Mirrors server.js makeError for the null-id case.
|
|
95
|
+
* @param {number} code
|
|
96
|
+
* @param {string} message
|
|
97
|
+
* @param {*} [data]
|
|
98
|
+
* @returns {{ jsonrpc: string, id: null, error: { code: number, message: string, data?: * } }}
|
|
99
|
+
*/
|
|
100
|
+
function makeError(code, message, data) {
|
|
101
|
+
const error = { code, message };
|
|
102
|
+
if (data !== undefined) error.data = data;
|
|
103
|
+
return { jsonrpc: JSONRPC, id: null, error };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A JSON-RPC error carrying the REQUEST's id (modern header/validation failures know their id -
|
|
107
|
+
* a null id there would orphan the client's pending request). */
|
|
108
|
+
function makeErrorWithId(id, code, message, data) {
|
|
109
|
+
const error = { code, message };
|
|
110
|
+
if (data !== undefined) error.data = data;
|
|
111
|
+
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Diagnostics -> logger (default: stderr). NEVER throws (a logging failure must not sink a call). */
|
|
115
|
+
function makeLog(logger) {
|
|
116
|
+
const sink =
|
|
117
|
+
logger && typeof logger.error === 'function'
|
|
118
|
+
? (msg) => logger.error('[toolfunnel-http] ' + msg)
|
|
119
|
+
: (msg) => {
|
|
120
|
+
try {
|
|
121
|
+
process.stderr.write('[toolfunnel-http] ' + msg + '\n');
|
|
122
|
+
} catch (_e) {
|
|
123
|
+
/* never let logging throw */
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
return (...parts) => {
|
|
127
|
+
try {
|
|
128
|
+
sink(parts.join(' '));
|
|
129
|
+
} catch (_e) {
|
|
130
|
+
/* never throw out of the logger */
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Default build factory - the real full build over the on-disk (EMPTY by default) expose.json. */
|
|
136
|
+
function defaultBuildFactory() {
|
|
137
|
+
return require('./server').buildProtocol();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound request could still
|
|
142
|
+
* arrive with a non-loopback Host. Defence-in-depth: reject anything whose host part is not a
|
|
143
|
+
* recognised loopback name (DNS-rebinding guard). A MISSING Host header is allowed (HTTP/1.0 / a
|
|
144
|
+
* raw node:http client may omit it) - the bind address is already the hard boundary.
|
|
145
|
+
* @param {string|undefined} hostHeader the raw `Host` request header
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
function isLoopbackHost(hostHeader) {
|
|
149
|
+
if (hostHeader == null || hostHeader === '') return true; // no Host -> bind addr is the boundary
|
|
150
|
+
// Strip an optional :port. IPv6 hosts are bracketed: "[::1]:9998".
|
|
151
|
+
let host = String(hostHeader).trim();
|
|
152
|
+
if (host[0] === '[') {
|
|
153
|
+
const end = host.indexOf(']');
|
|
154
|
+
host = end === -1 ? host.slice(1) : host.slice(1, end);
|
|
155
|
+
} else {
|
|
156
|
+
// Strip an optional :port ONLY when unambiguous - exactly one colon means "host:port". A string
|
|
157
|
+
// with MULTIPLE colons is a bare IPv6 literal (e.g. "::1", "0:0:0:0:0:0:0:1"); compare it whole,
|
|
158
|
+
// else slicing at the first colon mangles it to "" and a valid IPv6 loopback bind is rejected.
|
|
159
|
+
const first = host.indexOf(':');
|
|
160
|
+
if (first !== -1 && first === host.lastIndexOf(':')) host = host.slice(0, first);
|
|
161
|
+
}
|
|
162
|
+
host = host.toLowerCase();
|
|
163
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Is the Origin header acceptable? OUR validation policy (an engineering call for a loopback
|
|
168
|
+
* gateway, not a spec quote), era-independent - it gates the HTTP request before any JSON-RPC
|
|
169
|
+
* body is read, so legacy and modern callers are treated identically:
|
|
170
|
+
*
|
|
171
|
+
* - ABSENT -> pass. Non-browser clients (stdio bridges, SDKs, curl) send no Origin; requiring
|
|
172
|
+
* one would break every legitimate client we serve.
|
|
173
|
+
* - PRESENT + loopback host part -> pass (a local web UI is a first-class client).
|
|
174
|
+
* - PRESENT + anything else -> reject. This is the DNS-rebinding / cross-origin-CSRF guard: a
|
|
175
|
+
* browser page on disallowed.example can script POSTs at 127.0.0.1 and the browser attaches its
|
|
176
|
+
* Origin - the one header the page cannot forge. Includes the opaque `Origin: null`
|
|
177
|
+
* (sandboxed iframes / file:// pages): present-and-not-loopback is the rejection rule.
|
|
178
|
+
*
|
|
179
|
+
* The caller applies this only when auth is DISABLED - with auth ON the bearer token is the
|
|
180
|
+
* boundary (same rationale as the Host guard) and remote browser clients legitimately present
|
|
181
|
+
* non-loopback Origins.
|
|
182
|
+
* @param {string|undefined} originHeader the raw `Origin` request header
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
function isLoopbackOrigin(originHeader) {
|
|
186
|
+
if (originHeader == null || originHeader === '') return true; // absent -> not a browser context
|
|
187
|
+
const raw = String(originHeader).trim();
|
|
188
|
+
// An Origin is scheme://host[:port] - no path. Anything without "://" (including the literal
|
|
189
|
+
// opaque token "null") is not a loopback web origin: reject.
|
|
190
|
+
const sep = raw.indexOf('://');
|
|
191
|
+
if (sep === -1) return false;
|
|
192
|
+
// An EMPTY host part ("http://") must reject HERE: isLoopbackHost passes '' because a request
|
|
193
|
+
// with no Host header at all is legitimate (HTTP/1.0), but this Origin IS present - a hostless
|
|
194
|
+
// origin is not a loopback web origin (origin.test.js 4b).
|
|
195
|
+
const hostPart = raw.slice(sep + 3);
|
|
196
|
+
if (hostPart === '') return false;
|
|
197
|
+
return isLoopbackHost(hostPart);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read a request body up to MAX_BODY_BYTES. Resolves the buffered string, or rejects 'too large'.
|
|
202
|
+
* On over-cap it does NOT destroy the request - destroying here tears down the shared req/res
|
|
203
|
+
* socket before the caller's -32700 reply can flush (the client saw ECONNRESET instead of the clean
|
|
204
|
+
* parse error the declared-Content-Length path produces). Instead: stop BUFFERING (later chunks are
|
|
205
|
+
* discarded, so memory stays bounded) and reject; the caller replies via sendJsonAndClose, which
|
|
206
|
+
* flushes the error body first and destroys the request only in the res.end callback.
|
|
207
|
+
*/
|
|
208
|
+
function readBody(req) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
let size = 0;
|
|
211
|
+
const chunks = [];
|
|
212
|
+
let done = false;
|
|
213
|
+
const finish = (fn, val) => {
|
|
214
|
+
if (done) return;
|
|
215
|
+
done = true;
|
|
216
|
+
fn(val);
|
|
217
|
+
};
|
|
218
|
+
req.on('data', (chunk) => {
|
|
219
|
+
if (done) return; // over-cap already signalled - discard, don't buffer
|
|
220
|
+
size += chunk.length;
|
|
221
|
+
if (size > MAX_BODY_BYTES) {
|
|
222
|
+
// Stop buffering; the caller flush-then-closes with the same -32700 as the declared-CL path.
|
|
223
|
+
finish(reject, new Error('request body too large'));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
chunks.push(chunk);
|
|
227
|
+
});
|
|
228
|
+
req.on('end', () => finish(resolve, Buffer.concat(chunks).toString('utf8')));
|
|
229
|
+
req.on('error', (err) => finish(reject, err));
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Write a JSON response with a status code. Defensive: a serialise failure degrades to 500 text. */
|
|
234
|
+
function sendJson(res, status, obj) {
|
|
235
|
+
let body;
|
|
236
|
+
try {
|
|
237
|
+
body = JSON.stringify(obj);
|
|
238
|
+
} catch (_e) {
|
|
239
|
+
body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
|
|
240
|
+
}
|
|
241
|
+
res.writeHead(status, {
|
|
242
|
+
'Content-Type': 'application/json',
|
|
243
|
+
'Content-Length': Buffer.byteLength(body, 'utf8'),
|
|
244
|
+
'Cache-Control': 'no-store',
|
|
245
|
+
});
|
|
246
|
+
res.end(body);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Send a JSON response and then CLOSE the connection, draining the unread request body WITHOUT
|
|
251
|
+
* buffering it. Used by both over-cap paths (the Content-Length pre-check and readBody's streaming
|
|
252
|
+
* cap): the client must receive the documented -32700 cleanly while the oversized payload never
|
|
253
|
+
* touches memory. The naive close - `req.destroy()`, even deferred to the res.end flush callback -
|
|
254
|
+
* is NOT safe: destroy() sends a TCP RST whenever request bytes are still unread/in-flight, and on
|
|
255
|
+
* RST Windows discards the peer's receive queue, eating the response we just flushed (the client
|
|
256
|
+
* saw ECONNRESET instead of the -32700; proven by test 5 in http.test.js). The robust sequence:
|
|
257
|
+
* 1. announce `Connection: close` so the client/keep-alive agent expects the socket to end here,
|
|
258
|
+
* 2. write the full response body, then in the res.end(...) flush callback:
|
|
259
|
+
* 3. resume() the request so any still-arriving body is DISCARDED (never buffered -> no DoS),
|
|
260
|
+
* 4. HALF-CLOSE the socket (socket.end()) - the FIN travels in-order AFTER the response bytes,
|
|
261
|
+
* so the client always reads the -32700 first, and
|
|
262
|
+
* 5. arm an unref'd hard-destroy timer as the backstop: a client that never stops sending and
|
|
263
|
+
* never closes gets its socket torn down after a grace window (bounded lifetime, no leak).
|
|
264
|
+
* Defensive: like sendJson, a serialise failure degrades to a 500-shaped error body; the whole
|
|
265
|
+
* function is wrapped so a write-after-headers race can never throw out of handleMcpPost.
|
|
266
|
+
* @param {import('node:http').IncomingMessage} req
|
|
267
|
+
* @param {import('node:http').ServerResponse} res
|
|
268
|
+
* @param {number} status
|
|
269
|
+
* @param {object} obj
|
|
270
|
+
*/
|
|
271
|
+
function sendJsonAndClose(req, res, status, obj) {
|
|
272
|
+
let body;
|
|
273
|
+
try {
|
|
274
|
+
body = JSON.stringify(obj);
|
|
275
|
+
} catch (_e) {
|
|
276
|
+
body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
|
|
277
|
+
}
|
|
278
|
+
const destroyReq = () => {
|
|
279
|
+
try {
|
|
280
|
+
req.destroy();
|
|
281
|
+
} catch (_e) {
|
|
282
|
+
/* the socket may already be gone; nothing more to do */
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
try {
|
|
286
|
+
res.writeHead(status, {
|
|
287
|
+
'Content-Type': 'application/json',
|
|
288
|
+
'Content-Length': Buffer.byteLength(body, 'utf8'),
|
|
289
|
+
'Cache-Control': 'no-store',
|
|
290
|
+
// Signal end-of-connection so the client agent does not try to reuse the socket for a
|
|
291
|
+
// pipelined follow-up (whose body would collide with the one we are about to discard).
|
|
292
|
+
Connection: 'close',
|
|
293
|
+
});
|
|
294
|
+
res.end(body, () => {
|
|
295
|
+
// The response has flushed to the kernel. Close down WITHOUT an RST (see the doc above):
|
|
296
|
+
try {
|
|
297
|
+
req.resume(); // discard (never buffer) whatever request body is still arriving
|
|
298
|
+
if (req.socket && !req.socket.destroyed) req.socket.end(); // FIN, in-order after the body
|
|
299
|
+
} catch (_e) {
|
|
300
|
+
return destroyReq(); // a torn socket at this point -> plain destroy is all that is left
|
|
301
|
+
}
|
|
302
|
+
// Backstop: bound the socket's lifetime if the client neither finishes nor closes. The
|
|
303
|
+
// timer is unref'd so it never keeps the process alive; a normal close clears it.
|
|
304
|
+
const t = setTimeout(destroyReq, 10000);
|
|
305
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
306
|
+
if (req.socket) req.socket.once('close', () => clearTimeout(t));
|
|
307
|
+
});
|
|
308
|
+
} catch (_e) {
|
|
309
|
+
// writeHead/end can throw if headers were already sent by a racing path - fall back to a
|
|
310
|
+
// straight destroy so the socket can never be left half-open.
|
|
311
|
+
destroyReq();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* createHttpMcpServer - construct (but do NOT start) the HTTP/SSE MCP host.
|
|
317
|
+
*
|
|
318
|
+
* @param {object} [opts]
|
|
319
|
+
* @param {Function} [opts.buildFactory] () => { protocol, aggregator, engine, ctx }. Injectable so
|
|
320
|
+
* tests pass a sandbox build. Default = buildProtocol().
|
|
321
|
+
* @param {string} [opts.host] bind address (default 127.0.0.1 - loopback only).
|
|
322
|
+
* @param {number} [opts.port] bind port (default 9998; pass 0 for an OS-assigned port).
|
|
323
|
+
* @param {object} [opts.logger] an object with .error(msg); default writes to stderr.
|
|
324
|
+
* @returns {{
|
|
325
|
+
* start: () => Promise<{ port:number, url:string }>,
|
|
326
|
+
* stop: () => Promise<void>,
|
|
327
|
+
* reload: () => Promise<void>,
|
|
328
|
+
* broadcastToolsListChanged: () => number,
|
|
329
|
+
* health: () => object,
|
|
330
|
+
* get port(): (number|null),
|
|
331
|
+
* get url(): (string|null),
|
|
332
|
+
* get sseClientCount(): number,
|
|
333
|
+
* }}
|
|
334
|
+
*/
|
|
335
|
+
function createHttpMcpServer(opts = {}) {
|
|
336
|
+
const o = opts || {};
|
|
337
|
+
const buildFactory = typeof o.buildFactory === 'function' ? o.buildFactory : defaultBuildFactory;
|
|
338
|
+
const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
|
|
339
|
+
// A port of 0 is VALID (OS-assigned) so we must distinguish "not provided" from 0.
|
|
340
|
+
const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
|
|
341
|
+
const log = makeLog(o.logger);
|
|
342
|
+
|
|
343
|
+
// ── Mutable runtime state ─────────────────────────────────────────────────────────────────
|
|
344
|
+
/** @type {object|null} the current full build { protocol, aggregator, engine, ctx }. */
|
|
345
|
+
let build = null;
|
|
346
|
+
/** @type {import('node:http').Server|null} */
|
|
347
|
+
let httpServer = null;
|
|
348
|
+
/** @type {number|null} the actual bound port (resolved after listen - matters for port 0). */
|
|
349
|
+
let boundPort = null;
|
|
350
|
+
/** @type {Set<import('node:http').ServerResponse>} connected SSE client responses. */
|
|
351
|
+
const sseClients = new Set();
|
|
352
|
+
/**
|
|
353
|
+
* Modern subscriptions/listen streams: { res, subId, agreed } per open listen POST. Kept
|
|
354
|
+
* SEPARATE from the legacy sseClients set - the two eras have different wire shapes (tagged +
|
|
355
|
+
* filtered vs raw) and different close semantics (a final result vs a plain end).
|
|
356
|
+
* @type {Set<{ res: import('node:http').ServerResponse, subId: *, agreed: object }>}
|
|
357
|
+
*/
|
|
358
|
+
const modernListeners = new Set();
|
|
359
|
+
// Cap on concurrent modern listen streams - loopback bounds this in practice, but a looping
|
|
360
|
+
// client that re-listens without closing could still exhaust the process.
|
|
361
|
+
// The stdio transport needs no cap: one client per pipe, and re-listens replace-on-same-id.
|
|
362
|
+
const MODERN_LISTENERS_MAX = 64;
|
|
363
|
+
/** @type {boolean} latch so stop() is idempotent and start() can't double-bind. */
|
|
364
|
+
let started = false;
|
|
365
|
+
let stopping = false;
|
|
366
|
+
// 4.4: modern tools/call sockets closed before the response was written (per host lifetime).
|
|
367
|
+
let disconnectCancels = 0;
|
|
368
|
+
/** @type {(() => void)|null} teardown for the config hot-reload watchers (armed in start()). */
|
|
369
|
+
let configWatchersStop = null;
|
|
370
|
+
/**
|
|
371
|
+
* The result of the LAST connectAll() (from start() or reload()), surfaced by health() so a
|
|
372
|
+
* silent upstream connect-failure becomes VISIBLE (otherwise it only reaches stderr via log()).
|
|
373
|
+
* Shape mirrors Aggregator.connectAll's return: { connected: string[], failed: [{id,error}] }.
|
|
374
|
+
* Defensive: only ever holds the well-formed arrays - a connectAll throw (which shouldn't happen)
|
|
375
|
+
* or a malformed result leaves it EMPTY rather than half-populated. Reset to empty by stop().
|
|
376
|
+
* @type {{ connected: string[], failed: Array<{id:string, error:string}> }}
|
|
377
|
+
*/
|
|
378
|
+
let lastConnect = { connected: [], failed: [] };
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Record a connectAll() result into lastConnect, defensively. A well-formed result has BOTH a
|
|
382
|
+
* `connected` array and a `failed` array; anything else (a throw upstream, a malformed return)
|
|
383
|
+
* leaves lastConnect EMPTY so health() never reports stale/partial connect state.
|
|
384
|
+
* @param {*} res the value returned by Aggregator.connectAll()
|
|
385
|
+
*/
|
|
386
|
+
function recordConnect(res) {
|
|
387
|
+
if (res && Array.isArray(res.connected) && Array.isArray(res.failed)) {
|
|
388
|
+
lastConnect = { connected: res.connected.slice(), failed: res.failed.slice() };
|
|
389
|
+
} else {
|
|
390
|
+
lastConnect = { connected: [], failed: [] };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The advertised base URL once bound (null before start / after stop). */
|
|
395
|
+
function currentUrl() {
|
|
396
|
+
return boundPort == null ? null : `http://${host}:${boundPort}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── SSE plumbing ──────────────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Open an SSE stream on a GET /mcp request (the Streamable-HTTP standard server->client stream) or
|
|
403
|
+
* its GET /mcp/sse alias. Registers the response in the client set, writes the SSE preamble + a
|
|
404
|
+
* ": connected" comment, and starts an unref()-ed keep-alive timer. On close (client disconnect,
|
|
405
|
+
* server stop) the client is deregistered and its timer cleared.
|
|
406
|
+
*/
|
|
407
|
+
function openSse(req, res) {
|
|
408
|
+
res.writeHead(200, {
|
|
409
|
+
'Content-Type': 'text/event-stream',
|
|
410
|
+
'Cache-Control': 'no-store',
|
|
411
|
+
Connection: 'keep-alive',
|
|
412
|
+
// Disable proxy buffering so events flush immediately (harmless when no proxy is present).
|
|
413
|
+
'X-Accel-Buffering': 'no',
|
|
414
|
+
});
|
|
415
|
+
// Initial comment so the client knows the stream is live (comments start with ':').
|
|
416
|
+
res.write(': connected\n\n');
|
|
417
|
+
|
|
418
|
+
// Periodic keep-alive comment. unref() so a dangling stream NEVER blocks process exit, and
|
|
419
|
+
// clear it on close/stop so we don't write to a dead socket.
|
|
420
|
+
const keepAlive = setInterval(() => {
|
|
421
|
+
try {
|
|
422
|
+
res.write(': ping\n\n');
|
|
423
|
+
} catch (_e) {
|
|
424
|
+
/* a write to a half-closed socket is benign; close handler will clean up */
|
|
425
|
+
}
|
|
426
|
+
}, SSE_KEEPALIVE_MS);
|
|
427
|
+
if (typeof keepAlive.unref === 'function') keepAlive.unref();
|
|
428
|
+
|
|
429
|
+
// Track the timer ON the response object so stop()/close can clear it.
|
|
430
|
+
res._keepAlive = keepAlive;
|
|
431
|
+
sseClients.add(res);
|
|
432
|
+
|
|
433
|
+
const cleanup = () => {
|
|
434
|
+
clearInterval(keepAlive);
|
|
435
|
+
sseClients.delete(res);
|
|
436
|
+
};
|
|
437
|
+
req.on('close', cleanup);
|
|
438
|
+
req.on('error', cleanup);
|
|
439
|
+
res.on('close', cleanup);
|
|
440
|
+
res.on('error', cleanup);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Push one JSON-RPC notification to every connected SSE client as a `data:` event. Each client
|
|
445
|
+
* that errors mid-write is dropped (and cleaned up) so one dead socket can't sink the broadcast.
|
|
446
|
+
* @param {object} notification a NO-ID JSON-RPC notification object
|
|
447
|
+
* @returns {number} how many clients were written to
|
|
448
|
+
*/
|
|
449
|
+
function pushToSse(notification) {
|
|
450
|
+
let line;
|
|
451
|
+
try {
|
|
452
|
+
line = JSON.stringify(notification);
|
|
453
|
+
} catch (_e) {
|
|
454
|
+
return 0; // an unserialisable notification is a no-op rather than a throw
|
|
455
|
+
}
|
|
456
|
+
let count = 0;
|
|
457
|
+
for (const res of Array.from(sseClients)) {
|
|
458
|
+
try {
|
|
459
|
+
res.write('data: ' + line + '\n\n');
|
|
460
|
+
count += 1;
|
|
461
|
+
} catch (_e) {
|
|
462
|
+
// Drop a dead client so it never blocks future broadcasts or process exit.
|
|
463
|
+
try {
|
|
464
|
+
if (res._keepAlive) clearInterval(res._keepAlive);
|
|
465
|
+
} catch (_e2) {
|
|
466
|
+
/* ignore */
|
|
467
|
+
}
|
|
468
|
+
sseClients.delete(res);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return count;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Open a modern subscriptions/listen stream: the POST's response becomes a long-lived SSE
|
|
476
|
+
* stream. Per spec the FIRST message is the acknowledgment notification (carrying the agreed
|
|
477
|
+
* filter subset + the subscription id = the listen request's JSON-RPC id); every later
|
|
478
|
+
* notification is tagged with that id; a graceful server-side close sends a final RESULT to
|
|
479
|
+
* the listen id then ends the stream. Keep-alive comments mirror the legacy openSse.
|
|
480
|
+
*/
|
|
481
|
+
function openListenStream(req, res, msg) {
|
|
482
|
+
const subId = msg.id;
|
|
483
|
+
// A listen-FIRST modern client bypasses handleMessage - arm the wrap-chatter filter here too.
|
|
484
|
+
if (typeof serverModule.armWrapChatter === 'function') serverModule.armWrapChatter(build);
|
|
485
|
+
const { agreed } = modern.normaliseListenFilter(msg.params);
|
|
486
|
+
|
|
487
|
+
// At capacity -> refuse the NEW stream with a clear error BEFORE any SSE bytes are written;
|
|
488
|
+
// existing streams are never evicted (they may carry subscriptions the client relies on).
|
|
489
|
+
//
|
|
490
|
+
// ORDER MATTERS (0.7.0): this check used to sit AFTER subscribeResources below, and the 503
|
|
491
|
+
// return path registers no cleanup - so a refused listen took refcounts it never released.
|
|
492
|
+
// Each leaked ref pinned its URI above zero (no resources/unsubscribe ever sent) and kept it
|
|
493
|
+
// in the replay set, re-subscribed on every future upstream reconnect, unboundedly.
|
|
494
|
+
if (modernListeners.size >= MODERN_LISTENERS_MAX) {
|
|
495
|
+
return sendJson(res, 503, makeErrorWithId(subId, ERR.INTERNAL,
|
|
496
|
+
'listen stream limit reached (' + MODERN_LISTENERS_MAX + ') - close an existing subscriptions/listen stream first'));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// resourceSubscriptions is only agreed when some upstream can DELIVER per-URI updates (modern
|
|
500
|
+
// era emits spontaneously; a subscribe-capable legacy upstream gets the subscribes forwarded).
|
|
501
|
+
// An ack must never promise a dead channel.
|
|
502
|
+
if (agreed.resourceSubscriptions) {
|
|
503
|
+
const agg = build && build.aggregator;
|
|
504
|
+
if (agg && typeof agg.canHonourResourceSubscriptions === 'function' && agg.canHonourResourceSubscriptions()) {
|
|
505
|
+
agg.subscribeResources(agreed.resourceSubscriptions);
|
|
506
|
+
} else {
|
|
507
|
+
delete agreed.resourceSubscriptions;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
res.writeHead(200, {
|
|
512
|
+
'Content-Type': 'text/event-stream',
|
|
513
|
+
'Cache-Control': 'no-store',
|
|
514
|
+
Connection: 'keep-alive',
|
|
515
|
+
'X-Accel-Buffering': 'no',
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Spec: the acknowledgment MUST be the first message on the stream.
|
|
519
|
+
try {
|
|
520
|
+
res.write('data: ' + JSON.stringify(modern.listenAck(subId, agreed)) + '\n\n');
|
|
521
|
+
} catch (_e) {
|
|
522
|
+
/* a client that died mid-open is cleaned up by the close handlers below */
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const keepAlive = setInterval(() => {
|
|
526
|
+
try {
|
|
527
|
+
res.write(': ping\n\n');
|
|
528
|
+
} catch (_e) {
|
|
529
|
+
/* benign - close handler cleans up */
|
|
530
|
+
}
|
|
531
|
+
}, SSE_KEEPALIVE_MS);
|
|
532
|
+
if (typeof keepAlive.unref === 'function') keepAlive.unref();
|
|
533
|
+
res._keepAlive = keepAlive;
|
|
534
|
+
|
|
535
|
+
const entry = { res, subId, agreed };
|
|
536
|
+
modernListeners.add(entry);
|
|
537
|
+
|
|
538
|
+
const cleanup = () => {
|
|
539
|
+
clearInterval(keepAlive);
|
|
540
|
+
modernListeners.delete(entry);
|
|
541
|
+
// 5.4: the stream WAS the subscription - closing it releases the listen-owned resource
|
|
542
|
+
// URIs (this cleanup previously released nothing: leak site #2). _released guards the
|
|
543
|
+
// up-to-four close/error events that all route here.
|
|
544
|
+
if (entry._released !== true && entry.agreed && Array.isArray(entry.agreed.resourceSubscriptions)) {
|
|
545
|
+
entry._released = true;
|
|
546
|
+
const agg = build && build.aggregator;
|
|
547
|
+
if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(entry.agreed.resourceSubscriptions);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
req.on('close', cleanup);
|
|
551
|
+
req.on('error', cleanup);
|
|
552
|
+
res.on('close', cleanup);
|
|
553
|
+
res.on('error', cleanup);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Push one notification to every modern listener whose agreed filter includes it (only
|
|
558
|
+
* toolsListChanged exists on this tools-only server), TAGGED with each stream's subscription
|
|
559
|
+
* id. A dead client is dropped, mirroring pushToSse. Returns how many were written to.
|
|
560
|
+
*/
|
|
561
|
+
function pushToListeners(notification) {
|
|
562
|
+
let count = 0;
|
|
563
|
+
for (const entry of Array.from(modernListeners)) {
|
|
564
|
+
// Deliver only to streams whose AGREED filter includes this notification's channel
|
|
565
|
+
// (tools/prompts/resources list_changed, or resources/updated for a subscribed uri).
|
|
566
|
+
if (!modern.notificationMatchesFilter(notification, entry.agreed)) continue;
|
|
567
|
+
let line;
|
|
568
|
+
try {
|
|
569
|
+
line = JSON.stringify(modern.tagNotification(notification, entry.subId));
|
|
570
|
+
} catch (_e) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
entry.res.write('data: ' + line + '\n\n');
|
|
575
|
+
count += 1;
|
|
576
|
+
} catch (_e) {
|
|
577
|
+
try {
|
|
578
|
+
if (entry.res._keepAlive) clearInterval(entry.res._keepAlive);
|
|
579
|
+
} catch (_e2) {
|
|
580
|
+
/* ignore */
|
|
581
|
+
}
|
|
582
|
+
modernListeners.delete(entry);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return count;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* broadcastToolsListChanged - push notifications/tools/list_changed (no id) to every legacy SSE
|
|
590
|
+
* client AND (tagged + filtered) to every modern listen stream. A client honouring listChanged
|
|
591
|
+
* re-fetches tools/list (the design doc). Returns the number of clients notified.
|
|
592
|
+
* @returns {number}
|
|
593
|
+
*/
|
|
594
|
+
function broadcastToolsListChanged() {
|
|
595
|
+
const note = { jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' };
|
|
596
|
+
return pushToSse(note) + pushToListeners(note);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Wire the CURRENT build's aggregator "tools changed" signal (a background reconnect finally
|
|
601
|
+
* winning an upstream) to the SSE broadcast, so an HTTP client honouring listChanged re-fetches.
|
|
602
|
+
* Re-called after every build (re)creation because a fresh aggregator has no listener - the stdio
|
|
603
|
+
* path does the equivalent in main(). NEVER throws.
|
|
604
|
+
*/
|
|
605
|
+
function wireAggregatorNotify() {
|
|
606
|
+
if (build && build.aggregator) {
|
|
607
|
+
// Arm the cross-upstream wrap-chatter filter from wiring time - bridged notifications can arrive
|
|
608
|
+
// before the first client message computes the wrap state.
|
|
609
|
+
if (typeof serverModule.armWrapChatter === 'function') serverModule.armWrapChatter(build);
|
|
610
|
+
build.aggregator.onToolsChanged = () => broadcastToolsListChanged();
|
|
611
|
+
// Bridge an upstream's server-initiated change-notifications (resources/prompts/tools
|
|
612
|
+
// list_changed, resources/updated) to legacy SSE clients (raw) AND modern listen streams
|
|
613
|
+
// (tagged + filtered per subscription). NEVER throws.
|
|
614
|
+
build.aggregator.onUpstreamNotification = (uid, n) => {
|
|
615
|
+
// Under a wrap, ONLY the wrapped upstream's notifications reach clients - another attached
|
|
616
|
+
// upstream's events are a transparency tell + cross-server leak.
|
|
617
|
+
const wrapOnly = build.aggregator && build.aggregator.wrapChatterUpstream;
|
|
618
|
+
if (wrapOnly && uid !== wrapOnly) return;
|
|
619
|
+
// Request-scoped chatter (ONE client's in-flight progress/log lines) must not BROADCAST on
|
|
620
|
+
// the multi-client HTTP transport - client B would receive client A's progress tokens and
|
|
621
|
+
// log output. stdio (one client per process) keeps the relay;
|
|
622
|
+
// chat-scoped change notifications (list_changed, resources/updated) still broadcast here.
|
|
623
|
+
const m = n && n.method;
|
|
624
|
+
if (m === 'notifications/progress' || m === 'notifications/message') return;
|
|
625
|
+
try { pushToSse(n); } catch (_e) { /* legacy SSE best-effort */ }
|
|
626
|
+
try { pushToListeners(n); } catch (_e) { /* modern listeners best-effort */ }
|
|
627
|
+
};
|
|
628
|
+
// Elicitation bridge: elicitations from the wrapped upstream bind to the one in-flight wrapped call
|
|
629
|
+
// (or decline). The handler reads `build` live via this closure, same as the bridge above.
|
|
630
|
+
if (typeof serverModule.handleUpstreamServerRequest === 'function') {
|
|
631
|
+
build.aggregator.onUpstreamServerRequest = (uid, m, c) =>
|
|
632
|
+
serverModule.handleUpstreamServerRequest(build, uid, m, c);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── OAuth 2.1 resource-server gate (OPTIONAL; default OFF) ──────────────────────────────────
|
|
638
|
+
// The validator is memoised and rebuilt ONLY when the relevant auth config changes - so a live
|
|
639
|
+
// enable/disable/edit takes effect on the next request WITHOUT discarding jose's JWKS cache in
|
|
640
|
+
// steady state. The config is read FRESH per request (mirrors the logger + tool-state overlays),
|
|
641
|
+
// so the on/off decision is always live; the cache key just avoids rebuilding the remote-JWKS
|
|
642
|
+
// resolver when nothing relevant changed.
|
|
643
|
+
let _validator = null;
|
|
644
|
+
let _validatorSig = '';
|
|
645
|
+
|
|
646
|
+
function validatorFor(authCfg) {
|
|
647
|
+
const resourceMetadataUrl = boundPort == null ? '' : currentUrl() + WELL_KNOWN_PRM;
|
|
648
|
+
const sig = JSON.stringify([
|
|
649
|
+
authCfg.issuer, authCfg.audience, authCfg.jwksUri, authCfg.algorithms,
|
|
650
|
+
authCfg.requiredScopes, authCfg.clockToleranceSec, resourceMetadataUrl,
|
|
651
|
+
]);
|
|
652
|
+
if (_validator && sig === _validatorSig) return _validator;
|
|
653
|
+
_validator = createValidator(authCfg, { resourceMetadataUrl, log });
|
|
654
|
+
_validatorSig = sig;
|
|
655
|
+
return _validator;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Enforce the OAuth gate for a protected route. Returns true if the request may proceed, false if
|
|
660
|
+
* it has ALREADY been answered (401/403/500). Fails CLOSED - any error rejects rather than allows.
|
|
661
|
+
*/
|
|
662
|
+
async function passesAuth(req, res, authCfg) {
|
|
663
|
+
let verdict;
|
|
664
|
+
try {
|
|
665
|
+
const validator = validatorFor(authCfg);
|
|
666
|
+
verdict = await validator.validate(req.headers && req.headers.authorization);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
// validate() is contracted never to throw; guard anyway and fail CLOSED (never allow on error).
|
|
669
|
+
log('auth validate threw (failing closed):', (err && err.message) || String(err));
|
|
670
|
+
verdict = { ok: false, status: 401, error: 'invalid_token', errorDescription: 'authentication error', wwwAuthenticate: 'Bearer' };
|
|
671
|
+
}
|
|
672
|
+
if (verdict && verdict.ok === true) return true;
|
|
673
|
+
// Audit the DENIAL (self-gating; no-op unless logging is on). A rejected access attempt is the
|
|
674
|
+
// security-relevant event - without this it was invisible (rejected at the gate, before the
|
|
675
|
+
// protocol/logging layer), so a run of failed auth attempts left no trace in the activity log.
|
|
676
|
+
try {
|
|
677
|
+
const rawUrl = req.url || '/';
|
|
678
|
+
const q = rawUrl.indexOf('?');
|
|
679
|
+
logger.log({
|
|
680
|
+
type: 'auth',
|
|
681
|
+
event: 'deny',
|
|
682
|
+
error: verdict && verdict.error,
|
|
683
|
+
status: verdict && verdict.status,
|
|
684
|
+
path: q === -1 ? rawUrl : rawUrl.slice(0, q),
|
|
685
|
+
});
|
|
686
|
+
} catch (_e) { /* never let audit logging break the gate */ }
|
|
687
|
+
sendAuthChallenge(res, verdict);
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Send an OAuth-style challenge (401/403/500) with the WWW-Authenticate header. NEVER throws. */
|
|
692
|
+
function sendAuthChallenge(res, verdict) {
|
|
693
|
+
const status = (verdict && Number.isInteger(verdict.status)) ? verdict.status : 401;
|
|
694
|
+
const payload = { error: (verdict && verdict.error) || 'invalid_token' };
|
|
695
|
+
if (verdict && verdict.errorDescription) payload.error_description = verdict.errorDescription;
|
|
696
|
+
let body;
|
|
697
|
+
try { body = JSON.stringify(payload); } catch (_e) { body = '{"error":"invalid_token"}'; }
|
|
698
|
+
const headers = {
|
|
699
|
+
'Content-Type': 'application/json',
|
|
700
|
+
'Content-Length': Buffer.byteLength(body, 'utf8'),
|
|
701
|
+
'Cache-Control': 'no-store',
|
|
702
|
+
};
|
|
703
|
+
if (verdict && verdict.wwwAuthenticate) headers['WWW-Authenticate'] = verdict.wwwAuthenticate;
|
|
704
|
+
try {
|
|
705
|
+
res.writeHead(status, headers);
|
|
706
|
+
res.end(body);
|
|
707
|
+
} catch (_e) {
|
|
708
|
+
/* response may already be committed; nothing more to do */
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── HTTP request handling ─────────────────────────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
/** POST /mcp - one JSON-RPC request -> handleMessage(build, msg) -> HTTP response. NEVER throws. */
|
|
715
|
+
async function handleMcpPost(req, res) {
|
|
716
|
+
// ── Content-Length pre-check ──────────────────────────────────────────────────────────────
|
|
717
|
+
// A client that ANNOUNCES an oversized body (Content-Length > MAX_BODY_BYTES) is rejected
|
|
718
|
+
// BEFORE a single byte of that body is read, so a multi-MiB POST never touches memory. We reply
|
|
719
|
+
// with the SAME clean -32700 parse-error contract as bad JSON / a streamed over-cap body, then
|
|
720
|
+
// close the connection (draining the unread body without buffering - see sendJsonAndClose).
|
|
721
|
+
// Chunked / Content-Length-less requests skip this branch and fall through to readBody's
|
|
722
|
+
// streaming cap (which still bounds memory and keeps the server alive).
|
|
723
|
+
const clHeader = req.headers && req.headers['content-length'];
|
|
724
|
+
if (clHeader != null) {
|
|
725
|
+
const declared = Number(clHeader);
|
|
726
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
727
|
+
log('POST /mcp rejected by Content-Length pre-check:', declared, '>', MAX_BODY_BYTES);
|
|
728
|
+
return sendJsonAndClose(
|
|
729
|
+
req,
|
|
730
|
+
res,
|
|
731
|
+
200,
|
|
732
|
+
makeError(ERR.PARSE, 'Parse error: request body too large')
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
let raw;
|
|
738
|
+
try {
|
|
739
|
+
raw = await readBody(req);
|
|
740
|
+
} catch (err) {
|
|
741
|
+
// Oversized / aborted body -> the SAME flush-then-close -32700 as the Content-Length pre-check
|
|
742
|
+
// (readBody no longer destroys the request, so the reply can actually reach the client; a
|
|
743
|
+
// genuinely-dead socket is fine too - sendJsonAndClose's writes are defensively wrapped).
|
|
744
|
+
log('POST /mcp body read failed:', (err && err.message) || String(err));
|
|
745
|
+
return sendJsonAndClose(req, res, 200, makeError(ERR.PARSE, 'Parse error: ' + ((err && err.message) || 'bad body')));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
let msg;
|
|
749
|
+
try {
|
|
750
|
+
msg = JSON.parse(raw);
|
|
751
|
+
} catch (_e) {
|
|
752
|
+
// Bad JSON -> JSON-RPC parse error at HTTP 200 (the body is the error object).
|
|
753
|
+
return sendJson(res, 200, makeError(ERR.PARSE, 'Parse error: invalid JSON body'));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── JSON-RPC batch ─────────────────────────────────────────────────────────────
|
|
757
|
+
// HTTP is sessionless, so the header is the only era evidence. ABSENT passes - 2025-03-26
|
|
758
|
+
// (the one revision that defines batching) PREDATES the MCP-Protocol-Version header, which
|
|
759
|
+
// 2025-06-18 introduced; an explicit 2025-03-26 passes; any other value lets the array fall
|
|
760
|
+
// through to handleMessage's frozen -32600. Mechanics per the 2025-03-26 Transports spec:
|
|
761
|
+
// solely-notifications -> 202 no body; any requests -> one application/json array; an empty
|
|
762
|
+
// batch -> 400 + the id-less -32600 ("server cannot accept the input").
|
|
763
|
+
if (Array.isArray(msg)) {
|
|
764
|
+
const hv = req.headers && req.headers['mcp-protocol-version'];
|
|
765
|
+
if (hv === undefined || hv === '2025-03-26') {
|
|
766
|
+
// A MODERN-shaped member cannot legally travel in a batch: batching exists ONLY in
|
|
767
|
+
// 2025-03-26, which predates the modern era entirely. Refusing here closes an era-mixing
|
|
768
|
+
// hole (0.7.0): this branch runs BEFORE bodyIsModern below, so a modern body wrapped in a
|
|
769
|
+
// one-element array used to skip validateModernHeaders, the Mcp-Param cross-check and the
|
|
770
|
+
// disconnect-cancel accounting, and still be SERVED with full modern semantics because
|
|
771
|
+
// handleMessage re-detects the era from the body.
|
|
772
|
+
if (msg.some((m) => modern.isModernRequest(m))) {
|
|
773
|
+
return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
|
|
774
|
+
'modern-era messages cannot be batched - JSON-RPC batching is 2025-03-26 only; send each modern request as its own POST'));
|
|
775
|
+
}
|
|
776
|
+
// A batch is executed member-by-member with a gated tool call possible in every slot, so an
|
|
777
|
+
// uncapped array is one POST buying unbounded sequential execution (0.7.0).
|
|
778
|
+
if (msg.length > MAX_BATCH_MEMBERS) {
|
|
779
|
+
return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
|
|
780
|
+
`batch too large (${msg.length} members, max ${MAX_BATCH_MEMBERS}) - split it`));
|
|
781
|
+
}
|
|
782
|
+
let batched;
|
|
783
|
+
try {
|
|
784
|
+
batched = await handleBatch(build, msg);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
log('handleBatch threw (should not):', (err && err.stack) || String(err));
|
|
787
|
+
return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error'));
|
|
788
|
+
}
|
|
789
|
+
if (batched == null) {
|
|
790
|
+
res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
|
|
791
|
+
return res.end();
|
|
792
|
+
}
|
|
793
|
+
if (!Array.isArray(batched)) return sendJson(res, 400, batched); // empty batch
|
|
794
|
+
return sendJson(res, 200, batched);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── Modern era (2026-07-28): strict header enforcement + the listen stream ────────────────
|
|
799
|
+
// Era detection is by BODY (per-request _meta - the spec's dual-era rule); initialize always
|
|
800
|
+
// selects legacy. Legacy requests skip ALL of this and flow byte-for-byte as 0.5.0 (their
|
|
801
|
+
// optional MCP-Protocol-Version header stays leniently ignored - pre-modern values carry no
|
|
802
|
+
// enforcement contract for a dual-era server).
|
|
803
|
+
const bodyIsModern = msg && msg.method !== 'initialize' && modern.isModernRequest(msg);
|
|
804
|
+
const headerVersion = req.headers && req.headers['mcp-protocol-version'];
|
|
805
|
+
if (bodyIsModern) {
|
|
806
|
+
// Headers are REQUIRED for compliance on modern requests: MCP-Protocol-Version, Mcp-Method,
|
|
807
|
+
// and (tools/call | resources/read | prompts/get) Mcp-Name must all mirror the body.
|
|
808
|
+
// A mismatch is 400 + HeaderMismatch (-32020) per spec.
|
|
809
|
+
const hErr = modern.validateModernHeaders(req.headers, msg);
|
|
810
|
+
if (hErr) {
|
|
811
|
+
logger.log({ type: 'client', event: 'modern-header-reject', method: msg.method });
|
|
812
|
+
return sendJson(res, hErr.httpStatus, makeErrorWithId(msg.id, hErr.code, hErr.message));
|
|
813
|
+
}
|
|
814
|
+
const vErr = modern.validateModernRequest(msg);
|
|
815
|
+
if (vErr) {
|
|
816
|
+
return sendJson(res, vErr.httpStatus, makeErrorWithId(msg.id, vErr.code, vErr.message, vErr.data));
|
|
817
|
+
}
|
|
818
|
+
// 4.3: x-mcp-header parameter mirroring - the 2026-07-28 Streamable HTTP spec's Server
|
|
819
|
+
// Validation MUST: a PRESENT Mcp-Param-{name} header must equal the body argument at the
|
|
820
|
+
// advertised annotation's path (split-source-of-truth guard: a proxy routing on the
|
|
821
|
+
// header while we execute on the body). Values ride the 4.2 sentinel. Absent headers
|
|
822
|
+
// pass (mirroring is the CLIENT's obligation); unknown Mcp-Param names are ignored.
|
|
823
|
+
// The numeric 42.0==42 note is a SHOULD - scoped out; primitives compare canonically.
|
|
824
|
+
if (msg.method === 'tools/call' && msg.params && typeof msg.params.name === 'string') {
|
|
825
|
+
const pErr = validateMcpParamHeaders(build, req.headers, msg.params);
|
|
826
|
+
if (pErr) return sendJson(res, 400, makeErrorWithId(msg.id, modern.ERR_MODERN.HEADER_MISMATCH, pErr));
|
|
827
|
+
}
|
|
828
|
+
// subscriptions/listen is transport-owned: the POST's response IS the SSE stream.
|
|
829
|
+
if (msg.method === 'subscriptions/listen') {
|
|
830
|
+
if (msg.id === undefined || msg.id === null) {
|
|
831
|
+
// A listen with no id can never be acknowledged or closed - reject it clearly.
|
|
832
|
+
return sendJson(res, 400, makeError(ERR.INVALID_REQUEST, 'subscriptions/listen requires an id'));
|
|
833
|
+
}
|
|
834
|
+
return openListenStream(req, res, msg);
|
|
835
|
+
}
|
|
836
|
+
} else if (headerVersion === modern.MODERN_PROTOCOL_VERSION) {
|
|
837
|
+
// The header announces the modern era but the request is not a modern one. Two cases, so the
|
|
838
|
+
// message is TRUTHFUL rather than always blaming a missing _meta:
|
|
839
|
+
// - `initialize` carrying a modern header: initialize ALWAYS selects the legacy era, so the
|
|
840
|
+
// modern header is contradictory - tell them to drop it on the fallback handshake.
|
|
841
|
+
// - any other body with no modern _meta: a genuine header/_meta mismatch.
|
|
842
|
+
const isInit = msg && msg.method === 'initialize';
|
|
843
|
+
const message = isInit
|
|
844
|
+
? 'Header mismatch: `initialize` always selects the legacy era - drop the MCP-Protocol-Version: ' +
|
|
845
|
+
modern.MODERN_PROTOCOL_VERSION + ' header on the legacy handshake (or send a modern request instead)'
|
|
846
|
+
: 'Header mismatch: MCP-Protocol-Version announces ' + modern.MODERN_PROTOCOL_VERSION +
|
|
847
|
+
' but the body carries no matching _meta protocolVersion';
|
|
848
|
+
return sendJson(res, 400, makeErrorWithId(msg && msg.id, modern.ERR_MODERN.HEADER_MISMATCH, message));
|
|
849
|
+
} else if (headerVersion !== undefined && !modern.supportedVersions().includes(headerVersion)) {
|
|
850
|
+
// The 2025-06-18 MUST: "a request with an invalid or unsupported
|
|
851
|
+
// MCP-Protocol-Version" gets 400. Only PRESENT-and-unknown values land here: absent stays
|
|
852
|
+
// the frozen lenient pass (the spec's own default-assumption case + every pre-2025-06-18
|
|
853
|
+
// client), supported legacy values pass, and the modern value is owned by the
|
|
854
|
+
// HeaderMismatch arm above (it demands matching modern _meta). Composition with 2.3:
|
|
855
|
+
// an array with a SUPPORTED non-batching header keeps the frozen -32600 (the batch gate
|
|
856
|
+
// declined, this arm passes); an array with an UNKNOWN header lands here and 400s - it is
|
|
857
|
+
// still a request with an invalid header, exactly what the MUST covers.
|
|
858
|
+
// -32022 (UnsupportedProtocolVersion), NOT the generic -32600. Same condition, same code,
|
|
859
|
+
// wherever it is detected: the _meta path already answers -32022 with supported/requested,
|
|
860
|
+
// and a client should not have to know WHERE the server noticed to parse the reply. The
|
|
861
|
+
// data payload is the useful part - it names the versions to retry with, so a client can
|
|
862
|
+
// renegotiate without a human reading the message string. Unlike the _meta path (whose one
|
|
863
|
+
// lawful value is the modern version) the HEADER may carry any era we speak, so the full
|
|
864
|
+
// supported set is advertised here.
|
|
865
|
+
return sendJson(res, 400, makeErrorWithId(msg && msg.id, modern.ERR_MODERN.UNSUPPORTED_PROTOCOL_VERSION,
|
|
866
|
+
`Unsupported protocol version: MCP-Protocol-Version header '${headerVersion}' (supported: ` +
|
|
867
|
+
modern.supportedVersions().join(', ') + ')',
|
|
868
|
+
{ supported: modern.supportedVersions(), requested: headerVersion }));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 4.4 - disconnect-as-cancellation (DECIDED; the trade STATED): the modern spec's
|
|
872
|
+
// stream-close-MUST is written for per-request SSE streams; we answer plain JSON, so the
|
|
873
|
+
// only close signal is the bare POST socket - ambiguous (proxy timeout, keep-alive reset,
|
|
874
|
+
// real client abort). Decided: treat a premature close as cancellation anyway, gated on
|
|
875
|
+
// modern tools/call. ACCEPTED CONSEQUENCE, documented: a proxy-killed socket spuriously
|
|
876
|
+
// cancels the RESPONSE of a live gated call (the upstream work stops only where a cancel
|
|
877
|
+
// path exists - sessionless HTTP has no id translation to relay one). Riders honoured:
|
|
878
|
+
// the cancellation is audit-logged and counted on /health for a client that comes back.
|
|
879
|
+
let modernDisconnected = false;
|
|
880
|
+
if (bodyIsModern && msg.method === 'tools/call') {
|
|
881
|
+
res.once('close', () => {
|
|
882
|
+
if (res.writableEnded) return; // normal completion - not a cancellation
|
|
883
|
+
modernDisconnected = true;
|
|
884
|
+
disconnectCancels += 1;
|
|
885
|
+
logger.log({
|
|
886
|
+
type: 'client', event: 'disconnect-cancel', method: msg.method,
|
|
887
|
+
tool: msg.params && msg.params.name, id: msg.id,
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
let response;
|
|
893
|
+
try {
|
|
894
|
+
response = await handleMessage(build, msg);
|
|
895
|
+
} catch (err) {
|
|
896
|
+
// handleMessage is contracted never to throw, but the transport must survive even if a
|
|
897
|
+
// wiring bug breaks that contract - a single bad request can never crash the host.
|
|
898
|
+
log('handleMessage threw (should not):', (err && err.stack) || String(err));
|
|
899
|
+
return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err)));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// 4.4: a cancelled request gets NO further messages (MUST) - the socket is gone anyway;
|
|
903
|
+
// returning here keeps the contract explicit and the counter honest.
|
|
904
|
+
if (modernDisconnected) return;
|
|
905
|
+
|
|
906
|
+
// A notification (no reply) -> 202 Accepted, no body. A request -> 200 with the JSON-RPC result.
|
|
907
|
+
if (response == null) {
|
|
908
|
+
res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
|
|
909
|
+
return res.end();
|
|
910
|
+
}
|
|
911
|
+
// Modern-era unknown method pairs -32601 with HTTP 404 (spec: the status IS the era-detection
|
|
912
|
+
// signal distinguishing a modern server from a legacy one). Legacy bodies keep the
|
|
913
|
+
// JSON-RPC-over-HTTP convention of 200-with-error.
|
|
914
|
+
if (modern.isModernRequest(msg) && response && response.error && response.error.code === -32601) {
|
|
915
|
+
return sendJson(res, 404, response);
|
|
916
|
+
}
|
|
917
|
+
// 4.1 - the modern-error status map: in-handler mints inherit their spec-mandated status by
|
|
918
|
+
// CODE. -32602 is a MUST-400 (2026-07-28 base protocol, Error Handling); the three modern codes
|
|
919
|
+
// (-32020/-32021/-32022) are 400 wherever they arise (the seam validators already 400 their
|
|
920
|
+
// own). Legacy bodies keep the JSON-RPC-over-HTTP 200-with-error convention untouched.
|
|
921
|
+
if (modern.isModernRequest(msg) && response && response.error &&
|
|
922
|
+
(response.error.code === -32602 || response.error.code === -32020 ||
|
|
923
|
+
response.error.code === -32021 || response.error.code === -32022)) {
|
|
924
|
+
return sendJson(res, 400, response);
|
|
925
|
+
}
|
|
926
|
+
// NARROW remap for the modern-only refusal ONLY: a legacy body refused under
|
|
927
|
+
// serveLegacy:false carries the mint's data.policy marker - give it the same 404 a real
|
|
928
|
+
// modern-only server presents a probing legacy client. Gated on the exact marker so every
|
|
929
|
+
// ORDINARY legacy -32601 keeps its frozen 200-with-error (the golden diff enforces that).
|
|
930
|
+
if (response && response.error && response.error.code === -32601 &&
|
|
931
|
+
response.error.data && response.error.data.policy === 'modern-only') {
|
|
932
|
+
return sendJson(res, 404, response);
|
|
933
|
+
}
|
|
934
|
+
return sendJson(res, 200, response);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* validateMcpParamHeaders - the 4.3 Server-Validation check. Resolves the ADVERTISED def for
|
|
939
|
+
* params.name off the live surface (handleToolsList - the same assembly the client saw, so
|
|
940
|
+
* the annotation map can never disagree with the advertisement; the ingest hygiene in the
|
|
941
|
+
* aggregator has already stripped invalid annotations). Walks the pure-`properties` chains
|
|
942
|
+
* collecting x-mcp-header names, then compares each PRESENT `mcp-param-<name>` header
|
|
943
|
+
* (sentinel-decoded) against the body argument at that path. Returns a mismatch message, or
|
|
944
|
+
* null when valid. NEVER throws - a lookup failure validates nothing (fail open here: the
|
|
945
|
+
* body is still the single execution source of truth downstream).
|
|
946
|
+
*/
|
|
947
|
+
function validateMcpParamHeaders(build, headers, params) {
|
|
948
|
+
try {
|
|
949
|
+
const list = serverModule.handleToolsList(build.protocol, build.aggregator,
|
|
950
|
+
{ registry: build.registry, toolStatePath: build.toolStatePath });
|
|
951
|
+
const def = ((list && list.tools) || []).find((t) => t && t.name === params.name);
|
|
952
|
+
const schema = def && def.inputSchema;
|
|
953
|
+
if (!schema || typeof schema !== 'object') return null;
|
|
954
|
+
const annotations = []; // { header: lowercased header name, path: [prop, ...] }
|
|
955
|
+
(function walk(node, pathSoFar) {
|
|
956
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
|
957
|
+
const props = node.properties;
|
|
958
|
+
if (!props || typeof props !== 'object' || Array.isArray(props)) return;
|
|
959
|
+
for (const [key, sub] of Object.entries(props)) {
|
|
960
|
+
if (!sub || typeof sub !== 'object' || Array.isArray(sub)) continue;
|
|
961
|
+
const p = pathSoFar.concat(key);
|
|
962
|
+
if (typeof sub['x-mcp-header'] === 'string') {
|
|
963
|
+
annotations.push({ header: 'mcp-param-' + sub['x-mcp-header'].toLowerCase(), path: p });
|
|
964
|
+
}
|
|
965
|
+
walk(sub, p); // nested objects stay on the chain via their own `properties`
|
|
966
|
+
}
|
|
967
|
+
})(schema, []);
|
|
968
|
+
const args = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
|
|
969
|
+
for (const a of annotations) {
|
|
970
|
+
const raw = headers && headers[a.header];
|
|
971
|
+
if (raw === undefined) continue; // absent -> the client mirrored nothing; nothing to cross-check
|
|
972
|
+
const headerValue = modern.decodeHeaderSentinel(String(raw));
|
|
973
|
+
let bodyValue = args;
|
|
974
|
+
for (const k of a.path) {
|
|
975
|
+
bodyValue = bodyValue && typeof bodyValue === 'object' ? bodyValue[k] : undefined;
|
|
976
|
+
}
|
|
977
|
+
if (bodyValue === undefined || String(bodyValue) !== headerValue) {
|
|
978
|
+
return `Header mismatch: ${a.header} value '${String(raw)}' does not match the body argument at '${a.path.join('.')}'`;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return null;
|
|
982
|
+
} catch (_e) {
|
|
983
|
+
return null; // never let validation break the request path
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* The single node:http request listener. Dispatches by method + path. async (it may await the
|
|
989
|
+
* OAuth gate). NEVER throws - start() wraps it so a rejection can't escape into the http server.
|
|
990
|
+
*
|
|
991
|
+
* Note on MCP-Protocol-Version: a client MAY send an `MCP-Protocol-Version` header on post-
|
|
992
|
+
* initialize requests. We read it leniently (unknown headers are ignored) and do not 400 on a
|
|
993
|
+
* mismatch - the version is negotiated in `initialize`. Strict per-request enforcement is a
|
|
994
|
+
* roadmap item; rejecting working clients over a header is the worse default for a local gateway.
|
|
995
|
+
*/
|
|
996
|
+
async function onRequest(req, res) {
|
|
997
|
+
// Auth config is read FRESH per request (default OFF). When OFF the transport is loopback-only
|
|
998
|
+
// and unauthenticated (the original behaviour). When ON, a valid bearer token is the boundary.
|
|
999
|
+
const authCfg = authConfig.getConfig();
|
|
1000
|
+
const authEnabled = authCfg.enabled === true;
|
|
1001
|
+
|
|
1002
|
+
// DNS-rebinding / non-loopback guard. With auth DISABLED the bind address is the ONLY boundary,
|
|
1003
|
+
// so we reject any non-loopback Host (defence-in-depth, unchanged). With auth ENABLED the token
|
|
1004
|
+
// is the boundary - remote clients legitimately present a non-loopback Host - so we skip this
|
|
1005
|
+
// check and let the OAuth gate below authenticate every protected route.
|
|
1006
|
+
if (!authEnabled && !isLoopbackHost(req.headers && req.headers.host)) {
|
|
1007
|
+
return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Host'));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Origin guard: with auth OFF, a PRESENT non-loopback Origin is refused - the
|
|
1011
|
+
// browser-attached Origin is the one header a third-party page cannot forge, so this closes the
|
|
1012
|
+
// DNS-rebinding / cross-origin-CSRF hole the bind address alone cannot (a browser on ANY site
|
|
1013
|
+
// can reach 127.0.0.1). Absent Origin passes (non-browser clients). Policy + parsing rules
|
|
1014
|
+
// live on isLoopbackOrigin(). With auth ON the token is the boundary, exactly like the Host
|
|
1015
|
+
// guard above.
|
|
1016
|
+
if (!authEnabled && !isLoopbackOrigin(req.headers && req.headers.origin)) {
|
|
1017
|
+
return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Origin'));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Parse the path only (ignore query) without pulling in node:url's WHATWG parser overhead.
|
|
1021
|
+
const rawUrl = req.url || '/';
|
|
1022
|
+
const qIndex = rawUrl.indexOf('?');
|
|
1023
|
+
const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
|
1024
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
1025
|
+
|
|
1026
|
+
try {
|
|
1027
|
+
// RFC 9728 discovery document - the ONE unauthenticated route, and only when auth is enabled.
|
|
1028
|
+
// A client hits this (or follows the `resource_metadata` hint from a 401) to learn which
|
|
1029
|
+
// authorization server issues tokens for this gateway.
|
|
1030
|
+
if (method === 'GET' && pathName === WELL_KNOWN_PRM) {
|
|
1031
|
+
if (!authEnabled) return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: auth is not enabled'));
|
|
1032
|
+
return sendJson(res, 200, protectedResourceMetadata(authCfg));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// OAuth gate: when auth is enabled, EVERY remaining route requires a valid bearer token -
|
|
1036
|
+
// /mcp (POST + SSE) and /health alike. The discovery document above is the sole exception.
|
|
1037
|
+
if (authEnabled) {
|
|
1038
|
+
const allowed = await passesAuth(req, res, authCfg);
|
|
1039
|
+
if (!allowed) return; // a 401/403/500 challenge has already been sent
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (method === 'POST' && pathName === '/mcp') {
|
|
1043
|
+
// handleMcpPost is async + self-contained (it owns its try/catch); a rejection here would
|
|
1044
|
+
// be unusual but we still guard so an unhandled rejection can't escape the listener.
|
|
1045
|
+
handleMcpPost(req, res).catch((err) => {
|
|
1046
|
+
log('handleMcpPost rejected (should not):', (err && err.stack) || String(err));
|
|
1047
|
+
try {
|
|
1048
|
+
sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error'));
|
|
1049
|
+
} catch (_e) {
|
|
1050
|
+
/* response may be partly written; nothing more to do */
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Streamable-HTTP standard: a GET to the SINGLE /mcp endpoint with Accept: text/event-stream
|
|
1057
|
+
// opens the server->client SSE stream. We serve that here, AND keep GET /mcp/sse as a working
|
|
1058
|
+
// alias (the older HTTP+SSE shape, deprecated but still supported). Both routes
|
|
1059
|
+
// hand off to the same openSse() - the loopback/auth guard above already gated the request.
|
|
1060
|
+
if (method === 'GET' && (pathName === '/mcp' || pathName === '/mcp/sse')) {
|
|
1061
|
+
// MODERN-ONLY POLICY (0.7.0): the GET-SSE stream is a LEGACY-era channel (modern uses
|
|
1062
|
+
// subscriptions/listen on POST) - answer the spec's own compatibility shape, 405 Method
|
|
1063
|
+
// Not Allowed + Allow: POST (the Streamable HTTP spec's rule; a MUST across three revisions).
|
|
1064
|
+
// Replaces the old 400/-32020 - HeaderMismatch was an allocation-policy misuse here too.
|
|
1065
|
+
if (build && build.serveLegacy === false) {
|
|
1066
|
+
res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1067
|
+
return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
|
|
1068
|
+
'modern-only gateway (serveLegacy:false): the legacy SSE channel is disabled - POST subscriptions/listen instead')));
|
|
1069
|
+
}
|
|
1070
|
+
return openSse(req, res);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// 4.5: modern-only mode answers DELETE on the MCP endpoint with 405 too (older-transport
|
|
1074
|
+
// session teardown - the spec's compatibility table). Dual-era keeps the frozen 404.
|
|
1075
|
+
if (method === 'DELETE' && (pathName === '/mcp' || pathName === '/mcp/sse') &&
|
|
1076
|
+
build && build.serveLegacy === false) {
|
|
1077
|
+
res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1078
|
+
return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
|
|
1079
|
+
'modern-only gateway (serveLegacy:false): sessions are not a modern mechanism - POST requests instead')));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (method === 'GET' && pathName === '/health') {
|
|
1083
|
+
return sendJson(res, 200, health());
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Unknown route.
|
|
1087
|
+
return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: ' + method + ' ' + pathName));
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
// Last-resort guard: a synchronous throw in routing becomes a 500 JSON, never a crash.
|
|
1090
|
+
log('onRequest threw:', (err && err.stack) || String(err));
|
|
1091
|
+
try {
|
|
1092
|
+
sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error'));
|
|
1093
|
+
} catch (_e) {
|
|
1094
|
+
/* response may already be committed */
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ── Health snapshot ───────────────────────────────────────────────────────────────────────
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* health - a SYNCHRONOUS snapshot of the host. `upstreamsConnected` + `toolsExposed` are read
|
|
1103
|
+
* from the live aggregator (defensively - a degraded build has a null aggregator). NEVER throws.
|
|
1104
|
+
*
|
|
1105
|
+
* `connected` + `failed` mirror the LAST connectAll() (start/reload) so a per-upstream connect
|
|
1106
|
+
* FAILURE is visible to the renderer instead of only landing on stderr. Both are fresh copies so
|
|
1107
|
+
* a caller cannot mutate the internal snapshot. `connected` is the list of upstream ids that
|
|
1108
|
+
* connected; `failed` is [{ id, error }] for the ones that did not (isolation guard, spawn
|
|
1109
|
+
* failure, handshake/listTools failure, ...). When the host is down both are empty (stop() resets).
|
|
1110
|
+
*
|
|
1111
|
+
* @returns {{ ok:boolean, server:object, protocolVersion:string, url:(string|null),
|
|
1112
|
+
* upstreamsConnected:number, upstreams:number, toolsExposed:number,
|
|
1113
|
+
* connected:string[], failed:Array<{id:string, error:string}> }}
|
|
1114
|
+
*/
|
|
1115
|
+
function health() {
|
|
1116
|
+
let toolsExposed = 0;
|
|
1117
|
+
let upstreamsConnected = 0;
|
|
1118
|
+
const agg = build && build.aggregator;
|
|
1119
|
+
if (agg) {
|
|
1120
|
+
try {
|
|
1121
|
+
if (typeof agg.exposedToolDefinitions === 'function') {
|
|
1122
|
+
const defs = agg.exposedToolDefinitions();
|
|
1123
|
+
if (Array.isArray(defs)) toolsExposed = defs.length;
|
|
1124
|
+
}
|
|
1125
|
+
} catch (_e) {
|
|
1126
|
+
/* a failing aggregator reports 0, never throws out of health */
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
if (typeof agg.toolsByUpstream === 'function') {
|
|
1130
|
+
// toolsByUpstream() returns a clone map of upstreamId -> tools for CONNECTED upstreams.
|
|
1131
|
+
upstreamsConnected = Object.keys(agg.toolsByUpstream() || {}).length;
|
|
1132
|
+
}
|
|
1133
|
+
} catch (_e) {
|
|
1134
|
+
/* ignore - report 0 */
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// The advertised meta-tool count, DERIVED from the protocol so it never drifts from what
|
|
1138
|
+
// tools/list actually returns (all four meta-tools - list, instructions, run, howto - are
|
|
1139
|
+
// advertised; deriving rather than hardcoding keeps health honest through future changes).
|
|
1140
|
+
// ACTIVE WRAP -> liveness only. The /mcp surface on this very port impersonates the wrapped
|
|
1141
|
+
// server; the full body (server:toolfunnel, versions, upstream ids, connect-failure strings)
|
|
1142
|
+
// would hand any unauthenticated loopback GET the funnel's existence wholesale - undoing the
|
|
1143
|
+
// error-text neutralisation the wrap paths pay for.
|
|
1144
|
+
try {
|
|
1145
|
+
if (build && typeof build.wrapActive === 'function' && build.wrapActive()) {
|
|
1146
|
+
return { ok: started === true && stopping === false };
|
|
1147
|
+
}
|
|
1148
|
+
} catch (_e) { /* degraded flag -> serve the full body */ }
|
|
1149
|
+
let metaCount = 0;
|
|
1150
|
+
try {
|
|
1151
|
+
const proto = build && build.protocol;
|
|
1152
|
+
const defs = proto && typeof proto.toolDefinitions === 'function' ? proto.toolDefinitions() : null;
|
|
1153
|
+
if (Array.isArray(defs)) metaCount = defs.length;
|
|
1154
|
+
} catch (_e) {
|
|
1155
|
+
/* degraded build -> metaCount stays 0 */
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
ok: started === true && stopping === false,
|
|
1159
|
+
server: SERVER_INFO,
|
|
1160
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1161
|
+
// Dual-era: every revision this host speaks (modern first). `protocolVersion` above stays
|
|
1162
|
+
// the legacy string for backward compatibility with 0.5.0 health consumers (the UI).
|
|
1163
|
+
protocolVersions: modern.supportedVersions(),
|
|
1164
|
+
// Open modern subscriptions/listen streams (legacy SSE clients are sseClientCount).
|
|
1165
|
+
listenStreams: modernListeners.size,
|
|
1166
|
+
// 4.4 rider: modern POST tools/call sockets that closed before the response was written -
|
|
1167
|
+
// treated as cancellation (audit-logged); a returning client discovers the count here.
|
|
1168
|
+
disconnectCancels,
|
|
1169
|
+
// The MCP endpoint the CLI points at (includes the /mcp path) - matches the .mcp.json
|
|
1170
|
+
// target and what the status tooltip should show, not the bare origin.
|
|
1171
|
+
url: boundPort == null ? null : currentUrl() + '/mcp',
|
|
1172
|
+
upstreamsConnected,
|
|
1173
|
+
// `upstreams` is an alias of upstreamsConnected for the renderer status tooltip
|
|
1174
|
+
// (the renderer reads snap.upstreams / snap.url for its title text).
|
|
1175
|
+
upstreams: upstreamsConnected,
|
|
1176
|
+
toolsExposed,
|
|
1177
|
+
// `tools` = what the CLI sees in tools/list: the four advertised meta-tools
|
|
1178
|
+
// (toolfunnel_list_tools/tool_instructions/run_tool/howto) + the curated-direct surface.
|
|
1179
|
+
// DERIVED from protocol.toolDefinitions() (metaCount) so it never drifts from the real
|
|
1180
|
+
// advertised count. The register's long tail lives behind toolfunnel_list_tools, not as
|
|
1181
|
+
// separate tools/list entries (a fresh host's status cell reads "up.4").
|
|
1182
|
+
tools: metaCount + toolsExposed,
|
|
1183
|
+
// Per-upstream connect outcome from the last connectAll() - makes silent connect-failures
|
|
1184
|
+
// visible. Fresh copies so the caller can never mutate the internal snapshot.
|
|
1185
|
+
connected: lastConnect.connected.slice(),
|
|
1186
|
+
failed: lastConnect.failed.map((f) => ({ id: f.id, error: f.error })),
|
|
1187
|
+
// OAuth resource-server status (no secrets - issuer/audience live in the well-known doc):
|
|
1188
|
+
// whether auth is enabled, whether the optional jose dependency is installed, and whether the
|
|
1189
|
+
// current config is coherent enough to enforce (misconfigured: configError() reason present).
|
|
1190
|
+
auth: authSnapshot(),
|
|
1191
|
+
// Lightweight in-memory observability: process-lifetime tools/call counters (total + per-tool
|
|
1192
|
+
// + error counts). Resets on restart; complementary to the on-disk JSONL audit log.
|
|
1193
|
+
metrics: metricsSnapshot(),
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** A defensive copy of the in-memory tool-call counters. NEVER throws. */
|
|
1198
|
+
function metricsSnapshot() {
|
|
1199
|
+
try {
|
|
1200
|
+
return metrics.snapshot();
|
|
1201
|
+
} catch (_e) {
|
|
1202
|
+
return { startedAt: '', calls: 0, errors: 0, byTool: {} };
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** A no-secrets snapshot of the OAuth gate state for /health + the UI. NEVER throws. */
|
|
1207
|
+
function authSnapshot() {
|
|
1208
|
+
try {
|
|
1209
|
+
const cfg = authConfig.getConfig();
|
|
1210
|
+
return {
|
|
1211
|
+
enabled: cfg.enabled === true,
|
|
1212
|
+
joseInstalled: isJoseInstalled(),
|
|
1213
|
+
configError: cfg.enabled ? authConfig.configError(cfg) : null,
|
|
1214
|
+
};
|
|
1215
|
+
} catch (_e) {
|
|
1216
|
+
return { enabled: false, joseInstalled: false, configError: null };
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────────────────────
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* start - build the protocol+aggregator, connect every enabled upstream, then bind HTTP. On
|
|
1224
|
+
* EADDRINUSE the returned promise REJECTS with a clear error (the caller decides what to do) -
|
|
1225
|
+
* it does NOT crash the process. Resolves to { port, url } once listening.
|
|
1226
|
+
*
|
|
1227
|
+
* Port 0 -> an OS-assigned ephemeral port; the actual port is read back from server.address().
|
|
1228
|
+
* @returns {Promise<{ port:number, url:string }>}
|
|
1229
|
+
*/
|
|
1230
|
+
async function start() {
|
|
1231
|
+
if (started) {
|
|
1232
|
+
// Idempotent-ish: a second start() on a running host just reports the live binding.
|
|
1233
|
+
return { port: boundPort, url: currentUrl() };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ── Fail-fast safety guards (BEFORE building/binding) ───────────────────────────────────────
|
|
1237
|
+
// These throw a clear, typed error the caller surfaces - they NEVER start a misconfigured host.
|
|
1238
|
+
const authCfg = authConfig.getConfig();
|
|
1239
|
+
if (authCfg.enabled) {
|
|
1240
|
+
// OAuth on but the dependency missing -> refuse (else every request would fail closed anyway,
|
|
1241
|
+
// but failing at start with an actionable message is far better than a dead host).
|
|
1242
|
+
if (!isJoseInstalled()) {
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
'OAuth is enabled but the "jose" dependency is not installed - run `toolfunnel install-oauth` ' +
|
|
1245
|
+
'(or click Install in the UI Auth panel), or disable auth in auth/auth.config.json.'
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
const cfgErr = authConfig.configError(authCfg);
|
|
1249
|
+
if (cfgErr) throw new Error('OAuth is enabled but misconfigured: ' + cfgErr);
|
|
1250
|
+
} else if (!isLoopbackHost(host)) {
|
|
1251
|
+
// Binding off-loopback with NO auth would expose an UNAUTHENTICATED gateway to the network -
|
|
1252
|
+
// exactly the footgun the loopback default exists to prevent. Require auth before exposing it.
|
|
1253
|
+
throw new Error(
|
|
1254
|
+
`refusing to bind non-loopback host "${host}" without OAuth enabled - enable auth ` +
|
|
1255
|
+
'(and install jose) before exposing the gateway off localhost.'
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Build + connect upstreams BEFORE binding so tools/list advertises the curated surface from
|
|
1260
|
+
// the very first request. connectAll() NEVER throws (failures land in failed[]); with the
|
|
1261
|
+
// default EMPTY expose.json it is an instant no-op.
|
|
1262
|
+
build = buildFactory();
|
|
1263
|
+
wireAggregatorNotify();
|
|
1264
|
+
if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
|
|
1265
|
+
try {
|
|
1266
|
+
const res = await build.aggregator.connectAll();
|
|
1267
|
+
recordConnect(res); // surface connect outcome (connected[]/failed[]) via health()
|
|
1268
|
+
if (res && Array.isArray(res.failed) && res.failed.length > 0) {
|
|
1269
|
+
log('connectAll: ' + res.failed.length + ' upstream(s) failed:',
|
|
1270
|
+
res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
|
|
1271
|
+
}
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
|
|
1274
|
+
log('connectAll threw (ignored):', (err && err.message) || String(err));
|
|
1275
|
+
}
|
|
1276
|
+
} else {
|
|
1277
|
+
recordConnect(null); // no aggregator -> no upstreams; report empty connect state
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// onRequest is async (it may await the OAuth gate); wrap it so a rejection can NEVER escape into
|
|
1281
|
+
// the http server as an unhandled rejection - it degrades to a clean 500 instead.
|
|
1282
|
+
httpServer = http.createServer((req, res) => {
|
|
1283
|
+
onRequest(req, res).catch((err) => {
|
|
1284
|
+
log('onRequest rejected (should not):', (err && err.stack) || String(err));
|
|
1285
|
+
try { sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error')); } catch (_e) { /* committed */ }
|
|
1286
|
+
});
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
return new Promise((resolve, reject) => {
|
|
1290
|
+
// A bind failure (EADDRINUSE, EACCES) surfaces as an 'error' event before 'listening'. We
|
|
1291
|
+
// reject ONCE and detach the listener so a later runtime error can't double-settle.
|
|
1292
|
+
const onListenError = (err) => {
|
|
1293
|
+
httpServer.removeListener('listening', onListening);
|
|
1294
|
+
httpServer = null;
|
|
1295
|
+
// connectAll() ran BEFORE listen() and may have spawned upstream children; a bind failure
|
|
1296
|
+
// here would otherwise orphan them. Tear the aggregator down before rejecting. closeAll()
|
|
1297
|
+
// never throws; fire-and-forget (this is a sync 'error' handler) - it's cleanup only.
|
|
1298
|
+
const agg = build && build.aggregator;
|
|
1299
|
+
build = null; // nothing bound - drop the half-built state so a retry rebuilds cleanly
|
|
1300
|
+
if (agg && typeof agg.closeAll === 'function') {
|
|
1301
|
+
try { agg.closeAll(); } catch (_e) { /* never throw */ }
|
|
1302
|
+
}
|
|
1303
|
+
reject(err); // clear, typed error (err.code === 'EADDRINUSE' etc.) - caller decides
|
|
1304
|
+
};
|
|
1305
|
+
const onListening = () => {
|
|
1306
|
+
httpServer.removeListener('error', onListenError);
|
|
1307
|
+
const addr = httpServer.address();
|
|
1308
|
+
boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
|
|
1309
|
+
started = true;
|
|
1310
|
+
stopping = false;
|
|
1311
|
+
// Arm config hot-reload now the build is live + bound. Pass a GETTER so the reloaders
|
|
1312
|
+
// always target the CURRENT build (reload() rebuilds it); send = pushToSse so a
|
|
1313
|
+
// hook/register/expose change broadcasts notifications/tools/list_changed to SSE clients.
|
|
1314
|
+
// This is the wiring the HTTP host was missing - stdio's main() has always done it.
|
|
1315
|
+
try {
|
|
1316
|
+
configWatchersStop = require('./server').startConfigWatchers(() => build, (n) => {
|
|
1317
|
+
// Era-aware fanout: legacy SSE clients get the raw notification; modern listen
|
|
1318
|
+
// streams get tagged + filtered copies (tools/list_changed is the only channel).
|
|
1319
|
+
pushToSse(n);
|
|
1320
|
+
if (n && n.method === 'notifications/tools/list_changed') pushToListeners(n);
|
|
1321
|
+
});
|
|
1322
|
+
} catch (e) {
|
|
1323
|
+
log('config watchers failed to arm (host runs, without hot-reload):', (e && e.message) || String(e));
|
|
1324
|
+
}
|
|
1325
|
+
// Swap the one-shot bind-error handler for a steady-state one so a later socket error
|
|
1326
|
+
// (a client RST, etc.) is logged, not thrown.
|
|
1327
|
+
httpServer.on('error', (e) => log('http server error:', (e && e.message) || String(e)));
|
|
1328
|
+
log('listening on', currentUrl(), '(pid', process.pid + ')');
|
|
1329
|
+
resolve({ port: boundPort, url: currentUrl() });
|
|
1330
|
+
};
|
|
1331
|
+
httpServer.once('error', onListenError);
|
|
1332
|
+
httpServer.once('listening', onListening);
|
|
1333
|
+
httpServer.listen(requestedPort, host);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* stop - end every SSE client, close the HTTP server, then close the aggregator. Idempotent and
|
|
1339
|
+
* NEVER throws. Clears every keep-alive timer so the process can exit cleanly (no dangling
|
|
1340
|
+
* server, sockets, or timers).
|
|
1341
|
+
* @returns {Promise<void>}
|
|
1342
|
+
*/
|
|
1343
|
+
async function stop() {
|
|
1344
|
+
if (!started && !httpServer) {
|
|
1345
|
+
// Never started (or already fully stopped) - still close any build the caller may have left.
|
|
1346
|
+
await closeAggregatorSafely();
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
stopping = true;
|
|
1350
|
+
|
|
1351
|
+
// 0. Disarm the config watchers FIRST, so no debounced reload can fire against the build we're
|
|
1352
|
+
// about to tear down (a reload mutating a half-closed aggregator would be a benign no-op, but
|
|
1353
|
+
// disarming first is the clean order).
|
|
1354
|
+
if (configWatchersStop) {
|
|
1355
|
+
try { configWatchersStop(); } catch (_e) { /* never throw */ }
|
|
1356
|
+
configWatchersStop = null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// 1. Tear down SSE clients: clear their keep-alive timers and end the responses so their
|
|
1360
|
+
// sockets close (an open SSE socket would otherwise keep the server from closing).
|
|
1361
|
+
for (const res of Array.from(sseClients)) {
|
|
1362
|
+
try {
|
|
1363
|
+
if (res._keepAlive) clearInterval(res._keepAlive);
|
|
1364
|
+
} catch (_e) {
|
|
1365
|
+
/* ignore */
|
|
1366
|
+
}
|
|
1367
|
+
try {
|
|
1368
|
+
res.end();
|
|
1369
|
+
} catch (_e) {
|
|
1370
|
+
/* ignore */
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
sseClients.clear();
|
|
1374
|
+
|
|
1375
|
+
// 1b. Close modern listen streams GRACEFULLY per spec: a final RESULT to the listen id
|
|
1376
|
+
// (resultType complete, tagged), then end the stream - and WAIT (bounded) for the ends to
|
|
1377
|
+
// flush before step 2's closeAllConnections destroys the sockets, else the close result is
|
|
1378
|
+
// written but torn out of the kernel buffer before it ever reaches the client (the same
|
|
1379
|
+
// flush-vs-destroy race sendJsonAndClose documents). A dead socket just gets ended.
|
|
1380
|
+
const listenFlushes = [];
|
|
1381
|
+
for (const entry of Array.from(modernListeners)) {
|
|
1382
|
+
try {
|
|
1383
|
+
if (entry.res._keepAlive) clearInterval(entry.res._keepAlive);
|
|
1384
|
+
} catch (_e) {
|
|
1385
|
+
/* ignore */
|
|
1386
|
+
}
|
|
1387
|
+
try {
|
|
1388
|
+
entry.res.write('data: ' + JSON.stringify(modern.listenClose(entry.subId)) + '\n\n');
|
|
1389
|
+
} catch (_e) {
|
|
1390
|
+
/* half-closed socket - ending below is all that is left */
|
|
1391
|
+
}
|
|
1392
|
+
listenFlushes.push(
|
|
1393
|
+
new Promise((resolve) => {
|
|
1394
|
+
let settled = false;
|
|
1395
|
+
const done = () => {
|
|
1396
|
+
if (settled) return;
|
|
1397
|
+
settled = true;
|
|
1398
|
+
resolve();
|
|
1399
|
+
};
|
|
1400
|
+
try {
|
|
1401
|
+
entry.res.once('finish', done);
|
|
1402
|
+
entry.res.once('close', done);
|
|
1403
|
+
entry.res.once('error', done);
|
|
1404
|
+
entry.res.end();
|
|
1405
|
+
} catch (_e) {
|
|
1406
|
+
done();
|
|
1407
|
+
}
|
|
1408
|
+
})
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
modernListeners.clear();
|
|
1412
|
+
if (listenFlushes.length > 0) {
|
|
1413
|
+
// Bounded: a client that never drains cannot wedge stop() - 500ms then move on regardless.
|
|
1414
|
+
await Promise.race([
|
|
1415
|
+
Promise.all(listenFlushes),
|
|
1416
|
+
new Promise((resolve) => {
|
|
1417
|
+
const t = setTimeout(resolve, 500);
|
|
1418
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
1419
|
+
}),
|
|
1420
|
+
]);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// 2. Close the HTTP server (stops accepting + waits for in-flight sockets to drain). We also
|
|
1424
|
+
// actively destroy any lingering sockets so close() can never hang the process exit.
|
|
1425
|
+
if (httpServer) {
|
|
1426
|
+
const srv = httpServer;
|
|
1427
|
+
httpServer = null;
|
|
1428
|
+
await new Promise((resolve) => {
|
|
1429
|
+
let settled = false;
|
|
1430
|
+
const done = () => {
|
|
1431
|
+
if (settled) return;
|
|
1432
|
+
settled = true;
|
|
1433
|
+
resolve();
|
|
1434
|
+
};
|
|
1435
|
+
try {
|
|
1436
|
+
srv.close(done);
|
|
1437
|
+
} catch (_e) {
|
|
1438
|
+
done();
|
|
1439
|
+
}
|
|
1440
|
+
// Destroy any keep-alive sockets still attached so close() resolves promptly.
|
|
1441
|
+
try {
|
|
1442
|
+
if (typeof srv.closeAllConnections === 'function') {
|
|
1443
|
+
srv.closeAllConnections();
|
|
1444
|
+
}
|
|
1445
|
+
} catch (_e) {
|
|
1446
|
+
/* closeAllConnections is Node ≥18.2; absent -> close() still resolves on its own */
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// 3. Close the aggregator (idempotent; never throws).
|
|
1452
|
+
await closeAggregatorSafely();
|
|
1453
|
+
|
|
1454
|
+
boundPort = null;
|
|
1455
|
+
started = false;
|
|
1456
|
+
stopping = false;
|
|
1457
|
+
lastConnect = { connected: [], failed: [] }; // drop the connect snapshot - the host is down
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** Close the current build's aggregator if present. Idempotent + never throws. */
|
|
1461
|
+
async function closeAggregatorSafely() {
|
|
1462
|
+
const agg = build && build.aggregator;
|
|
1463
|
+
build = null;
|
|
1464
|
+
if (agg && typeof agg.closeAll === 'function') {
|
|
1465
|
+
try {
|
|
1466
|
+
await agg.closeAll();
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
log('aggregator.closeAll threw (ignored):', (err && err.message) || String(err));
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* reload - apply an expose.json edit live: fully tear down the current aggregator, rebuild from
|
|
1475
|
+
* the (now-edited) config, reconnect every enabled upstream, then broadcast list_changed so a
|
|
1476
|
+
* connected CLI re-fetches tools/list. A clean full rebuild because expose edits are rare +
|
|
1477
|
+
* user-driven (the design doc). The HTTP server keeps running across the reload - only
|
|
1478
|
+
* the build is swapped. NEVER throws (connectAll never throws; closeAll never throws).
|
|
1479
|
+
* @returns {Promise<void>}
|
|
1480
|
+
*/
|
|
1481
|
+
async function reload() {
|
|
1482
|
+
// 1. Build + connect the NEW build FIRST - the live build keeps serving throughout. The old
|
|
1483
|
+
// null-then-rebuild order left a `build == null` window (upstream spawn + handshake long)
|
|
1484
|
+
// during which a wrapped host answered initialize/server/discover as ToolFunnel - an
|
|
1485
|
+
// identity leak on the exported API. Swap-then-close, like reloadExpose.
|
|
1486
|
+
const next = buildFactory();
|
|
1487
|
+
// Arm the wrap scope on the NEW build BEFORE connectAll - the subscription replay in
|
|
1488
|
+
// _connectOne is scope-checked, and an unarmed aggregator replayed onto every upstream
|
|
1489
|
+
// Arm first, THEN carry the set: the arming setter clears on target change.
|
|
1490
|
+
if (next && next.aggregator && typeof serverModule.armWrapChatter === 'function') {
|
|
1491
|
+
serverModule.armWrapChatter(next);
|
|
1492
|
+
}
|
|
1493
|
+
// Subscription carryover - must land BEFORE connectAll so the new aggregator's
|
|
1494
|
+
// _connectOne replays the agreed URIs onto the fresh upstream processes.
|
|
1495
|
+
const prevAggForSubs = build && build.aggregator;
|
|
1496
|
+
if (next && next.aggregator && prevAggForSubs &&
|
|
1497
|
+
prevAggForSubs._subscribedUris instanceof Set && prevAggForSubs._subscribedUris.size) {
|
|
1498
|
+
next.aggregator._subscribedUris = new Set(prevAggForSubs._subscribedUris);
|
|
1499
|
+
// Carry the REFCOUNTS too, exactly as reloadExpose does (server.js). Without them the new
|
|
1500
|
+
// aggregator held the URIs with an empty refs Map, and releaseResources treats refcount 0 as
|
|
1501
|
+
// "last holder" - so the first listener to close unsubscribed upstream and killed the
|
|
1502
|
+
// channel for every other listener still holding that URI (0.7.0).
|
|
1503
|
+
if (prevAggForSubs._subscribedRefs instanceof Map) {
|
|
1504
|
+
next.aggregator._subscribedRefs = new Map(prevAggForSubs._subscribedRefs);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (next && next.aggregator && typeof next.aggregator.connectAll === 'function') {
|
|
1508
|
+
try {
|
|
1509
|
+
const res = await next.aggregator.connectAll();
|
|
1510
|
+
recordConnect(res); // refresh the health() connect snapshot for the new build
|
|
1511
|
+
if (res && Array.isArray(res.failed) && res.failed.length > 0) {
|
|
1512
|
+
log('reload connectAll: ' + res.failed.length + ' upstream(s) failed:',
|
|
1513
|
+
res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
|
|
1514
|
+
}
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
|
|
1517
|
+
log('reload connectAll threw (ignored):', (err && err.message) || String(err));
|
|
1518
|
+
}
|
|
1519
|
+
} else {
|
|
1520
|
+
recordConnect(null); // no aggregator -> no upstreams; report empty connect state
|
|
1521
|
+
}
|
|
1522
|
+
// 2. Swap, re-wire the notification bridges onto the new build, then close the OLD aggregator.
|
|
1523
|
+
const prev = build && build.aggregator;
|
|
1524
|
+
build = next;
|
|
1525
|
+
wireAggregatorNotify();
|
|
1526
|
+
if (prev && typeof prev.closeAll === 'function') {
|
|
1527
|
+
try {
|
|
1528
|
+
await prev.closeAll();
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
log('aggregator.closeAll threw (ignored):', (err && err.message) || String(err));
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
// 3. Tell connected clients the tool surface changed.
|
|
1534
|
+
broadcastToolsListChanged();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// ── Public handle ───────────────────────────────────────────────────────────────────────────
|
|
1538
|
+
return {
|
|
1539
|
+
start,
|
|
1540
|
+
stop,
|
|
1541
|
+
reload,
|
|
1542
|
+
broadcastToolsListChanged,
|
|
1543
|
+
health,
|
|
1544
|
+
get port() {
|
|
1545
|
+
return boundPort;
|
|
1546
|
+
},
|
|
1547
|
+
get url() {
|
|
1548
|
+
return currentUrl();
|
|
1549
|
+
},
|
|
1550
|
+
get sseClientCount() {
|
|
1551
|
+
return sseClients.size;
|
|
1552
|
+
},
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
module.exports = {
|
|
1557
|
+
createHttpMcpServer,
|
|
1558
|
+
// Exported for unit tests / reuse - the small pure seams.
|
|
1559
|
+
isLoopbackHost,
|
|
1560
|
+
isLoopbackOrigin,
|
|
1561
|
+
PROTOCOL_VERSION,
|
|
1562
|
+
SERVER_INFO,
|
|
1563
|
+
};
|