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/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 (0.7.0). The register only requires a
459
- // non-empty string, and an id carrying a quote produced a command that either could not run -
460
- // while the UI still reported "Pre gate enabled", i.e. a dead gate presented as a live one -
461
- // or carried arbitrary shell text into a file the operator reads as policy. Same character
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 (0.7.0). gateNameFor is `name || id` and the register
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 (0.7.0 deleted the global wildcard gate here).
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 (0.6.0) - null when the normal funnel surface is live.
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 (0.7.0): this writes node_modules/, not the config files that
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) registry.writeScript(inv.path, body.scriptText);
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
- const added = store.addUpstream(upstream);
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 (0.6.0): keep speaking MCP 2024-11-05 to this upstream forever - opt-in,
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
- /** Read a request body up to MAX_BODY_BYTES, then JSON.parse. Resolves {} for an empty body. */
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
- return sendJson(res, 400, { ok: false, error: (err && err.message) || 'bad request body' });
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
- // The store's normaliseUpstream already defaults transport/enabled/args/env, but
77
- // apply the documented defaults explicitly so the intent is visible here too.
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: typeof args.transport === 'string' ? args.transport : 'stdio',
84
- enabled: typeof args.enabled === 'boolean' ? args.enabled : true,
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
- for (const item of exposeList) {
91
- const e = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
92
- exposed.push(
93
- store.addExpose({
94
- upstream: upstream.id,
95
- tool: e.tool,
96
- as: e.as,
97
- category: e.category,
98
- enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
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 cryptographic digest (e.g. sha256) of input text.",
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 entry in the gateway tool register.",
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": "Activate, deactivate, promote (hot), hide, or remove a register tool.",
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": {