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/mcp/server.js CHANGED
@@ -40,6 +40,7 @@ const fs = require('node:fs');
40
40
  const path = require('node:path');
41
41
  const { spawn } = require('node:child_process');
42
42
  const reaper = require('../core/child-reaper');
43
+ const { takeWithinCap } = require('../core/output-cap');
43
44
 
44
45
  const { makeProtocol } = require('./protocol');
45
46
  const { gatedRun } = require('./gated-run');
@@ -79,16 +80,16 @@ const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
79
80
  // behaviourally identical to the bare gateway until an upstream + expose entry is added.
80
81
  const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
81
82
 
82
- // Every legacy revision this server speaks, NEWEST FIRST (0.7.0). initialize echoes the
83
+ // Every legacy revision this server speaks, NEWEST FIRST. initialize echoes the
83
84
  // requested member (2.1); an unsupported string gets LEGACY_VERSIONS[0] (lifecycle SHOULD:
84
85
  // "the latest version supported by the server").
85
86
  const LEGACY_VERSIONS = Object.freeze(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']);
86
- // Hard cap on JSON-RPC batch members (0.7.0) - see handleBatch. Well past any real client's
87
+ // Hard cap on JSON-RPC batch members - see handleBatch. Well past any real client's
87
88
  // batching, far short of buying unbounded gated execution with one message.
88
89
  const MAX_BATCH_MEMBERS = 64;
89
- // LOCAL shell-tool bounds (0.7.0), matching tools/registry.js's script executor: 120 s (the
90
+ // LOCAL shell-tool bounds, matching tools/registry.js's script executor: 120 s (the
90
91
  // upstream PAYLOAD window) and a 4 MiB output ceiling. Without a clock a hanging shell tool
91
- // wedged the serialised stdio chain and starved every later request, `ping` included.
92
+ // would wedge the serialised stdio chain and starve every later request, `ping` included.
92
93
  // Test seam: the suite proves the timeout/cap BEHAVIOUR by shrinking the windows via env;
93
94
  // production never sets them (same seam as tools/registry.js).
94
95
  const SHELL_TIMEOUT_MS = Number(process.env.TOOLFUNNEL_SHELL_TIMEOUT_MS) > 0
@@ -144,6 +145,11 @@ function logErr(...parts) {
144
145
  }
145
146
  }
146
147
 
148
+ /** An unknown field in expose.json, named at start and on every reload (a misspelt flag does nothing). */
149
+ function warnConfigField(message) {
150
+ logErr(message);
151
+ }
152
+
147
153
  // ─────────────────────────────────────────────────────────────────────────────────────────────
148
154
  // Registry adapter - bridge the real Registry to what protocol.runTool consumes.
149
155
  //
@@ -186,11 +192,10 @@ function makeRegistryAdapter(registry, opts) {
186
192
  }
187
193
 
188
194
  /**
189
- * Run a shell command, normalising to { ok, code, stdout, stderr }. ASYNC spawn - the old
190
- * spawnSync blocked the event loop for the tool's whole duration, stalling every concurrent
191
- * HTTP client (and pings) behind one long shell tool (0.5.0 debt). Its
192
- * only caller is the gated async execute thunk, so the promise is awaited naturally; the
193
- * spawn-failure contract (throw/reject with the same message) is unchanged.
195
+ * Run a shell command, normalising to { ok, code, stdout, stderr }. ASYNC spawn - a
196
+ * spawnSync would block the event loop for the tool's whole duration, stalling every concurrent
197
+ * HTTP client (and pings) behind one long shell tool. Its
198
+ * only caller is the gated async execute thunk, so the promise is awaited naturally.
194
199
  */
195
200
  function runShell(command, args) {
196
201
  return new Promise((resolve, reject) => {
@@ -213,9 +218,9 @@ function makeRegistryAdapter(registry, opts) {
213
218
  let stdout = '';
214
219
  let stderr = '';
215
220
  let settled = false;
216
- // TIMED + BOUNDED (0.7.0) - the twin of the script executor's fix in tools/registry.js. A
217
- // shell tool with no clock wedged the serialised stdio chain for its whole lifetime, and
218
- // unbounded accumulation made a chatty tool a memory risk in the gateway process.
221
+ // TIMED + BOUNDED - the twin of the script executor's bounds in tools/registry.js. A
222
+ // shell tool with no clock would wedge the serialised stdio chain for its whole lifetime, and
223
+ // unbounded accumulation would make a chatty tool a memory risk in the gateway process.
219
224
  const finish = (result) => {
220
225
  if (settled) return;
221
226
  settled = true;
@@ -248,8 +253,10 @@ function makeRegistryAdapter(registry, opts) {
248
253
  child.stdout.setEncoding('utf8');
249
254
  child.stdout.on('data', (d) => {
250
255
  if (shOutBytes >= MAX_SHELL_OUTPUT_BYTES) return;
251
- stdout += d;
252
- shOutBytes += Buffer.byteLength(d, 'utf8');
256
+ const kept = takeWithinCap(d, shOutBytes, MAX_SHELL_OUTPUT_BYTES);
257
+ stdout += kept;
258
+ shOutBytes += Buffer.byteLength(kept, 'utf8');
259
+ if (kept.length < d.length) shOutBytes = MAX_SHELL_OUTPUT_BYTES; // cut at a character: the cap is reached
253
260
  if (shOutBytes >= MAX_SHELL_OUTPUT_BYTES) {
254
261
  killShellTree(child);
255
262
  finish({ ok: false, code: -1, stdout, stderr: stderr + '\nshell tool output exceeded the cap and was terminated', outputCapped: true });
@@ -260,8 +267,10 @@ function makeRegistryAdapter(registry, opts) {
260
267
  child.stderr.setEncoding('utf8');
261
268
  child.stderr.on('data', (d) => {
262
269
  if (shErrBytes >= MAX_SHELL_OUTPUT_BYTES) return;
263
- stderr += d;
264
- shErrBytes += Buffer.byteLength(d, 'utf8');
270
+ const kept = takeWithinCap(d, shErrBytes, MAX_SHELL_OUTPUT_BYTES);
271
+ stderr += kept;
272
+ shErrBytes = kept.length < d.length ? MAX_SHELL_OUTPUT_BYTES : shErrBytes + Buffer.byteLength(kept, 'utf8');
273
+ if (shErrBytes >= MAX_SHELL_OUTPUT_BYTES) stderr += '\n[stderr truncated at cap]';
265
274
  });
266
275
  }
267
276
  child.on('error', (err) => {
@@ -326,10 +335,10 @@ function makeRegistryAdapter(registry, opts) {
326
335
  },
327
336
 
328
337
  // Build the { execute, toolName, args, mode } resolution protocol.runTool hands to gatedRun.
329
- resolveExecution(nameOrId, args) {
338
+ resolveExecution(nameOrId, args, callOpts) {
330
339
  const id = resolveId(nameOrId);
331
340
  // ENABLED means "lean-visible AND runnable" (tool-state.js) - the off-switch must switch the
332
- // tool OFF, not merely hide it from the lean list (0.7.0). Read FRESH like list()
341
+ // tool OFF, not merely hide it from the lean list. Read FRESH like list()
333
342
  // so a UI toggle is live. Disabled resolves to null -> the existing clean "not runnable"
334
343
  // error; indistinguishable from absent, which is the point.
335
344
  const state = toolStatePath ? loadToolState(toolStatePath) : null;
@@ -376,7 +385,7 @@ function makeRegistryAdapter(registry, opts) {
376
385
  const agg = liveAggregator();
377
386
  if (!agg || typeof agg.resolveLeanExecution !== 'function') return null;
378
387
  let fwd = null;
379
- try { fwd = agg.resolveLeanExecution(nameOrId, args); } catch (_e) { fwd = null; }
388
+ try { fwd = agg.resolveLeanExecution(nameOrId, args, callOpts); } catch (_e) { fwd = null; }
380
389
  if (!fwd || typeof fwd.execute !== 'function') return null;
381
390
  return { execute: fwd.execute, toolName: fwd.toolName, args: args == null ? {} : args, mode: 'gateway' };
382
391
  },
@@ -446,7 +455,7 @@ function buildProtocol() {
446
455
  // The aggregator over expose.json (EMPTY by default -> nothing connects).
447
456
  // loadExposeStore on a missing/empty file returns an empty store; Aggregator over an empty store
448
457
  // advertises no curated-direct tools and connectAll() is an instant no-op.
449
- const store = loadExposeStore(EXPOSE_PATH);
458
+ const store = loadExposeStore(EXPOSE_PATH, { warnUnknown: warnConfigField });
450
459
  build.aggregator = new Aggregator({
451
460
  store, gatewayRoot: ROOT,
452
461
  // The WRAPPED upstream is exempt from the path-isolation guard (transparent-wrapper mode,
@@ -597,7 +606,7 @@ function handleToolsList(protocol, aggregator, opts) {
597
606
  if (res.parseError) warnCorruptToolState(statePath);
598
607
  }
599
608
 
600
- // ── PASSTHROUGH (0.6.0): the transparent-wrapper mode ────────────────────────────────────────
609
+ // ── PASSTHROUGH: the transparent-wrapper mode ────────────────────────────────────────────────
601
610
  // When tools.state.json carries `"passthrough": "<upstreamId>"`, the advertised surface is
602
611
  // EXACTLY that upstream's tools (real schemas, implicitly hot) - no meta-tools, no locals, no
603
612
  // curated-direct. handleToolsCall routes by the same rule, so the callable surface stays equal
@@ -721,9 +730,9 @@ function passthroughDefinitions(aggregator, state, upstreamId) {
721
730
  }
722
731
  try {
723
732
  // The upstream's RAW discovered defs - a wrapped surface must advertise the tool exactly as
724
- // the upstream does (annotations, title, outputSchema, future fields). The old projection to
725
- // {name, description, inputSchema} made all 13 real server-everything tools differ from a
726
- // direct connection (found wire-testing the wrap, 2026-07-17). leanToolDefinitions still drives the walk: it
733
+ // the upstream does (annotations, title, outputSchema, future fields); a projection to
734
+ // {name, description, inputSchema} would make it differ from a direct connection.
735
+ // leanToolDefinitions still drives the walk: it
727
736
  // carries the SURFACED name (the stable enabled:false key) and the collision guard.
728
737
  const rawByName = new Map();
729
738
  if (typeof aggregator.toolsByUpstream === 'function') {
@@ -835,7 +844,44 @@ function localToolDefinition(entry) {
835
844
  return { name: entry.id, description, inputSchema: schema };
836
845
  }
837
846
 
847
+ /**
848
+ * The unknown-tool error with the next step in it (an agent
849
+ * recovers from "not found, call X", and retries a bare "not found" until it gives up). It names only
850
+ * what THIS client can see: the funnel listing when toolfunnel_list_tools AND toolfunnel_run_tool are
851
+ * on its surface, else tools/list alone - never a hidden meta-tool (lockdown) and never ToolFunnel
852
+ * under a wrap. recovery-hints.test.js.
853
+ * @param {string} name
854
+ * @param {boolean} funnelListing both funnel meta-tools are visible to this client
855
+ */
856
+ function unknownToolText(name, funnelListing) {
857
+ const base = `Unknown tool ${JSON.stringify(String(name))}. Call tools/list to see the tools available right now`;
858
+ return funnelListing
859
+ ? base + '; tools reached through toolfunnel_run_tool are listed by toolfunnel_list_tools.'
860
+ : base + '.';
861
+ }
862
+
863
+ /**
864
+ * tools/call: routeToolsCall, with one step either side for a held tool change. A modern caller
865
+ * that can answer a form question is ASKED to accept the change (MRTR input_required) instead of
866
+ * refused, and its retry carrying the user's yes accepts that exact change and runs the call.
867
+ */
838
868
  async function handleToolsCall(protocol, aggregator, deps, params) {
869
+ const d = deps || {};
870
+ let p = params || {};
871
+ const ask = d.modernCaller === true && typeof p.requestState === 'string' ? takeApprovalAsk(p.requestState) : null;
872
+ if (ask) {
873
+ if (ask.name !== p.name) return { content: [{ type: 'text', text: 'unknown or expired requestState' }], isError: true };
874
+ const notAccepted = await answerApprovalAsk(aggregator, d, ask, p.inputResponses);
875
+ if (notAccepted) return notAccepted;
876
+ p = Object.assign({}, p);
877
+ delete p.requestState;
878
+ delete p.inputResponses;
879
+ }
880
+ const result = await routeToolsCall(protocol, aggregator, d, p);
881
+ return askToAccept(result, d, p.name) || result;
882
+ }
883
+
884
+ async function routeToolsCall(protocol, aggregator, deps, params) {
839
885
  const p = params || {};
840
886
  const name = p.name;
841
887
  const args = p.arguments;
@@ -898,11 +944,15 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
898
944
  const meta = stripProtocolMeta(p._meta);
899
945
  return await runLeanForward(aggregator, { engine: d0.engine, ctx: d0.ctx }, surfaced, args,
900
946
  { calledName: name, meta, cancelKey: d0.cancelKey || null, modernCaller: d0.modernCaller === true,
901
- clientCapabilities: d0.clientCapabilities || null });
947
+ clientCapabilities: d0.clientCapabilities || null, progress: d0.progress || null });
902
948
  }
903
- return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
949
+ return { content: [{ type: 'text', text: unknownToolText(name, false) }], isError: true };
904
950
  }
905
951
 
952
+ // Whether the funnel listing is on THIS client's surface (for the unknown-tool hint): both meta-tools
953
+ // hot - a lockdown that hides them must not be undone by an error message naming them.
954
+ const funnelListing = isToolHot(state, 'toolfunnel_list_tools', true) && isToolHot(state, 'toolfunnel_run_tool', true);
955
+
906
956
  // 1. A meta-tool - but callable ONLY while it is on the advertised top-level surface (hot, default
907
957
  // true). A meta HIDDEN via hot:false is dropped from tools/list AND becomes uncallable, so the
908
958
  // callable surface == the advertised surface even for the meta-tools - the "ordinary tools as an
@@ -910,10 +960,13 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
910
960
  // local/upstream tool: this check runs FIRST, so a hot meta always wins precedence.)
911
961
  if (isMetaTool(protocol, name)) {
912
962
  if (isToolHot(state, name, true)) {
913
- return wrapProtocolResult(await protocol.dispatch(name, args || {}));
963
+ // The caller's request _meta and progress sink ride into the meta-tool: toolfunnel_run_tool on
964
+ // an upstream tool forwards the token and relays its progress.
965
+ return wrapProtocolResult(await protocol.dispatch(name, args || {},
966
+ { meta: stripProtocolMeta(p._meta), progress: d0.progress || null }));
914
967
  }
915
968
  // Hidden meta-tool: not advertised, so not callable - the clean unknown-tool error (never run it).
916
- return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
969
+ return { content: [{ type: 'text', text: unknownToolText(name, funnelListing) }], isError: true };
917
970
  }
918
971
 
919
972
  if (d0.registry && isPromotedLocal(d0.registry, state, name)) {
@@ -921,14 +974,14 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
921
974
  }
922
975
 
923
976
  if (aggregator && typeof aggregator.isExposed === 'function' && aggregator.isExposed(name)) {
924
- // enabled:false must actually switch the tool OFF (tool-state contract: "AND runnable" -
925
- // 0.7.0): the same clean unknown-tool error as every other not-on-the-surface call,
977
+ // enabled:false must actually switch the tool OFF (tool-state contract: "AND runnable"):
978
+ // the same clean unknown-tool error as every other not-on-the-surface call,
926
979
  // so a disabled tool is indistinguishable from an absent one.
927
980
  if (!isToolEnabled(state, name)) {
928
- return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
981
+ return { content: [{ type: 'text', text: unknownToolText(name, funnelListing) }], isError: true };
929
982
  }
930
- // The caller's non-protocol _meta rides the forward here too - the progressToken is the
931
- // upstream-side keep-alive (beats are NOT fanned out to funnel clients; wrap mode relays them).
983
+ // The caller's non-protocol _meta rides the forward here too; with the transport's progress sink
984
+ // the token is scoped to this call and its beats come back to THIS caller.
932
985
  return await runCuratedDirect(aggregator, deps, name, args, stripProtocolMeta(p._meta));
933
986
  }
934
987
 
@@ -936,12 +989,13 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
936
989
  // VERBATIM envelope, exactly like curated-direct - routing this through protocol.runTool's
937
990
  // lean adapter flattened multi-block content to one JSON-stringified text block and dropped
938
991
  // structuredContent: identical advertised def, broken results.
939
- return await runHotUpstreamDirect(aggregator, { engine: d0.engine, ctx: d0.ctx }, name, args,
992
+ return await runHotUpstreamDirect(aggregator, { engine: d0.engine, ctx: d0.ctx, progress: d0.progress || null }, name, args,
940
993
  stripProtocolMeta(p._meta));
941
994
  }
942
995
 
943
- // Not a meta-tool, not promoted, not exposed -> let the protocol return its clean unknown-tool error.
944
- return wrapProtocolResult(await protocol.dispatch(name, args || {}));
996
+ // Not a meta-tool, not promoted, not exposed -> the clean unknown-tool error with its next step,
997
+ // naming only the meta-tools this client can see.
998
+ return { content: [{ type: 'text', text: unknownToolText(name, funnelListing) }], isError: true };
945
999
  }
946
1000
 
947
1001
  /**
@@ -1028,7 +1082,7 @@ async function runCuratedDirect(aggregator, deps, name, args, meta) {
1028
1082
  // isExposed() check and here only if the expose set mutated mid-call; guard for null.
1029
1083
  let resolution = null;
1030
1084
  try {
1031
- resolution = aggregator.resolveExposedExecution(name, args, meta);
1085
+ resolution = aggregator.resolveExposedExecution(name, args, meta, d.progress || undefined);
1032
1086
  } catch (err) {
1033
1087
  return {
1034
1088
  content: [{ type: 'text', text: `curated tool "${name}" failed to resolve: ${(err && err.message) || String(err)}` }],
@@ -1075,7 +1129,7 @@ async function runCuratedDirect(aggregator, deps, name, args, meta) {
1075
1129
  // Success: result.output IS the upstream's own tools/call envelope ({ content, isError }).
1076
1130
  // Pass it THROUGH transparently so the client receives the upstream's real content blocks
1077
1131
  // (text / image / etc.) AND its isError flag - rather than the whole envelope JSON-stringified
1078
- // into one text block (which also used to swallow the upstream's isError). runCuratedDirect only
1132
+ // into one text block (which would also swallow the upstream's isError). runCuratedDirect only
1079
1133
  // ever runs FORWARDED upstream tools, so result.output is always an MCP envelope here.
1080
1134
  const out = result.output;
1081
1135
  if (out && typeof out === 'object' && Array.isArray(out.content)) {
@@ -1110,7 +1164,7 @@ async function runHotUpstreamDirect(aggregator, deps, name, args, meta) {
1110
1164
  let resolution = null;
1111
1165
  try {
1112
1166
  if (aggregator && typeof aggregator.resolveRawExecution === 'function') {
1113
- resolution = aggregator.resolveRawExecution(name, args, meta);
1167
+ resolution = aggregator.resolveRawExecution(name, args, meta, undefined, d.progress || undefined);
1114
1168
  }
1115
1169
  } catch (err) {
1116
1170
  return {
@@ -1120,7 +1174,7 @@ async function runHotUpstreamDirect(aggregator, deps, name, args, meta) {
1120
1174
  }
1121
1175
  if (!resolution || typeof resolution.execute !== 'function') {
1122
1176
  // Raced away (tools changed / upstream dropped) - clean unknown-tool error, never a silent run.
1123
- return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
1177
+ return { content: [{ type: 'text', text: unknownToolText(name, false) }], isError: true };
1124
1178
  }
1125
1179
  let result;
1126
1180
  try {
@@ -1201,7 +1255,7 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1201
1255
  };
1202
1256
  let resolution = null;
1203
1257
  try {
1204
- resolution = aggregator.resolveRawExecution(surfaced, args, o.meta, track);
1258
+ resolution = aggregator.resolveRawExecution(surfaced, args, o.meta, track, o.progress || undefined);
1205
1259
  } catch (_err) {
1206
1260
  return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1207
1261
  }
@@ -1289,7 +1343,7 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1289
1343
  }
1290
1344
 
1291
1345
  /**
1292
- * The wrap error-relay sentinel (0.7.0). A raw JSON-RPC error from the WRAPPED upstream
1346
+ * The wrap error-relay sentinel. A raw JSON-RPC error from the WRAPPED upstream
1293
1347
  * must reach the caller as a JSON-RPC ERROR (README: identity, tools, results, ERRORS,
1294
1348
  * notifications - byte-for-byte), never a flattened `tool "X" failed` result. The tools/call
1295
1349
  * handlers return result ENVELOPES, so the verbatim error rides this sentinel to the dispatch
@@ -1364,10 +1418,10 @@ function wrapProtocolResult(result) {
1364
1418
  payload = result && result.output; // undefined -> stringifyContent guards it to a string
1365
1419
  }
1366
1420
 
1367
- return {
1368
- content: [{ type: 'text', text: stringifyContent(payload) }],
1369
- isError,
1370
- };
1421
+ const out = { content: [{ type: 'text', text: stringifyContent(payload) }], isError };
1422
+ // A held tool change refused on the lean route carries the same structured refusal as the others.
1423
+ if (isError && result && result.error && result.error.withheld) out.structuredContent = result.error.withheld;
1424
+ return out;
1371
1425
  }
1372
1426
 
1373
1427
  /** Return the first argument that is a non-empty string (used to pick the best error message). */
@@ -1464,7 +1518,7 @@ function normaliseBuild(arg) {
1464
1518
  // THROUGH the PreToolUse gate.
1465
1519
  // ─────────────────────────────────────────────────────────────────────────────────────────────
1466
1520
  /**
1467
- * handleBatch - process a JSON-RPC batch (0.7.0). Batching exists ONLY in the
1521
+ * handleBatch - process a JSON-RPC batch. Batching exists ONLY in the
1468
1522
  * 2025-03-26 revision (added there, removed again in 2025-06-18), so the CALLER owns the gate:
1469
1523
  * stdio admits a batch iff the session negotiated 2025-03-26 (build.negotiatedLegacyVersion);
1470
1524
  * HTTP admits on header discipline (absent-or-2025-03-26 MCP-Protocol-Version - the header
@@ -1479,9 +1533,9 @@ async function handleBatch(build, arr, connKey) {
1479
1533
  if (!Array.isArray(arr) || arr.length === 0) {
1480
1534
  return makeError(null, ERR.INVALID_REQUEST, 'Invalid Request: empty batch');
1481
1535
  }
1482
- // Enforced HERE so BOTH transports are covered by one rule (0.7.0):
1536
+ // Enforced HERE so BOTH transports are covered by one rule:
1483
1537
  // - a MODERN-shaped member is illegal in a batch at all (batching is 2025-03-26 only, which
1484
- // predates the modern era), and admitting one skipped the modern header contract entirely;
1538
+ // predates the modern era), and admitting one would skip the modern header contract entirely;
1485
1539
  // - members are executed SEQUENTIALLY with a gated tool call possible in every slot, so an
1486
1540
  // uncapped array is unbounded work bought with one message.
1487
1541
  if (arr.length > MAX_BATCH_MEMBERS) {
@@ -1500,7 +1554,10 @@ async function handleBatch(build, arr, connKey) {
1500
1554
  return out.length ? out : null;
1501
1555
  }
1502
1556
 
1503
- async function handleMessage(build, msg, connKey) {
1557
+ async function handleMessage(build, msg, connKey, opts) {
1558
+ // opts.progress: the TRANSPORT's per-request progress sink - stdio writes the
1559
+ // notification to its one client, HTTP to this request's own response stream. Absent = no relay.
1560
+ const progress = opts && typeof opts.progress === 'function' ? opts.progress : null;
1504
1561
  // connKey: OPTIONAL stable per-connection identity from the transport. stdio (one client per
1505
1562
  // process) passes a constant; HTTP passes nothing - each POST is a fresh connection, so no
1506
1563
  // cross-POST identity exists in the modern (sessionless) era. Cancel translation under a wrap
@@ -1619,12 +1676,15 @@ async function handleMessage(build, msg, connKey) {
1619
1676
  if (method !== 'initialize' && modern.isModernRequest(msg)) {
1620
1677
  return await handleModernMessage(
1621
1678
  { protocol, aggregator, engine, ctx, registry, toolStatePath },
1622
- msg, hasId, id, method, wrapId, connKey
1679
+ msg, hasId, id, method, wrapId, connKey, progress
1623
1680
  );
1624
1681
  }
1625
1682
 
1626
1683
  switch (method) {
1627
1684
  case 'initialize': {
1685
+ // A notification (no id) MUST NOT get a reply (JSON-RPC 2.0) - the modern dispatcher's
1686
+ // rule, applied here per case.
1687
+ if (!hasId) return null;
1628
1688
  // CLIENT IDENTITY MIRRORING (wrap, STDIO only - HTTP is sessionless/multi-client, the
1629
1689
  // configured identity applies there). Capture the real downstream client's clientInfo;
1630
1690
  // if the live wrapped connection introduced itself differently, reconnect it INLINE -
@@ -1677,6 +1737,7 @@ async function handleMessage(build, msg, connKey) {
1677
1737
  return null;
1678
1738
 
1679
1739
  case 'tools/list':
1740
+ if (!hasId) return null; // notification: no reply (see initialize)
1680
1741
  // 6.1, era-scoped: an unrecognised cursor answers -32602 on every cursor-AWARE revision
1681
1742
  // (2025-03-26+; our list is single-page so ANY present cursor is unknown). ONLY the
1682
1743
  // exempt 2024-11-05 keeps the frozen ignore. Scoped to STDIO sessions - the negotiated
@@ -1697,11 +1758,12 @@ async function handleMessage(build, msg, connKey) {
1697
1758
  const result = await handleToolsCall(protocol, aggregator,
1698
1759
  // clientCapabilities EXPLICITLY null (5.1): a legacy caller declares nothing
1699
1760
  // per-request and can never receive a backwards request.
1700
- { engine, ctx, registry, toolStatePath, cancelKey, modernCaller: false, clientCapabilities: null }, msg.params);
1761
+ { engine, ctx, registry, toolStatePath, cancelKey, modernCaller: false, clientCapabilities: null, progress }, msg.params);
1701
1762
  // Observability (in-memory counters; never throws). Count every tools/call by name + whether
1702
1763
  // it errored (an isError envelope = a tool failure OR a PreToolUse denial). Single chokepoint
1703
1764
  // -> covers stdio AND the HTTP transport, which both route through handleMessage.
1704
1765
  metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && (result.isError === true || result.__tfRpcError)) });
1766
+ if (!hasId) return null; // notification-shaped call: executed (side effects), but never replied to
1705
1767
  // Relayed upstream error: a LEGACY caller gets the wrapped upstream's own
1706
1768
  // JSON-RPC error VERBATIM - code, message, data.
1707
1769
  if (result && result.__tfRpcError) return relayedRpcError(id, result, false);
@@ -1709,6 +1771,7 @@ async function handleMessage(build, msg, connKey) {
1709
1771
  }
1710
1772
 
1711
1773
  case 'ping':
1774
+ if (!hasId) return null; // notification: no reply (see initialize)
1712
1775
  // MCP keep-alive: empty result.
1713
1776
  return makeResult(id, {});
1714
1777
 
@@ -1851,7 +1914,7 @@ function handleUpstreamServerRequest(build, upstreamId, msg, client) {
1851
1914
  if (!msg || msg.id === undefined || msg.id === null) return;
1852
1915
  if (msg.method === 'ping') {
1853
1916
  // A server-initiated ping is a keepalive, not a capability - answer it (an error reply
1854
- // could read as a FAILED ping and kill a session that used to survive).
1917
+ // could read as a FAILED ping and kill an otherwise healthy session).
1855
1918
  client.respondToServer(msg.id, {});
1856
1919
  return;
1857
1920
  }
@@ -2056,6 +2119,86 @@ function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId)
2056
2119
  };
2057
2120
  }
2058
2121
 
2122
+ // ── Asking to accept a held tool change (MRTR) ─────────────────────────────────────────────────
2123
+ // The tool-change refusal, for a caller that can answer: one form question naming the tool and the
2124
+ // changed fields (never the new text), and a single-use requestState that remembers WHICH change
2125
+ // (by hash) the user was asked about, so a server that changes the tool again is asked about anew.
2126
+
2127
+ const approvalAsks = new Map(); // requestState -> { name, upstream, tool, hash, timer }
2128
+
2129
+ /** The input_required asking to accept a held tool change, or null when `result` is not that
2130
+ * refusal or this caller cannot answer a form question. */
2131
+ function askToAccept(result, deps, name) {
2132
+ const w = result && result.isError === true && result.structuredContent;
2133
+ if (!w || w.toolfunnel !== 'tool-change-withheld' || typeof w.hash !== 'string') return null;
2134
+ if (deps.modernCaller !== true || !callerAcceptsElicit(deps.clientCapabilities, { mode: 'form' })) return null;
2135
+ const token = require('node:crypto').randomBytes(16).toString('base64url');
2136
+ const timer = setTimeout(() => approvalAsks.delete(token), ELICIT_TTL_MS);
2137
+ if (typeof timer.unref === 'function') timer.unref();
2138
+ approvalAsks.set(token, { name, upstream: w.upstream, tool: w.tool, hash: w.hash, timer });
2139
+ const what = w.reason === 'new' ? 'is new since its server was approved' : `changed since it was approved (changed: ${w.changed.join(', ')})`;
2140
+ return {
2141
+ resultType: 'input_required',
2142
+ inputRequests: {
2143
+ r0: {
2144
+ method: 'elicitation/create',
2145
+ params: {
2146
+ mode: 'form',
2147
+ message: `ToolFunnel is holding back the tool ${JSON.stringify(w.tool)} on upstream ${JSON.stringify(w.upstream)}: it ${what}. ` +
2148
+ 'Review the new text with `toolfunnel changes` or in the ToolFunnel UI. Accept the change and run this call?',
2149
+ requestedSchema: {
2150
+ type: 'object',
2151
+ properties: { accept: { type: 'boolean', title: 'Accept the change', default: false } },
2152
+ required: ['accept'],
2153
+ },
2154
+ },
2155
+ },
2156
+ },
2157
+ requestState: token,
2158
+ };
2159
+ }
2160
+
2161
+ /** Take (single use) the ask a requestState names, or null. */
2162
+ function takeApprovalAsk(token) {
2163
+ const ask = approvalAsks.get(token);
2164
+ if (!ask) return null;
2165
+ approvalAsks.delete(token);
2166
+ clearTimeout(ask.timer);
2167
+ return ask;
2168
+ }
2169
+
2170
+ /**
2171
+ * Apply the user's answer and return null to run the call, or the result that stops it. A yes is
2172
+ * an accept like any other agent-side one: it passes the PreToolUse gate as tf_accept_change, so
2173
+ * a hook that denies agent accepts denies this one too. Only the asked change is accepted - one
2174
+ * that moved on is simply refused, and asked about, again; one accepted meanwhile just runs.
2175
+ * NEVER throws.
2176
+ */
2177
+ async function answerApprovalAsk(aggregator, deps, ask, inputResponses) {
2178
+ const stop = (text) => ({ content: [{ type: 'text', text }], isError: true });
2179
+ const what = `the tool ${JSON.stringify(ask.tool)} on upstream ${JSON.stringify(ask.upstream)}`;
2180
+ const held = (aggregator.toolChanges().pending || []).find((c) => c.upstream === ask.upstream && c.tool === ask.tool);
2181
+ const r = inputResponses && typeof inputResponses === 'object' && !Array.isArray(inputResponses) ? inputResponses.r0 : null;
2182
+ if (!(r && r.action === 'accept' && r.content && r.content.accept === true)) {
2183
+ return stop(held ? `The change to ${what} was not accepted; it stays withheld.`
2184
+ : `The call was not run. (The change to ${what} had already been accepted.)`);
2185
+ }
2186
+ if (!held || held.hash !== ask.hash) return null;
2187
+ const res = await gatedRun({
2188
+ engine: deps.engine,
2189
+ ctx: deps.ctx,
2190
+ toolName: 'tf_accept_change',
2191
+ args: { upstream: ask.upstream, tool: ask.tool, hash: ask.hash },
2192
+ execute: async () => aggregator.acceptChange(ask.upstream, ask.tool, ask.hash),
2193
+ });
2194
+ if (res.blocked) return stop(`ToolFunnel did not accept the change to ${what}: ${res.reason || 'a PreToolUse hook blocked it'}`);
2195
+ if (!res.ok || !res.output.accepted.length) {
2196
+ return stop(`ToolFunnel could not record the acceptance of ${what}` +
2197
+ (res.ok ? ': the approvals file does not hold this change (see the gateway log - it may not be writable).' : `: ${(res.error && res.error.message) || res.error}`));
2198
+ }
2199
+ return null;
2200
+ }
2201
+
2059
2202
  /**
2060
2203
  * armWrapChatter - sync the aggregator's wrapChatterUpstream from the on-disk wrap state.
2061
2204
  * handleMessage re-arms per message, but three paths bypass it: startup (notifications before the
@@ -2161,8 +2304,8 @@ async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey)
2161
2304
  }
2162
2305
  let raw;
2163
2306
  // Track the forward's upstream rpc id LIVE (the setter fires synchronously at issue time inside
2164
- // McpClient._request) so a concurrent notifications/cancelled can be translated (N1). Keyed by
2165
- // (connection, client id) - raw client ids collide across connections (focused review).
2307
+ // McpClient._request) so a concurrent notifications/cancelled can be translated. Keyed by
2308
+ // (connection, client id) - raw client ids collide across connections.
2166
2309
  const mapKey = connKey !== undefined ? `${connKey}:${id}` : null;
2167
2310
  const track = mapKey === null ? undefined : { set rpcId(v) { inflightForwards.set(mapKey, v); } };
2168
2311
  inflightRawForwards.set(ptId, (inflightRawForwards.get(ptId) || 0) + 1); // elicitation-bridge binding invariant
@@ -2211,7 +2354,7 @@ async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey)
2211
2354
  * modern revision removed (ping, logging/setLevel, the initialized notification) fall through
2212
2355
  * to method-not-found / ignored, per spec. NEVER throws (the caller's try/catch backstops it).
2213
2356
  */
2214
- async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKey) {
2357
+ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKey, progress) {
2215
2358
  const { protocol, aggregator, engine, ctx, registry, toolStatePath } = build;
2216
2359
 
2217
2360
  const vErr = modern.validateModernRequest(msg);
@@ -2251,7 +2394,7 @@ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKe
2251
2394
  // receive an elicitation is decided by what THIS request declared.
2252
2395
  { engine, ctx, registry, toolStatePath, modernCaller: true,
2253
2396
  clientCapabilities: (modern.getMeta(msg) || {})[modern.META_KEYS.CLIENT_CAPABILITIES] || null,
2254
- cancelKey: (connKey !== undefined && hasId) ? `${connKey}:${id}` : null },
2397
+ cancelKey: (connKey !== undefined && hasId) ? `${connKey}:${id}` : null, progress: progress || null },
2255
2398
  msg.params
2256
2399
  );
2257
2400
  // Same observability chokepoint as the legacy path - both eras count in one place.
@@ -2334,7 +2477,7 @@ async function reloadExpose(build, send) {
2334
2477
  if (!build || typeof build !== 'object') return;
2335
2478
  let store;
2336
2479
  try {
2337
- store = loadExposeStore(EXPOSE_PATH);
2480
+ store = loadExposeStore(EXPOSE_PATH, { warnUnknown: warnConfigField });
2338
2481
  } catch (err) {
2339
2482
  logErr('reloadExpose: cannot read expose.json (keeping current upstreams):', (err && err.message) || String(err));
2340
2483
  return;
@@ -2554,10 +2697,28 @@ function startConfigWatchers(buildArg, send) {
2554
2697
  if (stateTimer && typeof stateTimer.unref === 'function') stateTimer.unref();
2555
2698
  }
2556
2699
 
2700
+ // Tool descriptor approvals: an accept written by the UI, the CLI or a management script
2701
+ // re-applies to the cached listings - no reconnect (reloadApprovals signals list_changed itself
2702
+ // when a served surface changed). The gateway's own writes land here too and change nothing.
2703
+ let approvalsTimer = null;
2704
+ function scheduleApprovals() {
2705
+ if (approvalsTimer) clearTimeout(approvalsTimer);
2706
+ approvalsTimer = setTimeout(() => {
2707
+ approvalsTimer = null;
2708
+ try {
2709
+ const b = getBuild();
2710
+ const agg = b && b.aggregator;
2711
+ if (agg && typeof agg.reloadApprovals === 'function') agg.reloadApprovals();
2712
+ } catch (_e) { /* reloadApprovals never throws; guard anyway */ }
2713
+ }, DEBOUNCE_MS);
2714
+ if (approvalsTimer && typeof approvalsTimer.unref === 'function') approvalsTimer.unref();
2715
+ }
2716
+
2557
2717
  try {
2558
2718
  const w = fs.watch(mcpDir, (_event, filename) => {
2559
2719
  // null filename (some platforms don't report it) -> reload to be safe; else only expose.json.
2560
2720
  if (!filename || path.basename(String(filename)) === 'expose.json') scheduleExpose();
2721
+ if (!filename || path.basename(String(filename)) === 'tool-approvals.json') scheduleApprovals();
2561
2722
  });
2562
2723
  // unref so an FSWatcher never keeps the process alive after the client closes stdin - the
2563
2724
  // gateway must still exit naturally when its transport closes (it did before this feature).
@@ -2621,6 +2782,7 @@ function startConfigWatchers(buildArg, send) {
2621
2782
  }
2622
2783
  };
2623
2784
  pollWatch(EXPOSE_PATH, scheduleExpose);
2785
+ pollWatch(path.join(mcpDir, 'tool-approvals.json'), scheduleApprovals);
2624
2786
  pollWatch(MANIFEST_PATH, scheduleHooks);
2625
2787
  pollWatch(path.join(hooksDir, 'hooks.state.json'), scheduleHooks);
2626
2788
  pollWatch(REGISTER_PATH, scheduleRegister);
@@ -2634,7 +2796,7 @@ function startConfigWatchers(buildArg, send) {
2634
2796
  // Clear any PENDING debounced reload so a change caught just before teardown can't fire a
2635
2797
  // reload against a torn-down build after the caller has moved on. (It would be a caught no-op,
2636
2798
  // but a truly-stopped watcher must not schedule work.)
2637
- for (const t of [exposeTimer, hooksTimer, registerTimer, stateTimer]) {
2799
+ for (const t of [exposeTimer, hooksTimer, registerTimer, stateTimer, approvalsTimer]) {
2638
2800
  if (t) { try { clearTimeout(t); } catch (_e) { /* ignore */ } }
2639
2801
  }
2640
2802
  for (const w of openWatchers) {
@@ -2831,7 +2993,10 @@ function createStdioLoop(build) {
2831
2993
  }
2832
2994
  // 'stdio' - the pipe has exactly ONE client for the process lifetime, so a constant
2833
2995
  // connection key is a true identity (enables safe cancel translation under a wrap).
2834
- const response = await handleMessage(build, msg, 'stdio');
2996
+ // The per-request progress sink: upstream beats for THIS request go to the one
2997
+ // client with its own token - and stop once it is cancelled (it gets nothing more for that id).
2998
+ const progress = (params) => { if (!(reg && reg.cancelled)) send({ jsonrpc: '2.0', method: 'notifications/progress', params }); };
2999
+ const response = await handleMessage(build, msg, 'stdio', { progress });
2835
3000
  // Cancelled while in flight -> the client gets NO response for that id (spec); whatever
2836
3001
  // settled - the upstream's late result or our timeout envelope - is dropped here
2837
3002
  if (reg && reg.cancelled) return;
@@ -2978,6 +3143,37 @@ function createStdioLoop(build) {
2978
3143
  return { send, notify };
2979
3144
  }
2980
3145
 
3146
+ /**
3147
+ * Stdio-mode interrupt handling. Without it, SIGINT / SIGTERM ends a stdio gateway without
3148
+ * closing its upstream clients: once any hook or tool has spawned a child, child-reaper's own
3149
+ * handler exits 130 on the spot (it exits only when it is the sole listener), so McpClient.close()
3150
+ * never runs, and on POSIX an upstream that ignores stdin EOF outlives the gateway. With it, the
3151
+ * first signal closes every upstream (tree-kill), then exits 130 (SIGINT) or 143 (SIGTERM); a
3152
+ * second signal exits at once; a close that hangs is cut off after `graceMs`. With this listener
3153
+ * present the reaper still sweeps its children but leaves the exit to us.
3154
+ * stdio-shutdown.test.js pins it.
3155
+ * @param {() => object|null} getBuild the live build (its aggregator is closed)
3156
+ * @param {{ graceMs?: number }} [opts]
3157
+ */
3158
+ function installStdioShutdown(getBuild, opts) {
3159
+ const graceMs = (opts && Number.isFinite(opts.graceMs) && opts.graceMs > 0) ? opts.graceMs : 5000;
3160
+ let stopping = false;
3161
+ const onSignal = (sig) => {
3162
+ const code = sig === 'SIGTERM' ? 143 : 130;
3163
+ if (stopping) { process.exit(code); return; } // a second signal: the user means it
3164
+ stopping = true;
3165
+ let agg = null;
3166
+ try { const b = getBuild(); agg = b && b.aggregator; } catch (_e) { agg = null; }
3167
+ const cutoff = setTimeout(() => process.exit(code), graceMs); // never hang on a stuck close
3168
+ Promise.resolve()
3169
+ .then(() => (agg && typeof agg.closeAll === 'function' ? agg.closeAll() : null))
3170
+ .catch((err) => { logErr('shutdown: closing upstreams failed:', (err && err.message) || String(err)); })
3171
+ .then(() => { clearTimeout(cutoff); process.exit(code); });
3172
+ };
3173
+ process.on('SIGINT', () => onSignal('SIGINT'));
3174
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
3175
+ }
3176
+
2981
3177
  // ─────────────────────────────────────────────────────────────────────────────────────────────
2982
3178
  // Entry point.
2983
3179
  // ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -3001,6 +3197,11 @@ async function main() {
3001
3197
  build = { protocol, aggregator: null, engine: null, ctx: null };
3002
3198
  }
3003
3199
 
3200
+ // Interrupts close every upstream before exiting (installed before the first connect, so a
3201
+ // Ctrl-C during startup closes what has already started). Reads `build` live: a hot reload
3202
+ // swaps its aggregator.
3203
+ installStdioShutdown(() => build);
3204
+
3004
3205
  // Connect every ENABLED upstream BEFORE the loop starts so tools/list advertises the curated
3005
3206
  // surface from the first request. connectAll() NEVER throws (per-upstream failures land in
3006
3207
  // failed[]) and with the default EMPTY expose.json it is an instant no-op - so this never
@@ -3034,7 +3235,7 @@ async function main() {
3034
3235
  // Bridge an upstream's server-initiated change-notifications (resources/prompts/tools list_changed,
3035
3236
  // resources/updated) to the era-aware notify path - so a modern client's subscriptions/listen
3036
3237
  // stream receives them, tagged + filtered. This is what makes wrapping a resource/prompt-bearing
3037
- // legacy MCP transparent for a modern client (2026-07-16).
3238
+ // legacy MCP transparent for a modern client.
3038
3239
  if (build.aggregator) build.aggregator.onUpstreamNotification = (uid, n) => {
3039
3240
  // Under a wrap, ONLY the wrapped upstream's notifications reach the client - another attached
3040
3241
  // upstream's events are ones the impersonated server would never emit: a transparency tell
@@ -3049,8 +3250,8 @@ async function main() {
3049
3250
  (uid, m, c) => handleUpstreamServerRequest(build, uid, m, c);
3050
3251
 
3051
3252
  // Hot-reload: pick up expose.json / hook changes written by the tf_* tools or the UI (a separate
3052
- // process) with NO restart and NO user intervention - the missing primitive that previously made
3053
- // a live MCP attach or a hook change need a bounce. Auto is the default; the meta-tools/UI button
3253
+ // process) with NO restart and NO user intervention, so a live MCP attach or a hook change
3254
+ // needs no bounce. Auto is the default; the meta-tools/UI button
3054
3255
  // are just optional manual levers on top of this.
3055
3256
  startConfigWatchers(build, loop.notify);
3056
3257
 
@@ -3086,6 +3287,10 @@ module.exports = {
3086
3287
  armWrapChatter,
3087
3288
  // Elicitation bridge: the HTTP transport wires this as the aggregator's onUpstreamServerRequest handler.
3088
3289
  handleUpstreamServerRequest,
3290
+ // Stdio interrupt handling (closes upstreams, then exits 130/143) - stdio-shutdown.test.js.
3291
+ installStdioShutdown,
3292
+ // The unknown-tool error with its recovery hint - recovery-hints.test.js.
3293
+ unknownToolText,
3089
3294
  // Matrix helpers (exported for unit tests / reuse).
3090
3295
  localHotDefinitions,
3091
3296
  localToolDefinition,