toolfunnel 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
@@ -1,86 +1,86 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-log.js — gateway-run MANAGEMENT tool: enable, disable, or check the activity log.
6
- *
7
- * A first-party "management" script-tool (discovered via list_tools, executed via
8
- * run_tool through the PreToolUse gate). It toggles ToolFunnel's own JSONL audit log
9
- * (tool runs + gate allow/deny decisions) via src/core/logger. Logging is DEFAULT OFF.
10
- *
11
- * Contract with the register (`defaultRunScript`)
12
- * ----------------------------------------------------------
13
- * - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
14
- * absent). Shape: { action: 'enable'|'disable'|'status', path? }.
15
- * - Prints EXACTLY ONE JSON line to stdout and exits 0 — ALWAYS. A logical failure
16
- * (bad args) is reported as { ok:false, error }.
17
- *
18
- * Output (stdout), exactly one JSON object:
19
- * enable/disable: { ok:true, action, enabled, path }
20
- * status: { ok:true, action, enabled, path, count } // count = log entries
21
- * failure: { ok:false, error }
22
- */
23
-
24
- // Shared HOME/engine resolution (see tf-env.js): the logger module itself resolves the config
25
- // home from the env the gateway set, so requiring it from the right PACKAGE is all we need.
26
- const { srcRequire } = require('./tf-env');
27
- const logger = srcRequire('core/logger');
28
-
29
- /**
30
- * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed JSON
31
- * or a non-object payload so the caller reports { ok:false, error }.
32
- * @returns {object}
33
- */
34
- function parseArgs() {
35
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
36
- if (raw === undefined || raw === null || raw === '') return {};
37
- const parsed = JSON.parse(raw);
38
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
39
- throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
40
- }
41
- return parsed;
42
- }
43
-
44
- /**
45
- * Perform the action. May throw — main() turns any throw into { ok:false, error }.
46
- * @returns {object} the success payload
47
- */
48
- function run() {
49
- const args = parseArgs();
50
- const action = args.action;
51
-
52
- switch (action) {
53
- case 'enable': {
54
- const patch = { enabled: true };
55
- if (typeof args.path === 'string' && args.path.length > 0) patch.path = args.path;
56
- const cfg = logger.setConfig(patch);
57
- return { ok: true, action, enabled: cfg.enabled, path: cfg.path };
58
- }
59
- case 'disable': {
60
- const cfg = logger.setConfig({ enabled: false });
61
- return { ok: true, action, enabled: cfg.enabled, path: cfg.path };
62
- }
63
- case 'status': {
64
- const cfg = logger.getConfig();
65
- const entries = logger.tail();
66
- return { ok: true, action, enabled: cfg.enabled, path: cfg.path, count: entries.length };
67
- }
68
- default:
69
- throw new Error(
70
- `args.action must be one of enable|disable|status (got ${JSON.stringify(action)})`
71
- );
72
- }
73
- }
74
-
75
- function main() {
76
- let payload;
77
- try {
78
- payload = run();
79
- } catch (err) {
80
- payload = { ok: false, error: (err && err.message) || String(err) };
81
- }
82
- process.stdout.write(JSON.stringify(payload) + '\n');
83
- process.exit(0);
84
- }
85
-
86
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-log.js - gateway-run MANAGEMENT tool: enable, disable, or check the activity log.
6
+ *
7
+ * A first-party "management" script-tool (discovered via list_tools, executed via
8
+ * run_tool through the PreToolUse gate). It toggles ToolFunnel's own JSONL audit log
9
+ * (tool runs + gate allow/deny decisions) via src/core/logger. Logging is DEFAULT OFF.
10
+ *
11
+ * Contract with the register (`defaultRunScript`)
12
+ * ----------------------------------------------------------
13
+ * - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
14
+ * absent). Shape: { action: 'enable'|'disable'|'status', path? }.
15
+ * - Prints EXACTLY ONE JSON line to stdout and exits 0 - ALWAYS. A logical failure
16
+ * (bad args) is reported as { ok:false, error }.
17
+ *
18
+ * Output (stdout), exactly one JSON object:
19
+ * enable/disable: { ok:true, action, enabled, path }
20
+ * status: { ok:true, action, enabled, path, count } // count = log entries
21
+ * failure: { ok:false, error }
22
+ */
23
+
24
+ // Shared HOME/engine resolution (see tf-env.js): the logger module itself resolves the config
25
+ // home from the env the gateway set, so requiring it from the right PACKAGE is all we need.
26
+ const { srcRequire } = require('./tf-env');
27
+ const logger = srcRequire('core/logger');
28
+
29
+ /**
30
+ * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed JSON
31
+ * or a non-object payload so the caller reports { ok:false, error }.
32
+ * @returns {object}
33
+ */
34
+ function parseArgs() {
35
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
36
+ if (raw === undefined || raw === null || raw === '') return {};
37
+ const parsed = JSON.parse(raw);
38
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
39
+ throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
40
+ }
41
+ return parsed;
42
+ }
43
+
44
+ /**
45
+ * Perform the action. May throw - main() turns any throw into { ok:false, error }.
46
+ * @returns {object} the success payload
47
+ */
48
+ function run() {
49
+ const args = parseArgs();
50
+ const action = args.action;
51
+
52
+ switch (action) {
53
+ case 'enable': {
54
+ const patch = { enabled: true };
55
+ if (typeof args.path === 'string' && args.path.length > 0) patch.path = args.path;
56
+ const cfg = logger.setConfig(patch);
57
+ return { ok: true, action, enabled: cfg.enabled, path: cfg.path };
58
+ }
59
+ case 'disable': {
60
+ const cfg = logger.setConfig({ enabled: false });
61
+ return { ok: true, action, enabled: cfg.enabled, path: cfg.path };
62
+ }
63
+ case 'status': {
64
+ const cfg = logger.getConfig();
65
+ const entries = logger.tail();
66
+ return { ok: true, action, enabled: cfg.enabled, path: cfg.path, count: entries.length };
67
+ }
68
+ default:
69
+ throw new Error(
70
+ `args.action must be one of enable|disable|status (got ${JSON.stringify(action)})`
71
+ );
72
+ }
73
+ }
74
+
75
+ function main() {
76
+ let payload;
77
+ try {
78
+ payload = run();
79
+ } catch (err) {
80
+ payload = { ok: false, error: (err && err.message) || String(err) };
81
+ }
82
+ process.stdout.write(JSON.stringify(payload) + '\n');
83
+ process.exit(0);
84
+ }
85
+
86
+ main();
@@ -1,117 +1,117 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-mcp-add.js — gateway-run MANAGEMENT tool: register an upstream MCP in the
6
- * ExposeStore and (optionally) curate which of its tools are exposed downstream.
7
- *
8
- * This is a first-party "management" script-tool: it is discovered via list_tools
9
- * and executed via run_tool through the PreToolUse gate. It does not add new
10
- * MCP-protocol commands — it edits ToolFunnel's own MCP config (mcp/expose.json)
11
- * by way of the ExposeStore, the on-disk source of truth the aggregator consumes.
12
- *
13
- * Contract with the register (`defaultRunScript`)
14
- * ----------------------------------------------------------
15
- * - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
16
- * absent). Shape:
17
- * {
18
- * id, command,
19
- * args?:[], env?:{}, transport?='stdio', enabled?=true, description?,
20
- * expose?:[ { tool, as?, category?, enabled? } ]
21
- * }
22
- * - Prints EXACTLY ONE JSON line to stdout and exits 0 — ALWAYS. A logical
23
- * failure (bad args, validation error from the store) is reported as
24
- * { ok:false, error }, never a non-zero exit or a crash.
25
- *
26
- * Output (stdout), exactly one JSON object:
27
- * success: { ok:true, upstream:{...}, exposed:[{...}, ...] }
28
- * failure: { ok:false, error }
29
- *
30
- * Behaviour
31
- * - addUpstream({ id, command, args, env, transport, enabled, description }) —
32
- * the store validates (unique non-empty id, transport==='stdio', non-empty
33
- * command) and persists atomically.
34
- * - For each expose[] item: addExpose({ upstream:id, tool, as, category, enabled }).
35
- * The store requires the upstream to exist (it does — we just added it), a
36
- * non-empty tool, and a unique (upstream,tool) pair.
37
- *
38
- * Note: there is no cross-step transaction. If the upstream is added but a later
39
- * expose[] item fails validation, the upstream (and any earlier exposes) remain
40
- * persisted; the failure is reported as { ok:false, error }.
41
- */
42
-
43
- const path = require('node:path');
44
- // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
45
- const { HOME, srcRequire } = require('./tf-env');
46
- const { loadExposeStore } = srcRequire('mcp/expose-store');
47
-
48
- const ROOT = HOME;
49
-
50
- /** The MCP config (upstreams + expose), per the host path contract. */
51
- const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
52
-
53
- /**
54
- * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed
55
- * JSON or a non-object payload so the caller reports { ok:false, error }.
56
- * @returns {object}
57
- */
58
- function parseArgs() {
59
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
60
- if (raw === undefined || raw === null || raw === '') return {};
61
- const parsed = JSON.parse(raw);
62
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
63
- throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
64
- }
65
- return parsed;
66
- }
67
-
68
- /**
69
- * Perform the add. May throw — main() turns any throw into { ok:false, error }.
70
- * @returns {object} the success payload
71
- */
72
- function run() {
73
- const args = parseArgs();
74
- const store = loadExposeStore(EXPOSE_PATH);
75
-
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.
78
- const upstream = store.addUpstream({
79
- id: args.id,
80
- command: args.command,
81
- args: args.args,
82
- env: args.env,
83
- transport: typeof args.transport === 'string' ? args.transport : 'stdio',
84
- enabled: typeof args.enabled === 'boolean' ? args.enabled : true,
85
- description: args.description,
86
- });
87
-
88
- const exposed = [];
89
- 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
- );
101
- }
102
-
103
- return { ok: true, upstream, exposed };
104
- }
105
-
106
- function main() {
107
- let payload;
108
- try {
109
- payload = run();
110
- } catch (err) {
111
- payload = { ok: false, error: (err && err.message) || String(err) };
112
- }
113
- process.stdout.write(JSON.stringify(payload) + '\n');
114
- process.exit(0);
115
- }
116
-
117
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-mcp-add.js - gateway-run MANAGEMENT tool: register an upstream MCP in the
6
+ * ExposeStore and (optionally) curate which of its tools are exposed downstream.
7
+ *
8
+ * This is a first-party "management" script-tool: it is discovered via list_tools
9
+ * and executed via run_tool through the PreToolUse gate. It does not add new
10
+ * MCP-protocol commands - it edits ToolFunnel's own MCP config (mcp/expose.json)
11
+ * by way of the ExposeStore, the on-disk source of truth the aggregator consumes.
12
+ *
13
+ * Contract with the register (`defaultRunScript`)
14
+ * ----------------------------------------------------------
15
+ * - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
16
+ * absent). Shape:
17
+ * {
18
+ * id, command,
19
+ * args?:[], env?:{}, transport?='stdio', enabled?=true, description?,
20
+ * expose?:[ { tool, as?, category?, enabled? } ]
21
+ * }
22
+ * - Prints EXACTLY ONE JSON line to stdout and exits 0 - ALWAYS. A logical
23
+ * failure (bad args, validation error from the store) is reported as
24
+ * { ok:false, error }, never a non-zero exit or a crash.
25
+ *
26
+ * Output (stdout), exactly one JSON object:
27
+ * success: { ok:true, upstream:{...}, exposed:[{...}, ...] }
28
+ * failure: { ok:false, error }
29
+ *
30
+ * Behaviour
31
+ * - addUpstream({ id, command, args, env, transport, enabled, description }) -
32
+ * the store validates (unique non-empty id, transport==='stdio', non-empty
33
+ * command) and persists atomically.
34
+ * - For each expose[] item: addExpose({ upstream:id, tool, as, category, enabled }).
35
+ * The store requires the upstream to exist (it does - we just added it), a
36
+ * non-empty tool, and a unique (upstream,tool) pair.
37
+ *
38
+ * Note: there is no cross-step transaction. If the upstream is added but a later
39
+ * expose[] item fails validation, the upstream (and any earlier exposes) remain
40
+ * persisted; the failure is reported as { ok:false, error }.
41
+ */
42
+
43
+ const path = require('node:path');
44
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
45
+ const { HOME, srcRequire } = require('./tf-env');
46
+ const { loadExposeStore } = srcRequire('mcp/expose-store');
47
+
48
+ const ROOT = HOME;
49
+
50
+ /** The MCP config (upstreams + expose), per the host path contract. */
51
+ const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
52
+
53
+ /**
54
+ * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed
55
+ * JSON or a non-object payload so the caller reports { ok:false, error }.
56
+ * @returns {object}
57
+ */
58
+ function parseArgs() {
59
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
60
+ if (raw === undefined || raw === null || raw === '') return {};
61
+ const parsed = JSON.parse(raw);
62
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
63
+ throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
64
+ }
65
+ return parsed;
66
+ }
67
+
68
+ /**
69
+ * Perform the add. May throw - main() turns any throw into { ok:false, error }.
70
+ * @returns {object} the success payload
71
+ */
72
+ function run() {
73
+ const args = parseArgs();
74
+ const store = loadExposeStore(EXPOSE_PATH);
75
+
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.
78
+ const upstream = store.addUpstream({
79
+ id: args.id,
80
+ command: args.command,
81
+ args: args.args,
82
+ env: args.env,
83
+ transport: typeof args.transport === 'string' ? args.transport : 'stdio',
84
+ enabled: typeof args.enabled === 'boolean' ? args.enabled : true,
85
+ description: args.description,
86
+ });
87
+
88
+ const exposed = [];
89
+ 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
+ );
101
+ }
102
+
103
+ return { ok: true, upstream, exposed };
104
+ }
105
+
106
+ function main() {
107
+ let payload;
108
+ try {
109
+ payload = run();
110
+ } catch (err) {
111
+ payload = { ok: false, error: (err && err.message) || String(err) };
112
+ }
113
+ process.stdout.write(JSON.stringify(payload) + '\n');
114
+ process.exit(0);
115
+ }
116
+
117
+ main();
@@ -1,102 +1,121 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-mcp-set.js — gateway-run MANAGEMENT tool: enable, disable, or remove an
6
- * upstream MCP in the ExposeStore.
7
- *
8
- * A first-party "management" script-tool (discovered via list_tools, executed via
9
- * run_tool through the PreToolUse gate). It edits ToolFunnel's own MCP config
10
- * (mcp/expose.json) via the ExposeStore — it does not add MCP-protocol commands.
11
- *
12
- * Contract with the register (`defaultRunScript`)
13
- * ----------------------------------------------------------
14
- * - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
15
- * absent). Shape: { id, action: 'enable'|'disable'|'remove' }.
16
- * - Prints EXACTLY ONE JSON line to stdout and exits 0 — ALWAYS. A logical
17
- * failure (bad args, unknown id) is reported as { ok:false, error }.
18
- *
19
- * Output (stdout), exactly one JSON object:
20
- * enable/disable: { ok:true, action, id, upstream:{...} } // the updated entry
21
- * remove: { ok:true, action, id, removed:true } // cascades expose[]
22
- * failure: { ok:false, error }
23
- *
24
- * Behaviour
25
- * - enable -> setUpstreamEnabled(id, true)
26
- * - disable -> setUpstreamEnabled(id, false)
27
- * - remove -> removeUpstream(id) (the store cascade-removes its expose entries)
28
- * All three throw on an unknown id; the throw is reported as { ok:false, error }.
29
- */
30
-
31
- const path = require('node:path');
32
- // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
33
- const { HOME, srcRequire } = require('./tf-env');
34
- const { loadExposeStore } = srcRequire('mcp/expose-store');
35
-
36
- const ROOT = HOME;
37
-
38
- /** The MCP config (upstreams + expose), per the host path contract. */
39
- const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
40
-
41
- /**
42
- * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed
43
- * JSON or a non-object payload so the caller reports { ok:false, error }.
44
- * @returns {object}
45
- */
46
- function parseArgs() {
47
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
48
- if (raw === undefined || raw === null || raw === '') return {};
49
- const parsed = JSON.parse(raw);
50
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
51
- throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
52
- }
53
- return parsed;
54
- }
55
-
56
- /**
57
- * Perform the action. May throw — main() turns any throw into { ok:false, error }.
58
- * @returns {object} the success payload
59
- */
60
- function run() {
61
- const args = parseArgs();
62
- const id = args.id;
63
- const action = args.action;
64
-
65
- if (typeof id !== 'string' || id.length === 0) {
66
- throw new Error('args.id must be a non-empty string');
67
- }
68
-
69
- const store = loadExposeStore(EXPOSE_PATH);
70
-
71
- switch (action) {
72
- case 'enable': {
73
- const upstream = store.setUpstreamEnabled(id, true);
74
- return { ok: true, action, id, upstream };
75
- }
76
- case 'disable': {
77
- const upstream = store.setUpstreamEnabled(id, false);
78
- return { ok: true, action, id, upstream };
79
- }
80
- case 'remove': {
81
- store.removeUpstream(id);
82
- return { ok: true, action, id, removed: true };
83
- }
84
- default:
85
- throw new Error(
86
- `args.action must be one of enable|disable|remove (got ${JSON.stringify(action)})`
87
- );
88
- }
89
- }
90
-
91
- function main() {
92
- let payload;
93
- try {
94
- payload = run();
95
- } catch (err) {
96
- payload = { ok: false, error: (err && err.message) || String(err) };
97
- }
98
- process.stdout.write(JSON.stringify(payload) + '\n');
99
- process.exit(0);
100
- }
101
-
102
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-mcp-set.js - gateway-run MANAGEMENT tool: enable, disable, or remove an
6
+ * upstream MCP in the ExposeStore.
7
+ *
8
+ * A first-party "management" script-tool (discovered via list_tools, executed via
9
+ * run_tool through the PreToolUse gate). It edits ToolFunnel's own MCP config
10
+ * (mcp/expose.json) via the ExposeStore - it does not add MCP-protocol commands.
11
+ *
12
+ * Contract with the register (`defaultRunScript`)
13
+ * ----------------------------------------------------------
14
+ * - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
15
+ * absent). Shape: { id, action: 'enable'|'disable'|'remove' }.
16
+ * - Prints EXACTLY ONE JSON line to stdout and exits 0 - ALWAYS. A logical
17
+ * failure (bad args, unknown id) is reported as { ok:false, error }.
18
+ *
19
+ * Output (stdout), exactly one JSON object:
20
+ * enable/disable: { ok:true, action, id, upstream:{...} } // the updated entry
21
+ * remove: { ok:true, action, id, removed:true } // cascades expose[]
22
+ * failure: { ok:false, error }
23
+ *
24
+ * Behaviour
25
+ * - enable -> setUpstreamEnabled(id, true)
26
+ * - disable -> setUpstreamEnabled(id, false)
27
+ * - remove -> removeUpstream(id) (the store cascade-removes its expose entries)
28
+ * All three throw on an unknown id; the throw is reported as { ok:false, error }.
29
+ */
30
+
31
+ const path = require('node:path');
32
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
33
+ const { HOME, srcRequire } = require('./tf-env');
34
+ const { loadExposeStore } = srcRequire('mcp/expose-store');
35
+ const { loadToolState, getPassthrough, setPassthrough } = srcRequire('tools/tool-state');
36
+
37
+ const ROOT = HOME;
38
+
39
+ /** The MCP config (upstreams + expose), per the host path contract. */
40
+ const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
41
+ const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
42
+
43
+ /**
44
+ * If `id` is the currently-wrapped upstream, clear the wrap - a disable/remove of the wrapped
45
+ * upstream would otherwise BRICK the whole surface (empty tool list, tf_wrap itself gone) with
46
+ * only out-of-band recovery. Returns true if a wrap was cleared.
47
+ */
48
+ function clearWrapIfTarget(id) {
49
+ try {
50
+ if (getPassthrough(loadToolState(STATE_PATH)) === id) {
51
+ setPassthrough(STATE_PATH, null);
52
+ return true;
53
+ }
54
+ } catch (_e) { /* never let wrap-cleanup sink the primary action */ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed
60
+ * JSON or a non-object payload so the caller reports { ok:false, error }.
61
+ * @returns {object}
62
+ */
63
+ function parseArgs() {
64
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
65
+ if (raw === undefined || raw === null || raw === '') return {};
66
+ const parsed = JSON.parse(raw);
67
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
68
+ throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
69
+ }
70
+ return parsed;
71
+ }
72
+
73
+ /**
74
+ * Perform the action. May throw - main() turns any throw into { ok:false, error }.
75
+ * @returns {object} the success payload
76
+ */
77
+ function run() {
78
+ const args = parseArgs();
79
+ const id = args.id;
80
+ const action = args.action;
81
+
82
+ if (typeof id !== 'string' || id.length === 0) {
83
+ throw new Error('args.id must be a non-empty string');
84
+ }
85
+
86
+ const store = loadExposeStore(EXPOSE_PATH);
87
+
88
+ switch (action) {
89
+ case 'enable': {
90
+ const upstream = store.setUpstreamEnabled(id, true);
91
+ return { ok: true, action, id, upstream };
92
+ }
93
+ case 'disable': {
94
+ const upstream = store.setUpstreamEnabled(id, false);
95
+ const unwrapped = clearWrapIfTarget(id);
96
+ return { ok: true, action, id, upstream, ...(unwrapped ? { wrapCleared: true } : {}) };
97
+ }
98
+ case 'remove': {
99
+ store.removeUpstream(id);
100
+ const unwrapped = clearWrapIfTarget(id);
101
+ return { ok: true, action, id, removed: true, ...(unwrapped ? { wrapCleared: true } : {}) };
102
+ }
103
+ default:
104
+ throw new Error(
105
+ `args.action must be one of enable|disable|remove (got ${JSON.stringify(action)})`
106
+ );
107
+ }
108
+ }
109
+
110
+ function main() {
111
+ let payload;
112
+ try {
113
+ payload = run();
114
+ } catch (err) {
115
+ payload = { ok: false, error: (err && err.message) || String(err) };
116
+ }
117
+ process.stdout.write(JSON.stringify(payload) + '\n');
118
+ process.exit(0);
119
+ }
120
+
121
+ main();