toolfunnel 0.7.1 → 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 +14 -4
- package/bin/toolfunnel.js +103 -24
- package/docs/KNOWN_BUGS.md +48 -28
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +1 -1
- package/docs/packaging.md +2 -1
- package/mcp/servers/mock-modern/server.js +1 -1
- package/mcp/servers/mock-upstream/server.js +3 -4
- package/package.json +1 -1
- package/src/auth/config.js +2 -4
- package/src/auth/resource-server.js +6 -1
- package/src/core/atomic-write.js +97 -0
- package/src/core/config-home.js +100 -8
- package/src/core/hook-engine.js +29 -54
- package/src/core/hook-loader.js +3 -61
- package/src/core/hook-runner.js +92 -43
- package/src/core/logger.js +5 -7
- package/src/core/metrics.js +17 -2
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +36 -21
- package/src/extend/add-hook.md +4 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/package.md +1 -1
- package/src/mcp/aggregator.js +583 -54
- package/src/mcp/expose-store.js +246 -68
- package/src/mcp/http-transport.js +111 -17
- package/src/mcp/mcp-client.js +130 -26
- package/src/mcp/modern.js +9 -6
- package/src/mcp/protocol.js +12 -7
- package/src/mcp/server.js +268 -63
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +33 -87
- package/src/tools/tool-state.js +1 -1
- package/src/ui/public/app.js +60 -1
- package/src/ui/server.js +142 -16
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-mcp-add.js +28 -15
- package/tools/tools.register.json +16 -5
package/src/ui/server.js
CHANGED
|
@@ -36,8 +36,10 @@ const fs = require('node:fs');
|
|
|
36
36
|
const path = require('node:path');
|
|
37
37
|
|
|
38
38
|
const { loadRegistry, atomicWriteJson, resolveMode } = require('../tools/registry');
|
|
39
|
+
const { snapshotForStoreFile, restoreSnapshot } = require('../core/rename-migration');
|
|
39
40
|
const { loadToolState, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState, getPassthrough, setPassthrough } = require('../tools/tool-state');
|
|
40
41
|
const { loadExposeStore } = require('../mcp/expose-store');
|
|
42
|
+
const approvals = require('../mcp/tool-approvals');
|
|
41
43
|
const { Aggregator } = require('../mcp/aggregator');
|
|
42
44
|
const { loadManifest, normalizeState } = require('../core/hook-loader');
|
|
43
45
|
const { matches } = require('../core/matcher');
|
|
@@ -69,6 +71,12 @@ const CONTENT_TYPES = Object.freeze({
|
|
|
69
71
|
'.webp': 'image/webp',
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
/** A host as it appears in a URL: a literal IPv6 address is bracketed (`[::1]`), anything else verbatim. */
|
|
75
|
+
function urlHost(h) {
|
|
76
|
+
const v = String(h == null ? '' : h);
|
|
77
|
+
return v.includes(':') && !v.startsWith('[') ? '[' + v + ']' : v;
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
/**
|
|
73
81
|
* Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound
|
|
74
82
|
* request could still arrive with a non-loopback Host. Defence-in-depth: reject
|
|
@@ -168,6 +176,7 @@ const CONFIG_EVENTS = {
|
|
|
168
176
|
'/api/hooks/state': (b) => ({ event: 'hook_state', id: b.id, action: b.action }),
|
|
169
177
|
'/api/mcp/add': (b) => ({ event: 'mcp_add', id: b.upstream && b.upstream.id }),
|
|
170
178
|
'/api/mcp/state': (b) => ({ event: 'mcp_state', id: b.id, action: b.action }),
|
|
179
|
+
'/api/tool-changes/accept': (b) => ({ event: 'tool_change_accept', upstream: b.upstream, tool: b.tool || null }),
|
|
171
180
|
'/api/wrap': (b) => ({ event: 'wrap', upstream: (b && b.upstream) || null }),
|
|
172
181
|
'/api/identity': () => ({ event: 'identity_config' }),
|
|
173
182
|
'/api/auth/config': (b) => ({ event: 'auth_config', enabled: b.enabled, issuer: b.issuer }),
|
|
@@ -219,6 +228,7 @@ function createUiServer(opts = {}) {
|
|
|
219
228
|
const REGISTER_PATH = path.join(root, 'tools', 'tools.register.json');
|
|
220
229
|
const TOOL_STATE_PATH = path.join(root, 'tools', 'tools.state.json');
|
|
221
230
|
const EXPOSE_PATH = path.join(root, 'mcp', 'expose.json');
|
|
231
|
+
const APPROVALS_PATH = path.join(root, 'mcp', 'tool-approvals.json');
|
|
222
232
|
const MANIFEST_PATH = path.join(root, 'hooks', 'hooks.manifest.json');
|
|
223
233
|
const HOOK_STATE_PATH = path.join(root, 'hooks', 'hooks.state.json');
|
|
224
234
|
const SCRIPTS_ROOT = path.join(root, 'tools', 'scripts');
|
|
@@ -236,7 +246,7 @@ function createUiServer(opts = {}) {
|
|
|
236
246
|
let started = false;
|
|
237
247
|
|
|
238
248
|
function currentUrl() {
|
|
239
|
-
return boundPort == null ? null : `http://${host}:${boundPort}`;
|
|
249
|
+
return boundPort == null ? null : `http://${urlHost(host)}:${boundPort}`;
|
|
240
250
|
}
|
|
241
251
|
|
|
242
252
|
// ── Store-backed config reads (FRESH per call so external edits show immediately) ──────────────
|
|
@@ -455,10 +465,10 @@ function createUiServer(opts = {}) {
|
|
|
455
465
|
const gateName = gateNameFor(registry.getEntry(id));
|
|
456
466
|
|
|
457
467
|
// The id becomes a FILENAME and is interpolated into a manifest command that hook-runner
|
|
458
|
-
// spawns with shell:true, so it must be filename-safe
|
|
459
|
-
// non-empty string, and an id carrying a quote
|
|
460
|
-
// while the UI still
|
|
461
|
-
// or
|
|
468
|
+
// spawns with shell:true, so it must be filename-safe. The register only requires a
|
|
469
|
+
// non-empty string, and an id carrying a quote would produce a command that either cannot run -
|
|
470
|
+
// while the UI still reports "Pre gate enabled", i.e. a dead gate presented as a live one -
|
|
471
|
+
// or carries arbitrary shell text into a file the operator reads as policy. Same character
|
|
462
472
|
// class deriveHookScriptRel already accepts.
|
|
463
473
|
if (!/^[A-Za-z0-9._-]+$/.test(id)) {
|
|
464
474
|
return {
|
|
@@ -515,7 +525,7 @@ function createUiServer(opts = {}) {
|
|
|
515
525
|
&& h.command !== ourCommand;
|
|
516
526
|
|
|
517
527
|
if (on) {
|
|
518
|
-
// GATE IDENTITY MUST BE UNAMBIGUOUS
|
|
528
|
+
// GATE IDENTITY MUST BE UNAMBIGUOUS. gateNameFor is `name || id` and the register
|
|
519
529
|
// never constrains `name`, so two DIFFERENT tools can resolve to ONE matcher, and ON would
|
|
520
530
|
// hand the second tool a gate it does not own. Ownership resolves by ID, which the register
|
|
521
531
|
// guarantees unique: the tool whose *id* IS the gate name is the canonical owner and keeps
|
|
@@ -624,7 +634,7 @@ function createUiServer(opts = {}) {
|
|
|
624
634
|
}
|
|
625
635
|
|
|
626
636
|
// OFF: drop ONLY the per-tool LITERAL entry the ON branch authors - never a hook that
|
|
627
|
-
// merely ALSO fires for this tool (
|
|
637
|
+
// merely ALSO fires for this tool (such as a global wildcard gate).
|
|
628
638
|
const next = hooks.filter((h) => !isOwnLiteral(h));
|
|
629
639
|
const removed = hooks.length - next.length;
|
|
630
640
|
if (removed > 0) {
|
|
@@ -651,13 +661,45 @@ function createUiServer(opts = {}) {
|
|
|
651
661
|
return { status: 200, json };
|
|
652
662
|
}
|
|
653
663
|
|
|
664
|
+
// GET /api/tool-changes -> tool descriptor changes held back for acceptance, read from the
|
|
665
|
+
// approvals file the gateway writes. An unreadable file is REPORTED (ok:false) - it must never look
|
|
666
|
+
// like "nothing waiting", because the gateway is withholding that upstream's tools meanwhile.
|
|
667
|
+
function apiToolChanges() {
|
|
668
|
+
try {
|
|
669
|
+
const doc = approvals.loadApprovals(APPROVALS_PATH);
|
|
670
|
+
return { ok: true, pending: approvals.listPending(doc), upgraded: approvals.listUpgrades(doc, 10) };
|
|
671
|
+
} catch (err) {
|
|
672
|
+
return { ok: false, pending: [], upgraded: [], error: (err && err.message) || String(err) };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// POST /api/tool-changes/accept {upstream, tool?, hash?} -> the engine accept every route shares
|
|
677
|
+
// (acceptInFile). The page sends the hash it listed, so a server that changed the tool again in
|
|
678
|
+
// between is not approved sight unseen. A running gateway applies it from the next call.
|
|
679
|
+
function apiToolChangeAccept(body) {
|
|
680
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
681
|
+
const bad = (error) => ({ status: 400, json: { ok: false, error } });
|
|
682
|
+
if (typeof b.upstream !== 'string' || b.upstream.length === 0) return bad('"upstream" (the upstream id) is required');
|
|
683
|
+
if (b.tool !== undefined && (typeof b.tool !== 'string' || b.tool.length === 0)) return bad('"tool" must be a non-empty string when given');
|
|
684
|
+
if (b.hash !== undefined && (typeof b.hash !== 'string' || b.hash.length === 0)) return bad('"hash" must be a non-empty string when given');
|
|
685
|
+
let res;
|
|
686
|
+
try {
|
|
687
|
+
res = approvals.acceptInFile(APPROVALS_PATH, b.upstream, b.tool, b.hash);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'accept failed' } };
|
|
690
|
+
}
|
|
691
|
+
const why = approvals.acceptFailure(res, b.upstream, b.tool);
|
|
692
|
+
if (why) return bad(res.stale ? why + ' - reload to see the current one' : why);
|
|
693
|
+
return { status: 200, json: { ok: true, accepted: res.accepted, pending: res.pending } };
|
|
694
|
+
}
|
|
695
|
+
|
|
654
696
|
// GET /api/upstreams -> { upstreams:[...], expose:[...] } (READ-ONLY for v1).
|
|
655
697
|
function apiUpstreams() {
|
|
656
698
|
const store = loadExposeStore(EXPOSE_PATH);
|
|
657
699
|
return {
|
|
658
700
|
upstreams: store.listUpstreams(),
|
|
659
701
|
expose: store.listExposed(),
|
|
660
|
-
// The active passthrough wrap
|
|
702
|
+
// The active passthrough wrap - null when the normal funnel surface is live.
|
|
661
703
|
wrapping: getPassthrough(loadToolState(TOOL_STATE_PATH)),
|
|
662
704
|
};
|
|
663
705
|
}
|
|
@@ -917,8 +959,8 @@ function createUiServer(opts = {}) {
|
|
|
917
959
|
// POST /api/oauth/install -> install the single optional dependency (jose@PIN) on demand. Async +
|
|
918
960
|
// potentially slow (it shells out to npm); installJose() owns a hard timeout and never throws.
|
|
919
961
|
//
|
|
920
|
-
// NOT on the POST mutation chain
|
|
921
|
-
// chain exists to serialise, so queueing it there put a multi-minute npm run in front of every
|
|
962
|
+
// NOT on the POST mutation chain: this writes node_modules/, not the config files that
|
|
963
|
+
// chain exists to serialise, so queueing it there would put a multi-minute npm run in front of every
|
|
922
964
|
// other POST - one click and the whole panel stops answering. It still must not overlap ITSELF,
|
|
923
965
|
// so it keeps a single-flight latch of its own: concurrent callers COALESCE onto the running
|
|
924
966
|
// install and get its result rather than starting a second npm.
|
|
@@ -1072,15 +1114,30 @@ function createUiServer(opts = {}) {
|
|
|
1072
1114
|
if (!registry.has(id)) {
|
|
1073
1115
|
return { status: 404, json: { ok: false, error: `unknown tool id "${id}"` } };
|
|
1074
1116
|
}
|
|
1117
|
+
// The script slot's prior body, so a refused register update can put it back exactly (absent
|
|
1118
|
+
// stays absent, an overwritten body returns) - apiToolAdd's snapshot, same reason: without it
|
|
1119
|
+
// the 400 reply left the NEW body on disk under an UNCHANGED entry.
|
|
1120
|
+
let slot = null;
|
|
1121
|
+
let priorBody = null;
|
|
1075
1122
|
try {
|
|
1076
1123
|
// Author the script body first (if provided) so the file matches the (possibly updated) invoke.
|
|
1077
1124
|
if (typeof body.scriptText === 'string') {
|
|
1078
1125
|
const inv = (patch.invoke && patch.invoke.type) ? patch.invoke : registry.getEntry(id).invoke;
|
|
1079
|
-
if (inv && inv.type === 'script' && inv.path)
|
|
1126
|
+
if (inv && inv.type === 'script' && inv.path) {
|
|
1127
|
+
slot = path.resolve(SCRIPTS_ROOT, path.basename(inv.path));
|
|
1128
|
+
priorBody = fs.existsSync(slot) ? fs.readFileSync(slot) : null;
|
|
1129
|
+
registry.writeScript(inv.path, body.scriptText);
|
|
1130
|
+
}
|
|
1080
1131
|
}
|
|
1081
1132
|
const updated = registry.update(id, patch);
|
|
1082
1133
|
return { status: 200, json: { ok: true, entry: updated } };
|
|
1083
1134
|
} catch (err) {
|
|
1135
|
+
if (slot) {
|
|
1136
|
+
try {
|
|
1137
|
+
if (priorBody != null) fs.writeFileSync(slot, priorBody);
|
|
1138
|
+
else fs.unlinkSync(slot);
|
|
1139
|
+
} catch (_e) { /* best-effort - the 400 below still reports the update failure */ }
|
|
1140
|
+
}
|
|
1084
1141
|
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool update failed' } };
|
|
1085
1142
|
}
|
|
1086
1143
|
}
|
|
@@ -1205,8 +1262,12 @@ function createUiServer(opts = {}) {
|
|
|
1205
1262
|
return { status: 400, json: { ok: false, error: 'upstream (object) is required' } };
|
|
1206
1263
|
}
|
|
1207
1264
|
const store = loadExposeStore(EXPOSE_PATH);
|
|
1265
|
+
let added = null;
|
|
1266
|
+
// Each ENABLED aliased item migrates gate matchers / state keys as it lands; removeUpstream alone
|
|
1267
|
+
// would not put those back. Snapshot the migration's files up front and restore them on failure.
|
|
1268
|
+
const snap = snapshotForStoreFile(EXPOSE_PATH);
|
|
1208
1269
|
try {
|
|
1209
|
-
|
|
1270
|
+
added = store.addUpstream(upstream);
|
|
1210
1271
|
const items = Array.isArray(body && body.expose) ? body.expose : [];
|
|
1211
1272
|
const exposed = [];
|
|
1212
1273
|
for (const item of items) {
|
|
@@ -1214,6 +1275,14 @@ function createUiServer(opts = {}) {
|
|
|
1214
1275
|
}
|
|
1215
1276
|
return { status: 200, json: { ok: true, upstream: added, expose: exposed } };
|
|
1216
1277
|
} catch (err) {
|
|
1278
|
+
// The upstream (and any earlier expose items) persisted before a later item was refused. A
|
|
1279
|
+
// reply that reports failure must not keep half the add - without the unwind, the natural
|
|
1280
|
+
// retry died on its own first attempt as a duplicate id (apiHookAdd's catch, same symptom).
|
|
1281
|
+
// removeUpstream cascades this id's expose entries, so the unwind is one call.
|
|
1282
|
+
if (added) {
|
|
1283
|
+
try { store.removeUpstream(added.id); } catch (_e) { /* best-effort; the 400 below still reports the real error */ }
|
|
1284
|
+
restoreSnapshot(snap);
|
|
1285
|
+
}
|
|
1217
1286
|
return { status: 400, json: { ok: false, error: (err && err.message) || 'mcp add failed' } };
|
|
1218
1287
|
}
|
|
1219
1288
|
}
|
|
@@ -1234,7 +1303,7 @@ function createUiServer(opts = {}) {
|
|
|
1234
1303
|
if (action === 'remove') {
|
|
1235
1304
|
store.removeUpstream(id);
|
|
1236
1305
|
} else if (action === 'pin' || action === 'unpin') {
|
|
1237
|
-
// legacyPin
|
|
1306
|
+
// legacyPin: keep speaking MCP 2024-11-05 to this upstream forever - opt-in,
|
|
1238
1307
|
// warns at startup and per call. Takes effect on the upstream's NEXT (re)connect.
|
|
1239
1308
|
store.updateUpstream(id, { legacyPin: action === 'pin' });
|
|
1240
1309
|
return { status: 200, json: { ok: true, legacyPin: action === 'pin' } };
|
|
@@ -1336,6 +1405,7 @@ function createUiServer(opts = {}) {
|
|
|
1336
1405
|
'/api/hooks/state': serialise(apiHookState),
|
|
1337
1406
|
'/api/mcp/add': serialise(apiMcpAdd),
|
|
1338
1407
|
'/api/mcp/state': serialise(apiMcpState),
|
|
1408
|
+
'/api/tool-changes/accept': serialise(apiToolChangeAccept),
|
|
1339
1409
|
// discover is a read-only probe - no config write, no need to queue it behind mutations.
|
|
1340
1410
|
'/api/mcp/discover': apiMcpDiscover,
|
|
1341
1411
|
'/api/wrap': serialise(apiWrap),
|
|
@@ -1349,7 +1419,14 @@ function createUiServer(opts = {}) {
|
|
|
1349
1419
|
|
|
1350
1420
|
// ── HTTP plumbing ───────────────────────────────────────────────────────────────────────────
|
|
1351
1421
|
|
|
1352
|
-
/**
|
|
1422
|
+
/**
|
|
1423
|
+
* Read a request body up to MAX_BODY_BYTES, then JSON.parse. Resolves {} for an empty body.
|
|
1424
|
+
* On over-cap it does NOT destroy the request: a destroy sends a TCP RST while request bytes are
|
|
1425
|
+
* still in flight, and on RST Windows discards the peer's receive queue - the 400 the caller then
|
|
1426
|
+
* wrote never reached the client (it saw ECONNRESET). Instead stop BUFFERING (later chunks are
|
|
1427
|
+
* discarded, memory stays bounded) and reject; the caller replies via sendJsonAndClose, which
|
|
1428
|
+
* flushes the body first and closes the socket only after. Mirrors http-transport's readBody.
|
|
1429
|
+
*/
|
|
1353
1430
|
function readJsonBody(req) {
|
|
1354
1431
|
return new Promise((resolve, reject) => {
|
|
1355
1432
|
let size = 0;
|
|
@@ -1357,10 +1434,10 @@ function createUiServer(opts = {}) {
|
|
|
1357
1434
|
let done = false;
|
|
1358
1435
|
const finish = (fn, val) => { if (!done) { done = true; fn(val); } };
|
|
1359
1436
|
req.on('data', (chunk) => {
|
|
1437
|
+
if (done) return; // over-cap already signalled - discard, don't buffer
|
|
1360
1438
|
size += chunk.length;
|
|
1361
1439
|
if (size > MAX_BODY_BYTES) {
|
|
1362
1440
|
finish(reject, new Error('request body too large'));
|
|
1363
|
-
try { req.destroy(); } catch (_e) { /* ignore */ }
|
|
1364
1441
|
return;
|
|
1365
1442
|
}
|
|
1366
1443
|
chunks.push(chunk);
|
|
@@ -1395,6 +1472,48 @@ function createUiServer(opts = {}) {
|
|
|
1395
1472
|
res.end(bodyStr);
|
|
1396
1473
|
}
|
|
1397
1474
|
|
|
1475
|
+
/**
|
|
1476
|
+
* Send a JSON response and then CLOSE the connection, draining the unread request body WITHOUT
|
|
1477
|
+
* buffering it - the over-cap reply path. The naive close (req.destroy(), even deferred to the
|
|
1478
|
+
* flush callback) sends a TCP RST whenever request bytes are still in flight, and on RST Windows
|
|
1479
|
+
* discards the peer's receive queue, eating the reply we just flushed. The sequence that holds
|
|
1480
|
+
* (http-transport's sendJsonAndClose is the reference implementation, pinned by http.test.js
|
|
1481
|
+
* step 5; ui-body-cap.test.js pins this one): announce Connection: close, write the full body,
|
|
1482
|
+
* then in the flush callback resume() the request so the rest is discarded, HALF-CLOSE the
|
|
1483
|
+
* socket (the FIN travels in-order after the body), and arm an unref'd hard-destroy backstop.
|
|
1484
|
+
*/
|
|
1485
|
+
function sendJsonAndClose(req, res, status, obj) {
|
|
1486
|
+
let bodyStr;
|
|
1487
|
+
try {
|
|
1488
|
+
bodyStr = JSON.stringify(obj);
|
|
1489
|
+
} catch (_e) {
|
|
1490
|
+
bodyStr = '{"ok":false,"error":"failed to serialise response"}';
|
|
1491
|
+
status = 500;
|
|
1492
|
+
}
|
|
1493
|
+
const destroyReq = () => { try { req.destroy(); } catch (_e) { /* the socket may already be gone */ } };
|
|
1494
|
+
try {
|
|
1495
|
+
res.writeHead(status, {
|
|
1496
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1497
|
+
'Content-Length': Buffer.byteLength(bodyStr, 'utf8'),
|
|
1498
|
+
'Cache-Control': 'no-store',
|
|
1499
|
+
Connection: 'close',
|
|
1500
|
+
});
|
|
1501
|
+
res.end(bodyStr, () => {
|
|
1502
|
+
try {
|
|
1503
|
+
req.resume(); // discard (never buffer) whatever request body is still arriving
|
|
1504
|
+
if (req.socket && !req.socket.destroyed) req.socket.end(); // FIN, in-order after the body
|
|
1505
|
+
} catch (_e) {
|
|
1506
|
+
return destroyReq();
|
|
1507
|
+
}
|
|
1508
|
+
const t = setTimeout(destroyReq, 10000);
|
|
1509
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
1510
|
+
if (req.socket) req.socket.once('close', () => clearTimeout(t));
|
|
1511
|
+
});
|
|
1512
|
+
} catch (_e) {
|
|
1513
|
+
destroyReq(); // headers already sent by a racing path - never leave the socket half-open
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1398
1517
|
/** Serve a static file from PUBLIC_DIR (read FRESH). 404 JSON when absent / outside the dir. */
|
|
1399
1518
|
function sendStatic(res, relName) {
|
|
1400
1519
|
// Resolve strictly inside PUBLIC_DIR (defence-in-depth against traversal).
|
|
@@ -1477,6 +1596,9 @@ function createUiServer(opts = {}) {
|
|
|
1477
1596
|
const d = apiToolDetail(id);
|
|
1478
1597
|
return sendJson(res, d.status, d.json);
|
|
1479
1598
|
}
|
|
1599
|
+
if (method === 'GET' && pathName === '/api/tool-changes') {
|
|
1600
|
+
return sendJson(res, 200, apiToolChanges());
|
|
1601
|
+
}
|
|
1480
1602
|
if (method === 'GET' && pathName === '/api/upstreams') {
|
|
1481
1603
|
return sendJson(res, 200, apiUpstreams());
|
|
1482
1604
|
}
|
|
@@ -1507,7 +1629,11 @@ function createUiServer(opts = {}) {
|
|
|
1507
1629
|
try {
|
|
1508
1630
|
body = await readJsonBody(req);
|
|
1509
1631
|
} catch (err) {
|
|
1510
|
-
|
|
1632
|
+
const msg = (err && err.message) || 'bad request body';
|
|
1633
|
+
// An over-cap body is still arriving: reply first, then close the socket cleanly (see
|
|
1634
|
+
// sendJsonAndClose). Any other body error is a finished request - a plain reply is fine.
|
|
1635
|
+
if (/too large/.test(msg)) return sendJsonAndClose(req, res, 400, { ok: false, error: msg });
|
|
1636
|
+
return sendJson(res, 400, { ok: false, error: msg });
|
|
1511
1637
|
}
|
|
1512
1638
|
// Handlers may be sync OR async (apiMcpDiscover connects an upstream) - await covers both.
|
|
1513
1639
|
const result = await POST_HANDLERS[pathName](body);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tf-accept-change.js - the tf_accept_change management tool (tool descriptor approval).
|
|
4
|
+
*
|
|
5
|
+
* When an upstream's tool changes its description or schema while the server version stays the
|
|
6
|
+
* same, the gateway keeps serving the APPROVED descriptor and refuses calls to the tool, returning
|
|
7
|
+
* which fields changed and this tool as the way to accept (the new text itself is shown only in
|
|
8
|
+
* `toolfunnel changes` and the UI). The agent is meant to ASK THE USER
|
|
9
|
+
* first; the call is gated by PreToolUse like every register tool, and so is the in-chat accept a
|
|
10
|
+
* 2026-07-28 client gets (the gateway gates it as tf_accept_change).
|
|
11
|
+
*
|
|
12
|
+
* Contract (same as the other tf-* scripts):
|
|
13
|
+
* - Args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS: { upstream, tool, hash }, all required:
|
|
14
|
+
* an agent accepts exactly the change the user was shown - one tool, at the hash from the
|
|
15
|
+
* refusal (a server that changed again is not approved sight unseen). Accepting several
|
|
16
|
+
* at once is for the user: `toolfunnel accept <upstream>` or the UI.
|
|
17
|
+
* - Output: ONE JSON line { ok, accepted, pending, error? }; exit 0 either way.
|
|
18
|
+
* - The logic lives in the engine (tool-approvals.js acceptInFile), shared with the CLI, the UI
|
|
19
|
+
* and the gateway; this script only adapts it. The running gateway applies the accept on the
|
|
20
|
+
* next call (it re-reads the file) and its file watcher refreshes the listings.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
// Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
|
|
25
|
+
const { HOME, srcRequire } = require('./tf-env');
|
|
26
|
+
const approvals = srcRequire('mcp/tool-approvals');
|
|
27
|
+
|
|
28
|
+
const APPROVALS_PATH = path.join(HOME, 'mcp', 'tool-approvals.json');
|
|
29
|
+
|
|
30
|
+
function parseArgs() {
|
|
31
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
32
|
+
if (raw === undefined || raw === null || raw === '') return {};
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
35
|
+
throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function run() {
|
|
41
|
+
const a = parseArgs();
|
|
42
|
+
if (typeof a.upstream !== 'string' || a.upstream.length === 0) throw new Error('"upstream" (the upstream id) is required');
|
|
43
|
+
if (typeof a.tool !== 'string' || a.tool.length === 0) throw new Error('"tool" (the changed tool\'s name, from the refusal) is required');
|
|
44
|
+
if (typeof a.hash !== 'string' || a.hash.length === 0) throw new Error('"hash" (from the refusal) is required');
|
|
45
|
+
const res = approvals.acceptInFile(APPROVALS_PATH, a.upstream, a.tool, a.hash);
|
|
46
|
+
const why = approvals.acceptFailure(res, a.upstream, a.tool);
|
|
47
|
+
if (why) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false, accepted: [], pending: res.pending || [],
|
|
50
|
+
error: why + (res.stale ? '. Call the tool once more to see the current change, and ask the user again.' : ''),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, accepted: res.accepted, pending: res.pending, note: 'The running gateway applies this from the next call.' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function main() {
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
payload = run();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
payload = { ok: false, accepted: [], pending: [], error: (err && err.message) || String(err) };
|
|
62
|
+
}
|
|
63
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main();
|
|
@@ -44,6 +44,7 @@ const path = require('node:path');
|
|
|
44
44
|
// Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
|
|
45
45
|
const { HOME, srcRequire } = require('./tf-env');
|
|
46
46
|
const { loadExposeStore } = srcRequire('mcp/expose-store');
|
|
47
|
+
const { snapshotForStoreFile, restoreSnapshot } = srcRequire('core/rename-migration');
|
|
47
48
|
|
|
48
49
|
const ROOT = HOME;
|
|
49
50
|
|
|
@@ -73,31 +74,43 @@ function run() {
|
|
|
73
74
|
const args = parseArgs();
|
|
74
75
|
const store = loadExposeStore(EXPOSE_PATH);
|
|
75
76
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
77
|
+
// Values go to the store RAW: it defaults what is absent and REFUSES what is wrong-typed,
|
|
78
|
+
// naming the field. Coercing here - `enabled: "false"` to true - would hide exactly the
|
|
79
|
+
// mistake the store reports.
|
|
78
80
|
const upstream = store.addUpstream({
|
|
79
81
|
id: args.id,
|
|
80
82
|
command: args.command,
|
|
81
83
|
args: args.args,
|
|
82
84
|
env: args.env,
|
|
83
|
-
transport:
|
|
84
|
-
enabled:
|
|
85
|
+
transport: args.transport,
|
|
86
|
+
enabled: args.enabled,
|
|
85
87
|
description: args.description,
|
|
86
88
|
});
|
|
87
89
|
|
|
90
|
+
// addUpstream has persisted; a later expose item can still be refused. A reply of ok:false must
|
|
91
|
+
// not keep half the add - the natural retry would die as a duplicate id. Unwind the whole add, as
|
|
92
|
+
// the UI's /api/mcp/add does: removeUpstream cascades this id's expose entries, and the snapshot
|
|
93
|
+
// puts back the gate matchers / state keys an ENABLED aliased item migrated as it landed.
|
|
94
|
+
const snap = snapshotForStoreFile(EXPOSE_PATH);
|
|
88
95
|
const exposed = [];
|
|
89
96
|
const exposeList = Array.isArray(args.expose) ? args.expose : [];
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
try {
|
|
98
|
+
for (const item of exposeList) {
|
|
99
|
+
const e = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
|
|
100
|
+
exposed.push(
|
|
101
|
+
store.addExpose({
|
|
102
|
+
upstream: upstream.id,
|
|
103
|
+
tool: e.tool,
|
|
104
|
+
as: e.as,
|
|
105
|
+
category: e.category,
|
|
106
|
+
enabled: e.enabled,
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
try { store.removeUpstream(upstream.id); } catch (_e) { /* best-effort; the real error is reported below */ }
|
|
112
|
+
restoreSnapshot(snap);
|
|
113
|
+
throw err;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
return { ok: true, upstream, exposed };
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"id": "hash",
|
|
29
29
|
"name": "Hash",
|
|
30
|
-
"summary": "Compute a
|
|
30
|
+
"summary": "Compute a sha256, sha1 or md5 digest of input text.",
|
|
31
31
|
"category": "crypto",
|
|
32
32
|
"instructions": "Args: { algo?: \"sha256\"|\"sha1\"|\"md5\" (default \"sha256\"), text: string }. Returns the lowercase hex digest of the UTF-8 text.",
|
|
33
33
|
"invoke": {
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
{
|
|
83
83
|
"id": "tf_tool_add",
|
|
84
84
|
"name": "tf_tool_add",
|
|
85
|
-
"summary": "Register a new tool
|
|
85
|
+
"summary": "Register a new tool (a script or a shell command) in the gateway tool register.",
|
|
86
86
|
"category": "management",
|
|
87
87
|
"instructions": "Add a register entry. Args: { id, name, summary?, category?, instructions?, inputSchema?, invoke:{type:'script',path:'scripts/<file>'}|{type:'shell',command}, scriptText? }. inputSchema is a JSON Schema object for the tool's args - a HOT-promoted tool advertises it verbatim in the top-level tools/list, so author it if the tool may be promoted. If scriptText is given for a script invoke, the body is written to tools/scripts/<basename> first. Change is LIVE (no reconnect). Fails if id already exists.",
|
|
88
88
|
"invoke": {
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
{
|
|
94
94
|
"id": "tf_tool_set",
|
|
95
95
|
"name": "tf_tool_set",
|
|
96
|
-
"summary": "
|
|
96
|
+
"summary": "Enable, disable, promote (hot), hide, or remove a register tool.",
|
|
97
97
|
"category": "management",
|
|
98
98
|
"instructions": "Args: { id, action:'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }. enable/disable flips the active overlay (default ON) - disabled tools drop from toolfunnel_list_tools. hot/unhot promote/demote on the TOP-LEVEL every-turn surface (tools/list), making it directly callable (default OFF). hide/unhide declutter the MANAGER views only (tf_list + UI) and do NOT change what the AI sees. The id may be a local tool id, an upstream surfaced name, or a meta-tool name. remove deletes the register entry and purges its state key. All LIVE (no restart).",
|
|
99
99
|
"invoke": {
|
|
@@ -123,10 +123,21 @@
|
|
|
123
123
|
"path": "scripts/tf-mcp-set.js"
|
|
124
124
|
}
|
|
125
125
|
},
|
|
126
|
+
{
|
|
127
|
+
"id": "tf_accept_change",
|
|
128
|
+
"name": "tf_accept_change",
|
|
129
|
+
"summary": "Accept a tool description change ToolFunnel is holding back - ask the user first.",
|
|
130
|
+
"category": "management",
|
|
131
|
+
"instructions": "Args: { upstream, tool, hash }, all required - copy them from the refusal. When an upstream's tool changes its description or schema while the server version stays the same, ToolFunnel keeps serving the approved version and refuses calls to it, naming which fields changed; the user reviews the new text with `toolfunnel changes` or in the UI. ASK THE USER before running this. Only the version the user saw is accepted; to accept several changes at once the user runs `toolfunnel accept <upstream>` or uses the UI. Takes effect from the next call.",
|
|
132
|
+
"invoke": {
|
|
133
|
+
"type": "script",
|
|
134
|
+
"path": "scripts/tf-accept-change.js"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
126
137
|
{
|
|
127
138
|
"id": "tf_wrap",
|
|
128
139
|
"name": "tf_wrap",
|
|
129
|
-
"summary": "Wrap ONE attached upstream MCP: its tools become the entire surface (transparent passthrough).",
|
|
140
|
+
"summary": "Wrap ONE attached upstream MCP: its tools become the entire surface (transparent passthrough). Also reports or clears the wrap.",
|
|
130
141
|
"category": "management",
|
|
131
142
|
"instructions": "Args: {} reports status ({ wrapping, upstreams }). { off:true } clears the wrap. { upstream:'<id>', confirm:true } sets it: that upstream's tools become the ENTIRE advertised surface under their ORIGINAL names, everything else (meta-tools INCLUDED) is hidden and uncallable, and every call still fires the PreToolUse gate. Because the gateway answers both MCP eras, this keeps a legacy-only MCP working with 2026-07-28-era clients. WARNING (why confirm is required): the wrap hides tf_wrap itself, so YOU cannot undo it in-band afterwards - recovery is the config web UI (--ui) or `toolfunnel wrap --off` on a terminal. A running host applies the change live.",
|
|
132
143
|
"invoke": {
|
|
@@ -170,7 +181,7 @@
|
|
|
170
181
|
{
|
|
171
182
|
"id": "tf_log",
|
|
172
183
|
"name": "tf_log",
|
|
173
|
-
"summary": "Enable, disable, or check the gateway activity/audit log.",
|
|
184
|
+
"summary": "Enable, disable, or check the status of the gateway activity/audit log.",
|
|
174
185
|
"category": "management",
|
|
175
186
|
"instructions": "Args: { action:'enable'|'disable'|'status', path? }. Toggles ToolFunnel's JSONL audit log (tool runs + gate allow/deny decisions). Default OFF. 'status' returns {enabled,path} + recent count. Like any tool, tf_log can itself be disabled to revoke logging control.",
|
|
176
187
|
"invoke": {
|