toolfunnel 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +16 -5
package/src/mcp/expose-store.js
CHANGED
|
@@ -1,577 +1,755 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* expose-store.js - the persisted MCP config store (the extension guide defines the
|
|
5
|
-
* authoritative shape).
|
|
6
|
-
*
|
|
7
|
-
* This is a PURE, config-only module: no process spawning, no network, no MCP
|
|
8
|
-
* client. It is the on-disk source of truth that the aggregator reads
|
|
9
|
-
* to know WHICH upstream MCPs to connect to and WHICH of their tools to surface
|
|
10
|
-
* downstream as curated-direct tools. Editing this store is how a human/model
|
|
11
|
-
* authors the MCP surface; the aggregator just consumes the result.
|
|
12
|
-
*
|
|
13
|
-
* Persisted file shape (src/mcp/expose.json):
|
|
14
|
-
* {
|
|
15
|
-
* "version": 1,
|
|
16
|
-
* "upstreams": [
|
|
17
|
-
* { id, transport:"stdio", command, args:[], env:{}, enabled:bool, description }
|
|
18
|
-
* ],
|
|
19
|
-
* "expose": [
|
|
20
|
-
* { upstream, tool, as, category, enabled:bool }
|
|
21
|
-
* ]
|
|
22
|
-
* }
|
|
23
|
-
*
|
|
24
|
-
* Two blocks (per the extension guide):
|
|
25
|
-
* - upstreams[] - one entry per upstream MCP. `id` is the stable key referenced
|
|
26
|
-
* by expose[].upstream. `transport:"stdio"` spawns command+args.
|
|
27
|
-
* - expose[] - which upstream tools become curated-direct. `(upstream,tool)`
|
|
28
|
-
* is the natural key; `as` is the downstream name the CLI sees
|
|
29
|
-
* (defaults to `<upstream>_<tool>` so two upstreams can't collide).
|
|
30
|
-
*
|
|
31
|
-
* Read/write discipline (mirrors src/tools/registry.js - the safety contract):
|
|
32
|
-
* - READS never throw and always return CLONES (callers cannot mutate internal
|
|
33
|
-
* state, and a missing file is simply an empty store).
|
|
34
|
-
* - WRITES validate and throw a clear error on a bad write; nothing is persisted
|
|
35
|
-
* when validation fails.
|
|
36
|
-
* - Persistence is ATOMIC: a temp file in the SAME directory, fsync, then rename
|
|
37
|
-
* over the target (copied exactly from registry.js::atomicWriteJson). A crash
|
|
38
|
-
* leaves either the old file or the new file, never a half-written config.
|
|
39
|
-
* - loadExposeStore on a MISSING file returns an empty store bound to the path;
|
|
40
|
-
* the file is NOT created until the first write (keeps the server starting
|
|
41
|
-
* clean with no live upstreams).
|
|
42
|
-
*
|
|
43
|
-
* CommonJS only. Node built-ins only. No new npm dependency.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
const fs = require('node:fs');
|
|
47
|
-
const path = require('node:path');
|
|
48
|
-
|
|
49
|
-
const VALID_TRANSPORTS = new Set(['stdio']);
|
|
50
|
-
|
|
51
|
-
/** Deep-ish clone for plain JSON values (structuredClone may be absent on old node). */
|
|
52
|
-
function clone(value) {
|
|
53
|
-
return JSON.parse(JSON.stringify(value));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
// upstream
|
|
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
|
-
const
|
|
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
|
-
return
|
|
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
|
-
if (
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
this.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
this.
|
|
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
|
-
return
|
|
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
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* expose-store.js - the persisted MCP config store (the extension guide defines the
|
|
5
|
+
* authoritative shape).
|
|
6
|
+
*
|
|
7
|
+
* This is a PURE, config-only module: no process spawning, no network, no MCP
|
|
8
|
+
* client. It is the on-disk source of truth that the aggregator reads
|
|
9
|
+
* to know WHICH upstream MCPs to connect to and WHICH of their tools to surface
|
|
10
|
+
* downstream as curated-direct tools. Editing this store is how a human/model
|
|
11
|
+
* authors the MCP surface; the aggregator just consumes the result.
|
|
12
|
+
*
|
|
13
|
+
* Persisted file shape (src/mcp/expose.json):
|
|
14
|
+
* {
|
|
15
|
+
* "version": 1,
|
|
16
|
+
* "upstreams": [
|
|
17
|
+
* { id, transport:"stdio", command, args:[], env:{}, enabled:bool, description }
|
|
18
|
+
* ],
|
|
19
|
+
* "expose": [
|
|
20
|
+
* { upstream, tool, as, category, enabled:bool }
|
|
21
|
+
* ]
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Two blocks (per the extension guide):
|
|
25
|
+
* - upstreams[] - one entry per upstream MCP. `id` is the stable key referenced
|
|
26
|
+
* by expose[].upstream. `transport:"stdio"` spawns command+args.
|
|
27
|
+
* - expose[] - which upstream tools become curated-direct. `(upstream,tool)`
|
|
28
|
+
* is the natural key; `as` is the downstream name the CLI sees
|
|
29
|
+
* (defaults to `<upstream>_<tool>` so two upstreams can't collide).
|
|
30
|
+
*
|
|
31
|
+
* Read/write discipline (mirrors src/tools/registry.js - the safety contract):
|
|
32
|
+
* - READS never throw and always return CLONES (callers cannot mutate internal
|
|
33
|
+
* state, and a missing file is simply an empty store).
|
|
34
|
+
* - WRITES validate and throw a clear error on a bad write; nothing is persisted
|
|
35
|
+
* when validation fails.
|
|
36
|
+
* - Persistence is ATOMIC: a temp file in the SAME directory, fsync, then rename
|
|
37
|
+
* over the target (copied exactly from registry.js::atomicWriteJson). A crash
|
|
38
|
+
* leaves either the old file or the new file, never a half-written config.
|
|
39
|
+
* - loadExposeStore on a MISSING file returns an empty store bound to the path;
|
|
40
|
+
* the file is NOT created until the first write (keeps the server starting
|
|
41
|
+
* clean with no live upstreams).
|
|
42
|
+
*
|
|
43
|
+
* CommonJS only. Node built-ins only. No new npm dependency.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('node:fs');
|
|
47
|
+
const path = require('node:path');
|
|
48
|
+
|
|
49
|
+
const VALID_TRANSPORTS = new Set(['stdio']);
|
|
50
|
+
|
|
51
|
+
/** Deep-ish clone for plain JSON values (structuredClone may be absent on old node). */
|
|
52
|
+
function clone(value) {
|
|
53
|
+
return JSON.parse(JSON.stringify(value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
|
|
57
|
+
const { atomicWriteJson } = require('../core/atomic-write');
|
|
58
|
+
|
|
59
|
+
/** True for a non-empty string. */
|
|
60
|
+
function isNonEmptyString(v) {
|
|
61
|
+
return typeof v === 'string' && v.length > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { migrateForStoreFile, snapshotForStoreFile, restoreSnapshot } = require('../core/rename-migration');
|
|
65
|
+
|
|
66
|
+
/** The name an expose entry surfaces its tool under RIGHT NOW: an ENABLED entry's `as`
|
|
67
|
+
* (self-describing since addExpose defaults it); a disabled entry does not alias - the tool
|
|
68
|
+
* surfaces under the namespaced default. Mirrors Aggregator._surfacedName / the UI's
|
|
69
|
+
* surfacedNameFor. */
|
|
70
|
+
function surfacedOf(e) {
|
|
71
|
+
return (e && e.enabled && isNonEmptyString(e.as)) ? e.as : `${e.upstream}_${e.tool}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalise an env map to a NULL-PROTOTYPE object of string -> string.
|
|
76
|
+
*
|
|
77
|
+
* Not cosmetic - it closes two proven isolation-guard bypasses at the boundary, by making the
|
|
78
|
+
* stored, guarded and spawned representations identical:
|
|
79
|
+
* - `JSON.parse('{"__proto__":{...}}')` yields an OWN `__proto__` data property. Spread keeps it,
|
|
80
|
+
* and the client's `Object.assign` env merge then uses [[Set]], firing the
|
|
81
|
+
* Object.prototype.__proto__ setter so the payload becomes the merged env's PROTOTYPE.
|
|
82
|
+
* child_process enumerates env with for..in ("prototype values are intentionally included"),
|
|
83
|
+
* so the child receives those keys while an own-key walk sees nothing. Copying own keys into
|
|
84
|
+
* a null-prototype object makes that carrier inert.
|
|
85
|
+
* - Non-string values reach the child regardless: node builds each pair as `${key}=${value}`, so
|
|
86
|
+
* `['--require=/outside-module.js']` stringifies to its element. Coerce here, once.
|
|
87
|
+
*
|
|
88
|
+
* @param {*} raw
|
|
89
|
+
* @returns {Object<string,string>} null-prototype, string-valued
|
|
90
|
+
*/
|
|
91
|
+
function normaliseEnv(raw) {
|
|
92
|
+
const out = Object.create(null);
|
|
93
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
94
|
+
for (const key of Object.getOwnPropertyNames(raw)) {
|
|
95
|
+
if (key === '__proto__') continue; // never re-establish the carrier
|
|
96
|
+
const v = raw[key];
|
|
97
|
+
if (v === undefined || v === null) continue;
|
|
98
|
+
out[key] = typeof v === 'string' ? v : String(v);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Normalise a raw upstream object into the canonical shape, filling sane defaults
|
|
105
|
+
* for the optional fields. Does NOT validate - used both on load (lenient) and as
|
|
106
|
+
* the base for write-validation. Always returns a fresh object.
|
|
107
|
+
*/
|
|
108
|
+
function normaliseUpstream(raw) {
|
|
109
|
+
const u = raw && typeof raw === 'object' ? raw : {};
|
|
110
|
+
return {
|
|
111
|
+
// Unknown fields ride along (the same deliberate choice as /api/identity): every store
|
|
112
|
+
// WRITE rewrites the file, so dropping unrecognised fields meant a 0.6.0 pin/unpin toggle
|
|
113
|
+
// silently erased hand-authored extras. Known fields are normalised below.
|
|
114
|
+
...(u && typeof u === 'object' ? JSON.parse(JSON.stringify(u)) : {}),
|
|
115
|
+
id: typeof u.id === 'string' ? u.id : '',
|
|
116
|
+
transport: typeof u.transport === 'string' ? u.transport : 'stdio',
|
|
117
|
+
command: typeof u.command === 'string' ? u.command : '',
|
|
118
|
+
args: Array.isArray(u.args) ? u.args.slice() : [],
|
|
119
|
+
env: normaliseEnv(u.env),
|
|
120
|
+
enabled: typeof u.enabled === 'boolean' ? u.enabled : true,
|
|
121
|
+
// 0.6.0 legacy shim: OPT-IN commitment flag. A pinned upstream is knowingly legacy-era -
|
|
122
|
+
// the gateway keeps speaking MCP 2024-11-05 to it forever (never auto-upgrades), warns at
|
|
123
|
+
// startup and on every forwarded call naming the pinned version, and tells modern clients
|
|
124
|
+
// via result _meta. Off by default, always.
|
|
125
|
+
legacyPin: u.legacyPin === true,
|
|
126
|
+
// modernOnly: legacyPin's MIRROR - this upstream must speak the modern era; the client
|
|
127
|
+
// refuses the legacy fallback at connect instead of negotiating down. Off by default
|
|
128
|
+
// (default = speak whichever era the server understands).
|
|
129
|
+
modernOnly: u.modernOnly === true,
|
|
130
|
+
// allowCodeLoadingEnv: OPT-OUT of the isolation guard's code-loading env refusal for THIS
|
|
131
|
+
// upstream (NODE_OPTIONS and friends). Same pattern as legacyPin: off by default, explicit
|
|
132
|
+
// in config where an auditor can see it, warned on connect when it excuses something.
|
|
133
|
+
allowCodeLoadingEnv: u.allowCodeLoadingEnv === true,
|
|
134
|
+
// allowOutsidePaths: *** USE WITH EXTREME CAUTION ***
|
|
135
|
+
//
|
|
136
|
+
// This is the big one. allowCodeLoadingEnv excuses ONE class of environment variable; this
|
|
137
|
+
// excuses the ENTIRE path guard for this upstream - args, the cwd they resolve against, and
|
|
138
|
+
// the path-list env vars - so the server can reach anywhere on disk the account can.
|
|
139
|
+
//
|
|
140
|
+
// It exists because refusing outright was wrong. A filesystem server pointed at a documents
|
|
141
|
+
// folder, a git server pointed at a repo, a database tool holding a socket somewhere else:
|
|
142
|
+
// these live outside the config home BY NATURE, and without this flag funnel mode could not
|
|
143
|
+
// run them at all. The honest answer is a switch the operator sets deliberately, not a wall
|
|
144
|
+
// with no door.
|
|
145
|
+
//
|
|
146
|
+
// What it costs: the guard is a TRIPWIRE for configs that quietly reach outside an auditable
|
|
147
|
+
// home. Setting this says "I know this one reaches out, and I meant it". You are then trusting
|
|
148
|
+
// that server the way you trust anything you installed - because that is what you have done.
|
|
149
|
+
// It buys you nothing to set it on an upstream that does not need it.
|
|
150
|
+
//
|
|
151
|
+
// What it does NOT excuse: the OPTION-STRING env class (NODE_OPTIONS, JAVA_TOOL_OPTIONS,
|
|
152
|
+
// PERL5OPT, RUBYOPT...). Those are mini command lines in the runtime's own grammar - they
|
|
153
|
+
// load code with no path to check - and they have their own flag, directly above. Setting
|
|
154
|
+
// this one to point a git server at a repo must not silently disarm that one too: two
|
|
155
|
+
// exemptions mean two deliberate decisions, each visible on its own line to an auditor.
|
|
156
|
+
//
|
|
157
|
+
// Scope is the safety property: it is read per upstream, from THIS entry, at every connect.
|
|
158
|
+
// One upstream opting out NEVER relaxes another's guard, and the gateway warns on every
|
|
159
|
+
// connect, ONCE PER CLASS, about everything it excused. Same pattern as legacyPin - off by
|
|
160
|
+
// default, explicit in config where an auditor can see it, loud when it applies.
|
|
161
|
+
allowOutsidePaths: u.allowOutsidePaths === true,
|
|
162
|
+
// strictApproval (tool descriptor approval): a server VERSION change does not approve changed
|
|
163
|
+
// tool descriptors by itself - they wait for acceptance like a same-version change.
|
|
164
|
+
// Off by default (a version bump is the ordinary upgrade signal); on where the upstream's
|
|
165
|
+
// maintainer is not trusted to bump honestly. See src/mcp/tool-approvals.js.
|
|
166
|
+
strictApproval: u.strictApproval === true,
|
|
167
|
+
// Child working directory - the wrap era-probe and the client spawn both read it; it was
|
|
168
|
+
// dropped here, so a configured cwd never survived the store.
|
|
169
|
+
cwd: typeof u.cwd === 'string' && u.cwd.length ? u.cwd : undefined,
|
|
170
|
+
// Per-upstream PAYLOAD timeout (ms) for tools/call | prompts/get | resources/read -
|
|
171
|
+
// overrides the client's 120 s default. The control-plane window is requestTimeoutMs below.
|
|
172
|
+
timeoutMs: Number.isFinite(u.timeoutMs) && u.timeoutMs > 0 ? u.timeoutMs : undefined,
|
|
173
|
+
// Per-upstream CONTROL-PLANE window (ms) - handshake/list/discover/ping; default 10 s.
|
|
174
|
+
// Raise it for a server that genuinely needs longer than 10 s to boot before it can answer
|
|
175
|
+
// its handshake (a browser, a DB, a heavy session restore). The default stays the
|
|
176
|
+
// dead-upstream detector; this is a knowing opt-in per upstream.
|
|
177
|
+
requestTimeoutMs: Number.isFinite(u.requestTimeoutMs) && u.requestTimeoutMs > 0
|
|
178
|
+
? u.requestTimeoutMs : undefined,
|
|
179
|
+
description: typeof u.description === 'string' ? u.description : '',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Normalise a raw expose object into the canonical shape. `as` is left as-is (may
|
|
185
|
+
* be empty); exposedName() / addExpose() apply the `<upstream>_<tool>` default so
|
|
186
|
+
* the stored entry can carry an explicit override or fall back lazily.
|
|
187
|
+
*/
|
|
188
|
+
function normaliseExpose(raw) {
|
|
189
|
+
const e = raw && typeof raw === 'object' ? raw : {};
|
|
190
|
+
return {
|
|
191
|
+
// Unknown fields ride along, as on upstreams (normaliseUpstream): every write rewrites the
|
|
192
|
+
// whole file, so dropping them erased hand-authored extras on the next unrelated edit.
|
|
193
|
+
...JSON.parse(JSON.stringify(e)),
|
|
194
|
+
upstream: typeof e.upstream === 'string' ? e.upstream : '',
|
|
195
|
+
tool: typeof e.tool === 'string' ? e.tool : '',
|
|
196
|
+
as: typeof e.as === 'string' ? e.as : '',
|
|
197
|
+
category: typeof e.category === 'string' ? e.category : '',
|
|
198
|
+
enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validate an upstream entry for a WRITE. Throws a clear error on a bad shape.
|
|
204
|
+
* - id must be a non-empty string and unique among the OTHER upstreams.
|
|
205
|
+
* - transport must be 'stdio' (the only baseline transport).
|
|
206
|
+
* - a stdio upstream requires a non-empty command.
|
|
207
|
+
* @param {object} entry normalised upstream
|
|
208
|
+
* @param {Map} byId existing upstreams keyed by id (for uniqueness)
|
|
209
|
+
* @param {string} [ignoreId] an id to skip in the uniqueness check (updates)
|
|
210
|
+
* @param {string} [filePath] named in the message when given (load and store writes)
|
|
211
|
+
*/
|
|
212
|
+
function validateUpstream(entry, byId, ignoreId, filePath) {
|
|
213
|
+
const at = filePath ? `ExposeStore: ${filePath}:` : 'ExposeStore:';
|
|
214
|
+
if (!isNonEmptyString(entry.id)) {
|
|
215
|
+
throw new Error(`${at} upstream "id" must be a non-empty string`);
|
|
216
|
+
}
|
|
217
|
+
if (byId.has(entry.id) && entry.id !== ignoreId) {
|
|
218
|
+
throw new Error(`${at} upstream id "${entry.id}" already exists`);
|
|
219
|
+
}
|
|
220
|
+
if (!VALID_TRANSPORTS.has(entry.transport)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`${at} upstream "${entry.id}": "transport" must be one of ${[...VALID_TRANSPORTS].join('|')}, got ${describeValue(entry.transport)}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
// A disabled entry is never spawned, so a placeholder without a command still loads.
|
|
226
|
+
if (entry.transport === 'stdio' && entry.enabled !== false && !isNonEmptyString(entry.command)) {
|
|
227
|
+
throw new Error(`${at} upstream "${entry.id}": a stdio upstream needs a non-empty "command"`);
|
|
228
|
+
}
|
|
229
|
+
if (entry.legacyPin === true && entry.modernOnly === true) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`${at} upstream "${entry.id}" sets BOTH legacyPin and modernOnly - they contradict (force legacy vs refuse legacy); pick one`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return entry;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── fail-loud config ────────────────────────────────────────────────────────────────────────
|
|
238
|
+
//
|
|
239
|
+
// The normalisers above replace a wrong-typed value with the default, silently: `"enabled":
|
|
240
|
+
// "false"` became ENABLED, `"timeoutMs": "5000"` the 120 s default, `"args": "a b"` no args,
|
|
241
|
+
// `"allowOutsidePaths": 1` false. The checks below run on the RAW object - at load, and on every
|
|
242
|
+
// write before normalising (which would coerce the evidence away) - and name the first bad field.
|
|
243
|
+
// Absent (undefined) and null both mean "unset" and keep the default. Unknown fields are not
|
|
244
|
+
// checked: they ride along untouched.
|
|
245
|
+
|
|
246
|
+
const UPSTREAM_BOOLEAN_FIELDS = ['enabled', 'legacyPin', 'modernOnly', 'allowCodeLoadingEnv', 'allowOutsidePaths', 'strictApproval'];
|
|
247
|
+
const UPSTREAM_STRING_FIELDS = ['transport', 'command', 'cwd', 'description'];
|
|
248
|
+
const UPSTREAM_MS_FIELDS = ['timeoutMs', 'requestTimeoutMs'];
|
|
249
|
+
const EXPOSE_STRING_FIELDS = ['upstream', 'tool', 'as', 'category'];
|
|
250
|
+
|
|
251
|
+
function isSet(v) {
|
|
252
|
+
return v !== undefined && v !== null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Unknown fields still ride along (see normaliseUpstream), but a misspelt known one did nothing
|
|
256
|
+
// and said nothing: `"strictAproval": true` loaded and left strictApproval off. Loading with a
|
|
257
|
+
// warnUnknown callback names each unknown field, with the nearest known one when it is close.
|
|
258
|
+
// Fields starting with "_" or "$" are comments or schema references, so they are not reported.
|
|
259
|
+
const UPSTREAM_KNOWN_FIELDS = ['id', 'args', 'env', ...UPSTREAM_BOOLEAN_FIELDS, ...UPSTREAM_STRING_FIELDS, ...UPSTREAM_MS_FIELDS];
|
|
260
|
+
const EXPOSE_KNOWN_FIELDS = [...EXPOSE_STRING_FIELDS, 'enabled'];
|
|
261
|
+
|
|
262
|
+
/** Edit distance between two short strings (case-insensitive); swapping two neighbours counts as one edit. */
|
|
263
|
+
function editDistance(a, b) {
|
|
264
|
+
a = a.toLowerCase(); b = b.toLowerCase();
|
|
265
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
|
|
266
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
267
|
+
for (let i = 1; i <= a.length; i++) {
|
|
268
|
+
for (let j = 1; j <= b.length; j++) {
|
|
269
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
270
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return d[a.length][b.length];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @returns {string[]} one message per unknown field of a RAW entry. */
|
|
277
|
+
function unknownFieldMessages(raw, known, at) {
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const key of Object.keys(raw)) {
|
|
280
|
+
if (known.includes(key) || key.startsWith('_') || key.startsWith('$')) continue;
|
|
281
|
+
let best = null;
|
|
282
|
+
let bestD = Infinity;
|
|
283
|
+
for (const k of known) {
|
|
284
|
+
const d = editDistance(key, k);
|
|
285
|
+
if (d < bestD) { best = k; bestD = d; }
|
|
286
|
+
}
|
|
287
|
+
const guess = bestD <= Math.max(1, Math.floor(best.length / 4)) ? ` (did you mean "${best}"?)` : '';
|
|
288
|
+
out.push(`${at}: unknown field ${JSON.stringify(key)}${guess}. It is kept but has no effect.`);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A short, safe description of a value for an error message. */
|
|
294
|
+
function describeValue(v) {
|
|
295
|
+
if (v === null) return 'null';
|
|
296
|
+
if (Array.isArray(v)) return 'an array';
|
|
297
|
+
if (typeof v === 'string') return 'a string'; // never the value: args and env strings can hold secrets
|
|
298
|
+
if (typeof v === 'object') return 'an object';
|
|
299
|
+
return `${typeof v} ${String(v)}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @returns {string|null} the first wrong-typed known field of a RAW upstream, or null. */
|
|
303
|
+
function upstreamFieldProblem(raw) {
|
|
304
|
+
for (const f of UPSTREAM_BOOLEAN_FIELDS) {
|
|
305
|
+
if (isSet(raw[f]) && typeof raw[f] !== 'boolean') return `"${f}" must be true or false, got ${describeValue(raw[f])}`;
|
|
306
|
+
}
|
|
307
|
+
for (const f of UPSTREAM_STRING_FIELDS) {
|
|
308
|
+
if (isSet(raw[f]) && typeof raw[f] !== 'string') return `"${f}" must be a string, got ${describeValue(raw[f])}`;
|
|
309
|
+
}
|
|
310
|
+
for (const f of UPSTREAM_MS_FIELDS) {
|
|
311
|
+
const v = raw[f];
|
|
312
|
+
if (isSet(v) && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
|
|
313
|
+
return `"${f}" must be a positive number of milliseconds, got ${describeValue(v)}`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (isSet(raw.args)) {
|
|
317
|
+
if (!Array.isArray(raw.args)) return `"args" must be an array of strings, got ${describeValue(raw.args)}`;
|
|
318
|
+
const i = raw.args.findIndex((a) => typeof a !== 'string');
|
|
319
|
+
if (i !== -1) return `"args" must be an array of strings, element ${i} is ${describeValue(raw.args[i])}`;
|
|
320
|
+
}
|
|
321
|
+
if (isSet(raw.env)) {
|
|
322
|
+
if (typeof raw.env !== 'object' || Array.isArray(raw.env)) {
|
|
323
|
+
return `"env" must be an object of NAME: value pairs, got ${describeValue(raw.env)}`;
|
|
324
|
+
}
|
|
325
|
+
// Scalars are coerced to strings by normaliseEnv (unchanged); an object or array value has
|
|
326
|
+
// no meaning as an environment variable - including the "__proto__" carrier, now refused
|
|
327
|
+
// loudly instead of dropped.
|
|
328
|
+
for (const key of Object.getOwnPropertyNames(raw.env)) {
|
|
329
|
+
const v = raw.env[key];
|
|
330
|
+
if (isSet(v) && typeof v === 'object') {
|
|
331
|
+
return `"env" value for ${JSON.stringify(key)} must be a string, number or boolean, got ${describeValue(v)}`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @returns {string|null} the first wrong-typed known field of a RAW expose entry, or null. */
|
|
339
|
+
function exposeFieldProblem(raw) {
|
|
340
|
+
for (const f of EXPOSE_STRING_FIELDS) {
|
|
341
|
+
if (isSet(raw[f]) && typeof raw[f] !== 'string') return `"${f}" must be a string, got ${describeValue(raw[f])}`;
|
|
342
|
+
}
|
|
343
|
+
if (isSet(raw.enabled) && typeof raw.enabled !== 'boolean') {
|
|
344
|
+
return `"enabled" must be true or false, got ${describeValue(raw.enabled)}`;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** `upstream "u1"` - the id when it is a usable string, else the array position. */
|
|
350
|
+
function upstreamLabel(raw, index) {
|
|
351
|
+
const id = raw && typeof raw.id === 'string' && raw.id ? `upstream "${raw.id}"` : 'upstream';
|
|
352
|
+
return index === undefined ? id : `${id} (upstreams[${index}])`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** `expose entry (upstream="u1", tool="t1")`, with the array position at load. */
|
|
356
|
+
function exposeLabel(raw, index) {
|
|
357
|
+
const q = (v) => (typeof v === 'string' ? JSON.stringify(v) : '(missing)');
|
|
358
|
+
const where = index === undefined ? '' : ` (expose[${index}])`;
|
|
359
|
+
return `expose entry (upstream=${q(raw && raw.upstream)}, tool=${q(raw && raw.tool)})${where}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
class ExposeStore {
|
|
363
|
+
/**
|
|
364
|
+
* @param {object} opts
|
|
365
|
+
* @param {string} opts.filePath path to expose.json (source of truth on disk)
|
|
366
|
+
* @param {object} [opts.data] parsed config { version, upstreams, expose }
|
|
367
|
+
*/
|
|
368
|
+
constructor({ filePath, data } = {}) {
|
|
369
|
+
if (!isNonEmptyString(filePath)) throw new Error('ExposeStore: filePath is required');
|
|
370
|
+
this._filePath = filePath;
|
|
371
|
+
|
|
372
|
+
const d = data && typeof data === 'object' ? data : {};
|
|
373
|
+
// Fail-loud config: a present top-level field of the wrong type refuses, never reads as empty.
|
|
374
|
+
for (const f of ['upstreams', 'expose']) {
|
|
375
|
+
if (isSet(d[f]) && !Array.isArray(d[f])) {
|
|
376
|
+
throw new Error(`ExposeStore: ${filePath}: "${f}" must be an array, got ${describeValue(d[f])}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (isSet(d.version) && typeof d.version !== 'number') {
|
|
380
|
+
throw new Error(`ExposeStore: ${filePath}: "version" must be a number, got ${describeValue(d.version)}`);
|
|
381
|
+
}
|
|
382
|
+
this._version = typeof d.version === 'number' ? d.version : 1;
|
|
383
|
+
|
|
384
|
+
// Index upstreams by id; reject duplicate ids at load (a duplicate id makes
|
|
385
|
+
// getUpstream / cascade-remove ambiguous). Keep insertion order via the Map.
|
|
386
|
+
this._upstreams = new Map();
|
|
387
|
+
const rawUpstreams = Array.isArray(d.upstreams) ? d.upstreams : [];
|
|
388
|
+
rawUpstreams.forEach((raw, i) => {
|
|
389
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
390
|
+
throw new Error(`ExposeStore: ${filePath}: upstreams[${i}] must be an object, got ${describeValue(raw)}`);
|
|
391
|
+
}
|
|
392
|
+
const u = normaliseUpstream(raw);
|
|
393
|
+
if (!isNonEmptyString(u.id)) {
|
|
394
|
+
throw new Error(`ExposeStore: an upstream is missing its id in ${filePath} (upstreams[${i}])`);
|
|
395
|
+
}
|
|
396
|
+
if (this._upstreams.has(u.id)) {
|
|
397
|
+
throw new Error(`ExposeStore: duplicate upstream id "${u.id}" in ${filePath}`);
|
|
398
|
+
}
|
|
399
|
+
const problem = upstreamFieldProblem(raw);
|
|
400
|
+
if (problem) throw new Error(`ExposeStore: ${filePath}: ${upstreamLabel(raw, i)}: ${problem}`);
|
|
401
|
+
// The write-time shape rules (transport, a stdio command, the era-flag contradiction) hold
|
|
402
|
+
// at load too: a file that no write could have produced is refused, not half-run.
|
|
403
|
+
validateUpstream(u, this._upstreams, undefined, filePath);
|
|
404
|
+
this._upstreams.set(u.id, u);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// expose[] is kept as an ordered array; the natural key is (upstream,tool).
|
|
408
|
+
// Reject duplicate (upstream,tool) pairs at load.
|
|
409
|
+
this._expose = [];
|
|
410
|
+
const seenExpose = new Set();
|
|
411
|
+
const rawExpose = Array.isArray(d.expose) ? d.expose : [];
|
|
412
|
+
rawExpose.forEach((raw, i) => {
|
|
413
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
414
|
+
throw new Error(`ExposeStore: ${filePath}: expose[${i}] must be an object, got ${describeValue(raw)}`);
|
|
415
|
+
}
|
|
416
|
+
const problem = exposeFieldProblem(raw);
|
|
417
|
+
if (problem) throw new Error(`ExposeStore: ${filePath}: ${exposeLabel(raw, i)}: ${problem}`);
|
|
418
|
+
for (const f of ['upstream', 'tool']) {
|
|
419
|
+
if (!isNonEmptyString(raw[f])) {
|
|
420
|
+
throw new Error(`ExposeStore: ${filePath}: ${exposeLabel(raw, i)}: "${f}" must be a non-empty string`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const e = normaliseExpose(raw);
|
|
424
|
+
const key = `${e.upstream} ${e.tool}`;
|
|
425
|
+
if (seenExpose.has(key)) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`ExposeStore: duplicate expose entry (upstream="${e.upstream}", tool="${e.tool}") in ${filePath}`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
seenExpose.add(key);
|
|
431
|
+
this._expose.push(e);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── upstreams: read API (clones, never throw) ─────────────────────────────
|
|
436
|
+
|
|
437
|
+
/** @returns {Array<{id,transport,command,args,env,enabled,description}>} cloned, in order. */
|
|
438
|
+
listUpstreams() {
|
|
439
|
+
return [...this._upstreams.values()].map(clone);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** @returns {object|undefined} cloned upstream entry, or undefined if unknown. */
|
|
443
|
+
getUpstream(id) {
|
|
444
|
+
const u = this._upstreams.get(id);
|
|
445
|
+
return u ? clone(u) : undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── upstreams: write API (validate + atomic persist) ──────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Add an upstream. Validates: unique non-empty id; transport === 'stdio';
|
|
452
|
+
* stdio requires a non-empty command. Persists atomically. Returns the clone.
|
|
453
|
+
*/
|
|
454
|
+
addUpstream(entry) {
|
|
455
|
+
this._refuseWrongTypes(entry, upstreamFieldProblem, upstreamLabel);
|
|
456
|
+
const u = normaliseUpstream(entry);
|
|
457
|
+
validateUpstream(u, this._upstreams, undefined, this._filePath);
|
|
458
|
+
this._upstreams.set(u.id, u);
|
|
459
|
+
this._persist();
|
|
460
|
+
return clone(u);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Shallow-merge `patch` over an existing upstream. `id` is immutable (the key);
|
|
465
|
+
* any `id` in the patch is ignored. Re-validates the merged result and persists.
|
|
466
|
+
*/
|
|
467
|
+
updateUpstream(id, patch) {
|
|
468
|
+
const existing = this._upstreams.get(id);
|
|
469
|
+
if (!existing) throw new Error(`ExposeStore.updateUpstream: unknown upstream id "${id}"`);
|
|
470
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
471
|
+
throw new Error('ExposeStore.updateUpstream: patch must be an object');
|
|
472
|
+
}
|
|
473
|
+
this._refuseWrongTypes({ ...existing, ...patch, id }, upstreamFieldProblem, upstreamLabel);
|
|
474
|
+
const merged = normaliseUpstream({ ...existing, ...patch, id });
|
|
475
|
+
validateUpstream(merged, this._upstreams, id, this._filePath);
|
|
476
|
+
this._upstreams.set(id, merged);
|
|
477
|
+
this._persist();
|
|
478
|
+
return clone(merged);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Remove an upstream AND cascade-remove any expose[] entries whose `upstream`
|
|
483
|
+
* matches this id (a dangling expose entry would reference a missing upstream).
|
|
484
|
+
* Persists atomically. Throws on an unknown id.
|
|
485
|
+
* @returns {true}
|
|
486
|
+
*/
|
|
487
|
+
removeUpstream(id) {
|
|
488
|
+
if (!this._upstreams.has(id)) {
|
|
489
|
+
throw new Error(`ExposeStore.removeUpstream: unknown upstream id "${id}"`);
|
|
490
|
+
}
|
|
491
|
+
this._upstreams.delete(id);
|
|
492
|
+
this._expose = this._expose.filter((e) => e.upstream !== id);
|
|
493
|
+
this._persist();
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Toggle an upstream's `enabled` flag; persists. Throws on unknown id. */
|
|
498
|
+
setUpstreamEnabled(id, enabled) {
|
|
499
|
+
return this.updateUpstream(id, { enabled: !!enabled });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── expose: read API (clones, never throw) ────────────────────────────────
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* List exposed entries. Optional filters:
|
|
506
|
+
* - `upstream` restrict to one upstream id.
|
|
507
|
+
* - `enabledOnly` only entries with enabled === true.
|
|
508
|
+
* @returns {Array<{upstream,tool,as,category,enabled}>} cloned, in order.
|
|
509
|
+
*/
|
|
510
|
+
listExposed({ upstream, enabledOnly } = {}) {
|
|
511
|
+
const out = [];
|
|
512
|
+
for (const e of this._expose) {
|
|
513
|
+
if (upstream && e.upstream !== upstream) continue;
|
|
514
|
+
if (enabledOnly && e.enabled !== true) continue;
|
|
515
|
+
out.push(clone(e));
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** @returns {object|undefined} cloned expose entry for (upstream,tool), or undefined. */
|
|
521
|
+
getExposed(upstream, tool) {
|
|
522
|
+
const e = this._findExpose(upstream, tool);
|
|
523
|
+
return e ? clone(e) : undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The downstream name the CLI sees for an expose entry: explicit `as` if set,
|
|
528
|
+
* else the namespaced default `<upstream>_<tool>`.
|
|
529
|
+
* @param {{upstream,tool,as?}} e
|
|
530
|
+
* @returns {string}
|
|
531
|
+
*/
|
|
532
|
+
exposedName(e) {
|
|
533
|
+
const obj = e && typeof e === 'object' ? e : {};
|
|
534
|
+
if (isNonEmptyString(obj.as)) return obj.as;
|
|
535
|
+
return `${obj.upstream}_${obj.tool}`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── expose: write API (validate + atomic persist) ─────────────────────────
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Add an expose entry. Validates: the `upstream` must EXIST; `tool` must be a
|
|
542
|
+
* non-empty string; `(upstream,tool)` must be unique. `as` defaults to the
|
|
543
|
+
* namespaced `<upstream>_<tool>`. Persists atomically. Returns the clone.
|
|
544
|
+
*/
|
|
545
|
+
addExpose(entry) {
|
|
546
|
+
this._refuseWrongTypes(entry, exposeFieldProblem, exposeLabel);
|
|
547
|
+
const e = normaliseExpose(entry);
|
|
548
|
+
if (!isNonEmptyString(e.upstream)) {
|
|
549
|
+
throw new Error('ExposeStore.addExpose: expose.upstream must be a non-empty string');
|
|
550
|
+
}
|
|
551
|
+
if (!this._upstreams.has(e.upstream)) {
|
|
552
|
+
throw new Error(`ExposeStore.addExpose: unknown upstream "${e.upstream}" (add the upstream first)`);
|
|
553
|
+
}
|
|
554
|
+
if (!isNonEmptyString(e.tool)) {
|
|
555
|
+
throw new Error(`ExposeStore.addExpose: expose.tool must be a non-empty string (upstream=${e.upstream})`);
|
|
556
|
+
}
|
|
557
|
+
if (this._findExpose(e.upstream, e.tool)) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`ExposeStore.addExpose: expose entry (upstream="${e.upstream}", tool="${e.tool}") already exists`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
// Persist an explicit `as` (default the namespaced name so the stored entry
|
|
563
|
+
// is self-describing and the aggregator never has to recompute it).
|
|
564
|
+
e.as = isNonEmptyString(e.as) ? e.as : `${e.upstream}_${e.tool}`;
|
|
565
|
+
// A new ENABLED alias renames the surface from the namespaced default it had while merely
|
|
566
|
+
// lean-discovered - migrate-or-refuse, exactly like updateExpose (no-op when as == default).
|
|
567
|
+
const snap = e.enabled && e.as !== `${e.upstream}_${e.tool}` ? snapshotForStoreFile(this._filePath) : null;
|
|
568
|
+
if (e.enabled) this._migrateSurfaced(`${e.upstream}_${e.tool}`, e.as);
|
|
569
|
+
this._expose.push(e);
|
|
570
|
+
try {
|
|
571
|
+
this._persist();
|
|
572
|
+
} catch (err) {
|
|
573
|
+
this._expose.pop();
|
|
574
|
+
if (snap) restoreSnapshot(snap); // a failed persist puts the migrated files back byte-for-byte
|
|
575
|
+
throw err;
|
|
576
|
+
}
|
|
577
|
+
return clone(e);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Shallow-merge `patch` over an existing expose entry. The natural key
|
|
582
|
+
* `(upstream,tool)` is immutable - any `upstream`/`tool` in the patch is ignored.
|
|
583
|
+
* Persists atomically. Throws on an unknown (upstream,tool).
|
|
584
|
+
*/
|
|
585
|
+
updateExpose(upstream, tool, patch) {
|
|
586
|
+
const idx = this._indexOfExpose(upstream, tool);
|
|
587
|
+
if (idx === -1) {
|
|
588
|
+
throw new Error(`ExposeStore.updateExpose: unknown expose entry (upstream="${upstream}", tool="${tool}")`);
|
|
589
|
+
}
|
|
590
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
591
|
+
throw new Error('ExposeStore.updateExpose: patch must be an object');
|
|
592
|
+
}
|
|
593
|
+
const prior = this._expose[idx];
|
|
594
|
+
this._refuseWrongTypes({ ...prior, ...patch, upstream, tool }, exposeFieldProblem, exposeLabel);
|
|
595
|
+
const merged = normaliseExpose({ ...prior, ...patch, upstream, tool });
|
|
596
|
+
// Rename-safety: a change to the SURFACED name - an `as` edit OR an enabled flip
|
|
597
|
+
// (only an ENABLED entry's `as` surfaces; a disabled entry's tool falls back to the
|
|
598
|
+
// namespaced default) - must carry the gate matchers and tools.state.json keys with it, or
|
|
599
|
+
// the rename is REFUSED. Runs BEFORE persist so a refusal leaves this file untouched.
|
|
600
|
+
const snap = surfacedOf(prior) !== surfacedOf(merged) ? snapshotForStoreFile(this._filePath) : null;
|
|
601
|
+
this._migrateSurfaced(surfacedOf(prior), surfacedOf(merged));
|
|
602
|
+
this._expose[idx] = merged;
|
|
603
|
+
try {
|
|
604
|
+
this._persist();
|
|
605
|
+
} catch (err) {
|
|
606
|
+
this._expose[idx] = prior;
|
|
607
|
+
if (snap) restoreSnapshot(snap);
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
return clone(merged);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Remove an expose entry. Persists. Throws on an unknown (upstream,tool). @returns {true} */
|
|
614
|
+
removeExpose(upstream, tool) {
|
|
615
|
+
const idx = this._indexOfExpose(upstream, tool);
|
|
616
|
+
if (idx === -1) {
|
|
617
|
+
throw new Error(`ExposeStore.removeExpose: unknown expose entry (upstream="${upstream}", tool="${tool}")`);
|
|
618
|
+
}
|
|
619
|
+
// Removing an ENABLED aliased entry renames the surface back to the namespaced default -
|
|
620
|
+
// same migrate-or-refuse contract as updateExpose (rename-safety).
|
|
621
|
+
const prior = this._expose[idx];
|
|
622
|
+
const snap = surfacedOf(prior) !== `${prior.upstream}_${prior.tool}` ? snapshotForStoreFile(this._filePath) : null;
|
|
623
|
+
this._migrateSurfaced(surfacedOf(prior), `${prior.upstream}_${prior.tool}`);
|
|
624
|
+
this._expose.splice(idx, 1);
|
|
625
|
+
try {
|
|
626
|
+
this._persist();
|
|
627
|
+
} catch (err) {
|
|
628
|
+
this._expose.splice(idx, 0, prior);
|
|
629
|
+
if (snap) restoreSnapshot(snap);
|
|
630
|
+
throw err;
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Gate matchers + state keys follow the surfaced name (or the rename refuses; see rename-migration.js). */
|
|
636
|
+
_migrateSurfaced(oldName, newName) {
|
|
637
|
+
if (oldName === newName) return;
|
|
638
|
+
migrateForStoreFile(this._filePath, oldName, newName);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The other half of "migrate BEFORE persist": that ordering makes a REFUSED migration leave this
|
|
643
|
+
* file untouched; a migration that succeeded followed by a failed persist would leave the
|
|
644
|
+
* manifest naming the NEW surface while the file still named the OLD one - the gate firing on a
|
|
645
|
+
* name nothing surfaced. Each mutating site snapshots the migration's files (snapshotForStoreFile)
|
|
646
|
+
* before migrating and restores the bytes on a failed persist. A reverse MIGRATION is not an
|
|
647
|
+
* unwind - it re-validates and a pattern hook matching the new name refuses it. rename-rollback.test.js.
|
|
648
|
+
*/
|
|
649
|
+
|
|
650
|
+
/** Toggle an expose entry's `enabled` flag; persists. Throws on unknown (upstream,tool). */
|
|
651
|
+
setExposeEnabled(upstream, tool, enabled) {
|
|
652
|
+
return this.updateExpose(upstream, tool, { enabled: !!enabled });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── serialisation ─────────────────────────────────────────────────────────
|
|
656
|
+
|
|
657
|
+
/** @returns {{version, upstreams, expose}} a clone of the full config (safe to mutate). */
|
|
658
|
+
toJSON() {
|
|
659
|
+
return {
|
|
660
|
+
version: this._version,
|
|
661
|
+
upstreams: [...this._upstreams.values()].map(clone),
|
|
662
|
+
expose: this._expose.map(clone),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
/** @returns {object|undefined} the LIVE expose entry (not a clone) for (upstream,tool). */
|
|
669
|
+
_findExpose(upstream, tool) {
|
|
670
|
+
return this._expose.find((e) => e.upstream === upstream && e.tool === tool);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** @returns {number} index of the expose entry for (upstream,tool), or -1. */
|
|
674
|
+
_indexOfExpose(upstream, tool) {
|
|
675
|
+
return this._expose.findIndex((e) => e.upstream === upstream && e.tool === tool);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Fail-loud config on a WRITE: throw, naming the file, entry and field, before anything changes. */
|
|
679
|
+
_refuseWrongTypes(raw, problemOf, labelOf) {
|
|
680
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return; // the callers' own shape checks own this
|
|
681
|
+
const problem = problemOf(raw);
|
|
682
|
+
if (problem) throw new Error(`ExposeStore: ${this._filePath}: ${labelOf(raw)}: ${problem}`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Serialise current state back to the config file atomically. */
|
|
686
|
+
_persist() {
|
|
687
|
+
atomicWriteJson(this._filePath, this.toJSON());
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* loadExposeStore(filePath) -> ExposeStore
|
|
693
|
+
*
|
|
694
|
+
* Reads + parses the config JSON at `filePath` and returns an ExposeStore bound to
|
|
695
|
+
* it.
|
|
696
|
+
* - MISSING file -> return an empty store bound to filePath. The file is NOT
|
|
697
|
+
* created until the first write (keeps the server starting clean).
|
|
698
|
+
* - MALFORMED JSON (or a duplicate id / duplicate expose key) -> throw a clear
|
|
699
|
+
* error so misconfiguration is caught at load, not at run time.
|
|
700
|
+
* - A PRESENT-BUT-WRONG-TYPED known field (`"enabled": "false"`, `"timeoutMs": "5000"`,
|
|
701
|
+
* `"args": "a b"`...) -> throw naming the file, the entry and the field (fail-loud config).
|
|
702
|
+
* Absent fields keep their defaults; `null` counts as absent.
|
|
703
|
+
*
|
|
704
|
+
* - With opts.warnUnknown, each unknown field on an upstream or expose entry is reported through
|
|
705
|
+
* it (gateway start and reload pass one; the web UI, which loads on every request, does not).
|
|
706
|
+
*
|
|
707
|
+
* @param {string} filePath
|
|
708
|
+
* @param {{ warnUnknown?: (message: string) => void }} [opts]
|
|
709
|
+
* @returns {ExposeStore}
|
|
710
|
+
*/
|
|
711
|
+
function loadExposeStore(filePath, opts = {}) {
|
|
712
|
+
if (!isNonEmptyString(filePath)) {
|
|
713
|
+
throw new Error('loadExposeStore: filePath (non-empty string) is required');
|
|
714
|
+
}
|
|
715
|
+
let raw;
|
|
716
|
+
try {
|
|
717
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
718
|
+
} catch (err) {
|
|
719
|
+
if (err && err.code === 'ENOENT') {
|
|
720
|
+
// Missing file is not an error - bind an empty store; defer file creation.
|
|
721
|
+
return new ExposeStore({ filePath, data: { version: 1, upstreams: [], expose: [] } });
|
|
722
|
+
}
|
|
723
|
+
throw new Error(`loadExposeStore: cannot read config at "${filePath}": ${err.message}`);
|
|
724
|
+
}
|
|
725
|
+
let data;
|
|
726
|
+
try {
|
|
727
|
+
data = JSON.parse(raw);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
throw new Error(`loadExposeStore: config at "${filePath}" is not valid JSON: ${err.message}`);
|
|
730
|
+
}
|
|
731
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
732
|
+
throw new Error(`loadExposeStore: config at "${filePath}" must be a JSON object, got ${describeValue(data)}`);
|
|
733
|
+
}
|
|
734
|
+
const store = new ExposeStore({ filePath, data });
|
|
735
|
+
if (typeof opts.warnUnknown === 'function') {
|
|
736
|
+
(data.upstreams || []).forEach((raw, i) => {
|
|
737
|
+
for (const m of unknownFieldMessages(raw, UPSTREAM_KNOWN_FIELDS, `${filePath}: ${upstreamLabel(raw, i)}`)) opts.warnUnknown(m);
|
|
738
|
+
});
|
|
739
|
+
(data.expose || []).forEach((raw, i) => {
|
|
740
|
+
for (const m of unknownFieldMessages(raw, EXPOSE_KNOWN_FIELDS, `${filePath}: ${exposeLabel(raw, i)}`)) opts.warnUnknown(m);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return store;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
module.exports = {
|
|
747
|
+
ExposeStore,
|
|
748
|
+
loadExposeStore,
|
|
749
|
+
// exported for unit tests / the aggregator
|
|
750
|
+
atomicWriteJson,
|
|
751
|
+
normaliseUpstream,
|
|
752
|
+
normaliseExpose,
|
|
753
|
+
validateUpstream,
|
|
754
|
+
VALID_TRANSPORTS,
|
|
755
|
+
};
|