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/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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 -
|
|
190
|
-
* spawnSync
|
|
191
|
-
* HTTP client (and pings) behind one long shell tool
|
|
192
|
-
* only caller is the gated async execute thunk, so the promise is awaited naturally
|
|
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
|
|
217
|
-
// shell tool with no clock
|
|
218
|
-
// unbounded accumulation
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
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)
|
|
725
|
-
// {name, description, inputSchema}
|
|
726
|
-
//
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
931
|
-
//
|
|
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 ->
|
|
944
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2165
|
-
// (connection, client id) - raw client ids collide across connections
|
|
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
|
-
|
|
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
|
|
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
|
|
3053
|
-
//
|
|
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,
|