toolfunnel 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/src/mcp/expose-store.js
CHANGED
|
@@ -1,430 +1,460 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* expose-store.js
|
|
5
|
-
* the extension guide define the 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 (Phase 2) 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[]
|
|
26
|
-
* by expose[].upstream. `transport:"stdio"` spawns command+args.
|
|
27
|
-
* - expose[]
|
|
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
|
|
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
|
-
* Atomic write: serialise to a temp file in the SAME directory as the target (so
|
|
58
|
-
* the rename is atomic on the same filesystem/drive), fsync, then rename over the
|
|
59
|
-
* target. Copied verbatim from src/tools/registry.js so the two stores share one
|
|
60
|
-
* proven persistence pattern.
|
|
61
|
-
*/
|
|
62
|
-
function atomicWriteJson(targetPath, obj) {
|
|
63
|
-
const dir = path.dirname(targetPath);
|
|
64
|
-
const base = path.basename(targetPath);
|
|
65
|
-
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
66
|
-
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
67
|
-
const fd = fs.openSync(tmp, 'w');
|
|
68
|
-
try {
|
|
69
|
-
fs.writeSync(fd, data, 0, 'utf8');
|
|
70
|
-
fs.fsyncSync(fd);
|
|
71
|
-
} finally {
|
|
72
|
-
fs.closeSync(fd);
|
|
73
|
-
}
|
|
74
|
-
fs.renameSync(tmp, targetPath);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** True for a non-empty string. */
|
|
78
|
-
function isNonEmptyString(v) {
|
|
79
|
-
return typeof v === 'string' && v.length > 0;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Normalise a raw upstream object into the canonical shape, filling sane defaults
|
|
84
|
-
* for the optional fields. Does NOT validate
|
|
85
|
-
* the base for write-validation. Always returns a fresh object.
|
|
86
|
-
*/
|
|
87
|
-
function normaliseUpstream(raw) {
|
|
88
|
-
const u = raw && typeof raw === 'object' ? raw : {};
|
|
89
|
-
return {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
this._upstreams.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
*
|
|
236
|
-
* Persists atomically.
|
|
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
|
-
this._expose.
|
|
347
|
-
this._persist();
|
|
348
|
-
return
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* expose-store.js - the persisted MCP config store (the architecture notes and
|
|
5
|
+
* the extension guide define the 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 (Phase 2) 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
|
+
* Atomic write: serialise to a temp file in the SAME directory as the target (so
|
|
58
|
+
* the rename is atomic on the same filesystem/drive), fsync, then rename over the
|
|
59
|
+
* target. Copied verbatim from src/tools/registry.js so the two stores share one
|
|
60
|
+
* proven persistence pattern.
|
|
61
|
+
*/
|
|
62
|
+
function atomicWriteJson(targetPath, obj) {
|
|
63
|
+
const dir = path.dirname(targetPath);
|
|
64
|
+
const base = path.basename(targetPath);
|
|
65
|
+
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
66
|
+
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
67
|
+
const fd = fs.openSync(tmp, 'w');
|
|
68
|
+
try {
|
|
69
|
+
fs.writeSync(fd, data, 0, 'utf8');
|
|
70
|
+
fs.fsyncSync(fd);
|
|
71
|
+
} finally {
|
|
72
|
+
fs.closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
fs.renameSync(tmp, targetPath);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True for a non-empty string. */
|
|
78
|
+
function isNonEmptyString(v) {
|
|
79
|
+
return typeof v === 'string' && v.length > 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalise a raw upstream object into the canonical shape, filling sane defaults
|
|
84
|
+
* for the optional fields. Does NOT validate - used both on load (lenient) and as
|
|
85
|
+
* the base for write-validation. Always returns a fresh object.
|
|
86
|
+
*/
|
|
87
|
+
function normaliseUpstream(raw) {
|
|
88
|
+
const u = raw && typeof raw === 'object' ? raw : {};
|
|
89
|
+
return {
|
|
90
|
+
// Unknown fields ride along (the same deliberate choice as /api/identity): every store
|
|
91
|
+
// WRITE rewrites the file, so dropping unrecognised fields meant a 0.6.0 pin/unpin toggle
|
|
92
|
+
// silently erased hand-authored extras. Known fields are normalised below.
|
|
93
|
+
...u,
|
|
94
|
+
id: typeof u.id === 'string' ? u.id : '',
|
|
95
|
+
transport: typeof u.transport === 'string' ? u.transport : 'stdio',
|
|
96
|
+
command: typeof u.command === 'string' ? u.command : '',
|
|
97
|
+
args: Array.isArray(u.args) ? u.args.slice() : [],
|
|
98
|
+
env: u.env && typeof u.env === 'object' && !Array.isArray(u.env) ? { ...u.env } : {},
|
|
99
|
+
enabled: typeof u.enabled === 'boolean' ? u.enabled : true,
|
|
100
|
+
// 0.6.0 legacy shim: OPT-IN commitment flag. A pinned upstream is knowingly legacy-era -
|
|
101
|
+
// the gateway keeps speaking MCP 2024-11-05 to it forever (never auto-upgrades), warns at
|
|
102
|
+
// startup and on every forwarded call naming the pinned version, and tells modern clients
|
|
103
|
+
// via result _meta. Off by default, always.
|
|
104
|
+
legacyPin: u.legacyPin === true,
|
|
105
|
+
// modernOnly: legacyPin's MIRROR - this upstream must speak the modern era; the client
|
|
106
|
+
// refuses the legacy fallback at connect instead of negotiating down. Off by default
|
|
107
|
+
// (default = speak whichever era the server understands). Era-policy switches, 2026-07-18.
|
|
108
|
+
modernOnly: u.modernOnly === true,
|
|
109
|
+
// Child working directory - the wrap era-probe and the client spawn both read it; it was
|
|
110
|
+
// dropped here, so a configured cwd never survived the store.
|
|
111
|
+
cwd: typeof u.cwd === 'string' && u.cwd.length ? u.cwd : undefined,
|
|
112
|
+
// Per-upstream PAYLOAD timeout (ms) for tools/call | prompts/get | resources/read -
|
|
113
|
+
// overrides the client's 120 s default. The control-plane window is requestTimeoutMs below.
|
|
114
|
+
timeoutMs: Number.isFinite(u.timeoutMs) && u.timeoutMs > 0 ? u.timeoutMs : undefined,
|
|
115
|
+
// Per-upstream CONTROL-PLANE window (ms) - handshake/list/discover/ping; default 10 s.
|
|
116
|
+
// Raise it for a server that genuinely needs longer than 10 s to boot before it can answer
|
|
117
|
+
// its handshake (a browser, a DB, a heavy session restore). The default stays the
|
|
118
|
+
// dead-upstream detector; this is a knowing opt-in per upstream.
|
|
119
|
+
requestTimeoutMs: Number.isFinite(u.requestTimeoutMs) && u.requestTimeoutMs > 0
|
|
120
|
+
? u.requestTimeoutMs : undefined,
|
|
121
|
+
description: typeof u.description === 'string' ? u.description : '',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Normalise a raw expose object into the canonical shape. `as` is left as-is (may
|
|
127
|
+
* be empty); exposedName() / addExpose() apply the `<upstream>_<tool>` default so
|
|
128
|
+
* the stored entry can carry an explicit override or fall back lazily.
|
|
129
|
+
*/
|
|
130
|
+
function normaliseExpose(raw) {
|
|
131
|
+
const e = raw && typeof raw === 'object' ? raw : {};
|
|
132
|
+
return {
|
|
133
|
+
upstream: typeof e.upstream === 'string' ? e.upstream : '',
|
|
134
|
+
tool: typeof e.tool === 'string' ? e.tool : '',
|
|
135
|
+
as: typeof e.as === 'string' ? e.as : '',
|
|
136
|
+
category: typeof e.category === 'string' ? e.category : '',
|
|
137
|
+
enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate an upstream entry for a WRITE. Throws a clear error on a bad shape.
|
|
143
|
+
* - id must be a non-empty string and unique among the OTHER upstreams.
|
|
144
|
+
* - transport must be 'stdio' (the only baseline transport).
|
|
145
|
+
* - a stdio upstream requires a non-empty command.
|
|
146
|
+
* @param {object} entry normalised upstream
|
|
147
|
+
* @param {Map} byId existing upstreams keyed by id (for uniqueness)
|
|
148
|
+
* @param {string} [ignoreId] an id to skip in the uniqueness check (updates)
|
|
149
|
+
*/
|
|
150
|
+
function validateUpstream(entry, byId, ignoreId) {
|
|
151
|
+
if (!isNonEmptyString(entry.id)) {
|
|
152
|
+
throw new Error('ExposeStore: upstream.id must be a non-empty string');
|
|
153
|
+
}
|
|
154
|
+
if (byId.has(entry.id) && entry.id !== ignoreId) {
|
|
155
|
+
throw new Error(`ExposeStore: upstream id "${entry.id}" already exists`);
|
|
156
|
+
}
|
|
157
|
+
if (!VALID_TRANSPORTS.has(entry.transport)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`ExposeStore: upstream.transport must be one of ${[...VALID_TRANSPORTS].join('|')} (id=${entry.id})`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (entry.transport === 'stdio' && !isNonEmptyString(entry.command)) {
|
|
163
|
+
throw new Error(`ExposeStore: stdio upstream needs a non-empty "command" (id=${entry.id})`);
|
|
164
|
+
}
|
|
165
|
+
if (entry.legacyPin === true && entry.modernOnly === true) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`ExposeStore: upstream "${entry.id}" sets BOTH legacyPin and modernOnly - they contradict (force legacy vs refuse legacy); pick one`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return entry;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class ExposeStore {
|
|
174
|
+
/**
|
|
175
|
+
* @param {object} opts
|
|
176
|
+
* @param {string} opts.filePath path to expose.json (source of truth on disk)
|
|
177
|
+
* @param {object} [opts.data] parsed config { version, upstreams, expose }
|
|
178
|
+
*/
|
|
179
|
+
constructor({ filePath, data } = {}) {
|
|
180
|
+
if (!isNonEmptyString(filePath)) throw new Error('ExposeStore: filePath is required');
|
|
181
|
+
this._filePath = filePath;
|
|
182
|
+
|
|
183
|
+
const d = data && typeof data === 'object' ? data : {};
|
|
184
|
+
this._version = typeof d.version === 'number' ? d.version : 1;
|
|
185
|
+
|
|
186
|
+
// Index upstreams by id; reject duplicate ids at load (a duplicate id makes
|
|
187
|
+
// getUpstream / cascade-remove ambiguous). Keep insertion order via the Map.
|
|
188
|
+
this._upstreams = new Map();
|
|
189
|
+
const rawUpstreams = Array.isArray(d.upstreams) ? d.upstreams : [];
|
|
190
|
+
for (const raw of rawUpstreams) {
|
|
191
|
+
const u = normaliseUpstream(raw);
|
|
192
|
+
if (!isNonEmptyString(u.id)) {
|
|
193
|
+
throw new Error(`ExposeStore: an upstream is missing its id in ${filePath}`);
|
|
194
|
+
}
|
|
195
|
+
if (this._upstreams.has(u.id)) {
|
|
196
|
+
throw new Error(`ExposeStore: duplicate upstream id "${u.id}" in ${filePath}`);
|
|
197
|
+
}
|
|
198
|
+
this._upstreams.set(u.id, u);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// expose[] is kept as an ordered array; the natural key is (upstream,tool).
|
|
202
|
+
// Reject duplicate (upstream,tool) pairs at load.
|
|
203
|
+
this._expose = [];
|
|
204
|
+
const seenExpose = new Set();
|
|
205
|
+
const rawExpose = Array.isArray(d.expose) ? d.expose : [];
|
|
206
|
+
for (const raw of rawExpose) {
|
|
207
|
+
const e = normaliseExpose(raw);
|
|
208
|
+
const key = `${e.upstream} ${e.tool}`;
|
|
209
|
+
if (seenExpose.has(key)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`ExposeStore: duplicate expose entry (upstream="${e.upstream}", tool="${e.tool}") in ${filePath}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
seenExpose.add(key);
|
|
215
|
+
this._expose.push(e);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── upstreams: read API (clones, never throw) ─────────────────────────────
|
|
220
|
+
|
|
221
|
+
/** @returns {Array<{id,transport,command,args,env,enabled,description}>} cloned, in order. */
|
|
222
|
+
listUpstreams() {
|
|
223
|
+
return [...this._upstreams.values()].map(clone);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @returns {object|undefined} cloned upstream entry, or undefined if unknown. */
|
|
227
|
+
getUpstream(id) {
|
|
228
|
+
const u = this._upstreams.get(id);
|
|
229
|
+
return u ? clone(u) : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── upstreams: write API (validate + atomic persist) ──────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Add an upstream. Validates: unique non-empty id; transport === 'stdio';
|
|
236
|
+
* stdio requires a non-empty command. Persists atomically. Returns the clone.
|
|
237
|
+
*/
|
|
238
|
+
addUpstream(entry) {
|
|
239
|
+
const u = normaliseUpstream(entry);
|
|
240
|
+
validateUpstream(u, this._upstreams);
|
|
241
|
+
this._upstreams.set(u.id, u);
|
|
242
|
+
this._persist();
|
|
243
|
+
return clone(u);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Shallow-merge `patch` over an existing upstream. `id` is immutable (the key);
|
|
248
|
+
* any `id` in the patch is ignored. Re-validates the merged result and persists.
|
|
249
|
+
*/
|
|
250
|
+
updateUpstream(id, patch) {
|
|
251
|
+
const existing = this._upstreams.get(id);
|
|
252
|
+
if (!existing) throw new Error(`ExposeStore.updateUpstream: unknown upstream id "${id}"`);
|
|
253
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
254
|
+
throw new Error('ExposeStore.updateUpstream: patch must be an object');
|
|
255
|
+
}
|
|
256
|
+
const merged = normaliseUpstream({ ...existing, ...patch, id });
|
|
257
|
+
validateUpstream(merged, this._upstreams, id);
|
|
258
|
+
this._upstreams.set(id, merged);
|
|
259
|
+
this._persist();
|
|
260
|
+
return clone(merged);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Remove an upstream AND cascade-remove any expose[] entries whose `upstream`
|
|
265
|
+
* matches this id (a dangling expose entry would reference a missing upstream).
|
|
266
|
+
* Persists atomically. Throws on an unknown id.
|
|
267
|
+
* @returns {true}
|
|
268
|
+
*/
|
|
269
|
+
removeUpstream(id) {
|
|
270
|
+
if (!this._upstreams.has(id)) {
|
|
271
|
+
throw new Error(`ExposeStore.removeUpstream: unknown upstream id "${id}"`);
|
|
272
|
+
}
|
|
273
|
+
this._upstreams.delete(id);
|
|
274
|
+
this._expose = this._expose.filter((e) => e.upstream !== id);
|
|
275
|
+
this._persist();
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Toggle an upstream's `enabled` flag; persists. Throws on unknown id. */
|
|
280
|
+
setUpstreamEnabled(id, enabled) {
|
|
281
|
+
return this.updateUpstream(id, { enabled: !!enabled });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── expose: read API (clones, never throw) ────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* List exposed entries. Optional filters:
|
|
288
|
+
* - `upstream` restrict to one upstream id.
|
|
289
|
+
* - `enabledOnly` only entries with enabled === true.
|
|
290
|
+
* @returns {Array<{upstream,tool,as,category,enabled}>} cloned, in order.
|
|
291
|
+
*/
|
|
292
|
+
listExposed({ upstream, enabledOnly } = {}) {
|
|
293
|
+
const out = [];
|
|
294
|
+
for (const e of this._expose) {
|
|
295
|
+
if (upstream && e.upstream !== upstream) continue;
|
|
296
|
+
if (enabledOnly && e.enabled !== true) continue;
|
|
297
|
+
out.push(clone(e));
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @returns {object|undefined} cloned expose entry for (upstream,tool), or undefined. */
|
|
303
|
+
getExposed(upstream, tool) {
|
|
304
|
+
const e = this._findExpose(upstream, tool);
|
|
305
|
+
return e ? clone(e) : undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The downstream name the CLI sees for an expose entry: explicit `as` if set,
|
|
310
|
+
* else the namespaced default `<upstream>_<tool>`.
|
|
311
|
+
* @param {{upstream,tool,as?}} e
|
|
312
|
+
* @returns {string}
|
|
313
|
+
*/
|
|
314
|
+
exposedName(e) {
|
|
315
|
+
const obj = e && typeof e === 'object' ? e : {};
|
|
316
|
+
if (isNonEmptyString(obj.as)) return obj.as;
|
|
317
|
+
return `${obj.upstream}_${obj.tool}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── expose: write API (validate + atomic persist) ─────────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Add an expose entry. Validates: the `upstream` must EXIST; `tool` must be a
|
|
324
|
+
* non-empty string; `(upstream,tool)` must be unique. `as` defaults to the
|
|
325
|
+
* namespaced `<upstream>_<tool>`. Persists atomically. Returns the clone.
|
|
326
|
+
*/
|
|
327
|
+
addExpose(entry) {
|
|
328
|
+
const e = normaliseExpose(entry);
|
|
329
|
+
if (!isNonEmptyString(e.upstream)) {
|
|
330
|
+
throw new Error('ExposeStore.addExpose: expose.upstream must be a non-empty string');
|
|
331
|
+
}
|
|
332
|
+
if (!this._upstreams.has(e.upstream)) {
|
|
333
|
+
throw new Error(`ExposeStore.addExpose: unknown upstream "${e.upstream}" (add the upstream first)`);
|
|
334
|
+
}
|
|
335
|
+
if (!isNonEmptyString(e.tool)) {
|
|
336
|
+
throw new Error(`ExposeStore.addExpose: expose.tool must be a non-empty string (upstream=${e.upstream})`);
|
|
337
|
+
}
|
|
338
|
+
if (this._findExpose(e.upstream, e.tool)) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`ExposeStore.addExpose: expose entry (upstream="${e.upstream}", tool="${e.tool}") already exists`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
// Persist an explicit `as` (default the namespaced name so the stored entry
|
|
344
|
+
// is self-describing and the aggregator never has to recompute it).
|
|
345
|
+
e.as = isNonEmptyString(e.as) ? e.as : `${e.upstream}_${e.tool}`;
|
|
346
|
+
this._expose.push(e);
|
|
347
|
+
this._persist();
|
|
348
|
+
return clone(e);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Shallow-merge `patch` over an existing expose entry. The natural key
|
|
353
|
+
* `(upstream,tool)` is immutable - any `upstream`/`tool` in the patch is ignored.
|
|
354
|
+
* Persists atomically. Throws on an unknown (upstream,tool).
|
|
355
|
+
*/
|
|
356
|
+
updateExpose(upstream, tool, patch) {
|
|
357
|
+
const idx = this._indexOfExpose(upstream, tool);
|
|
358
|
+
if (idx === -1) {
|
|
359
|
+
throw new Error(`ExposeStore.updateExpose: unknown expose entry (upstream="${upstream}", tool="${tool}")`);
|
|
360
|
+
}
|
|
361
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
362
|
+
throw new Error('ExposeStore.updateExpose: patch must be an object');
|
|
363
|
+
}
|
|
364
|
+
const merged = normaliseExpose({ ...this._expose[idx], ...patch, upstream, tool });
|
|
365
|
+
this._expose[idx] = merged;
|
|
366
|
+
this._persist();
|
|
367
|
+
return clone(merged);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Remove an expose entry. Persists. Throws on an unknown (upstream,tool). @returns {true} */
|
|
371
|
+
removeExpose(upstream, tool) {
|
|
372
|
+
const idx = this._indexOfExpose(upstream, tool);
|
|
373
|
+
if (idx === -1) {
|
|
374
|
+
throw new Error(`ExposeStore.removeExpose: unknown expose entry (upstream="${upstream}", tool="${tool}")`);
|
|
375
|
+
}
|
|
376
|
+
this._expose.splice(idx, 1);
|
|
377
|
+
this._persist();
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Toggle an expose entry's `enabled` flag; persists. Throws on unknown (upstream,tool). */
|
|
382
|
+
setExposeEnabled(upstream, tool, enabled) {
|
|
383
|
+
return this.updateExpose(upstream, tool, { enabled: !!enabled });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── serialisation ─────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
/** @returns {{version, upstreams, expose}} a clone of the full config (safe to mutate). */
|
|
389
|
+
toJSON() {
|
|
390
|
+
return {
|
|
391
|
+
version: this._version,
|
|
392
|
+
upstreams: [...this._upstreams.values()].map(clone),
|
|
393
|
+
expose: this._expose.map(clone),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/** @returns {object|undefined} the LIVE expose entry (not a clone) for (upstream,tool). */
|
|
400
|
+
_findExpose(upstream, tool) {
|
|
401
|
+
return this._expose.find((e) => e.upstream === upstream && e.tool === tool);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** @returns {number} index of the expose entry for (upstream,tool), or -1. */
|
|
405
|
+
_indexOfExpose(upstream, tool) {
|
|
406
|
+
return this._expose.findIndex((e) => e.upstream === upstream && e.tool === tool);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Serialise current state back to the config file atomically. */
|
|
410
|
+
_persist() {
|
|
411
|
+
atomicWriteJson(this._filePath, this.toJSON());
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* loadExposeStore(filePath) -> ExposeStore
|
|
417
|
+
*
|
|
418
|
+
* Reads + parses the config JSON at `filePath` and returns an ExposeStore bound to
|
|
419
|
+
* it.
|
|
420
|
+
* - MISSING file -> return an empty store bound to filePath. The file is NOT
|
|
421
|
+
* created until the first write (keeps the server starting clean).
|
|
422
|
+
* - MALFORMED JSON (or a duplicate id / duplicate expose key) -> throw a clear
|
|
423
|
+
* error so misconfiguration is caught at load, not at run time.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} filePath
|
|
426
|
+
* @returns {ExposeStore}
|
|
427
|
+
*/
|
|
428
|
+
function loadExposeStore(filePath) {
|
|
429
|
+
if (!isNonEmptyString(filePath)) {
|
|
430
|
+
throw new Error('loadExposeStore: filePath (non-empty string) is required');
|
|
431
|
+
}
|
|
432
|
+
let raw;
|
|
433
|
+
try {
|
|
434
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
435
|
+
} catch (err) {
|
|
436
|
+
if (err && err.code === 'ENOENT') {
|
|
437
|
+
// Missing file is not an error - bind an empty store; defer file creation.
|
|
438
|
+
return new ExposeStore({ filePath, data: { version: 1, upstreams: [], expose: [] } });
|
|
439
|
+
}
|
|
440
|
+
throw new Error(`loadExposeStore: cannot read config at "${filePath}": ${err.message}`);
|
|
441
|
+
}
|
|
442
|
+
let data;
|
|
443
|
+
try {
|
|
444
|
+
data = JSON.parse(raw);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
throw new Error(`loadExposeStore: config at "${filePath}" is not valid JSON: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
return new ExposeStore({ filePath, data });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
module.exports = {
|
|
452
|
+
ExposeStore,
|
|
453
|
+
loadExposeStore,
|
|
454
|
+
// exported for unit tests / the aggregator
|
|
455
|
+
atomicWriteJson,
|
|
456
|
+
normaliseUpstream,
|
|
457
|
+
normaliseExpose,
|
|
458
|
+
validateUpstream,
|
|
459
|
+
VALID_TRANSPORTS,
|
|
460
|
+
};
|