toolfunnel 0.3.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-hook-add.js — management tool: add a hook entry to the hook manifest.
6
+ *
7
+ * A first-party "gateway-run" management function: it configures ToolFunnel's
8
+ * OWN hook manifest (and, optionally, drops an inline hook script into the
9
+ * scripts copy dir). Discovered via list_tools and executed via run_tool
10
+ * through the PreToolUse gate — it is NOT a new MCP-protocol command.
11
+ *
12
+ * Contract with the register (`defaultRunScript`)
13
+ * ----------------------------------------------------------
14
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} if absent).
15
+ * - Args: { id, event, matcher?, command, script?, timeout?, enabled?=true,
16
+ * description?, scriptText? }.
17
+ * - Prints EXACTLY ONE JSON object to stdout and exits 0 — ALWAYS exit 0, even
18
+ * on a logical error, which is reported as { ok:false, error }.
19
+ *
20
+ * Output (stdout), exactly one JSON object:
21
+ * success: { ok:true, id, hook, scriptWritten }
22
+ * failure: { ok:false, error }
23
+ *
24
+ * Ordering note (verified against src/core/hook-loader.js)
25
+ * -------------------------------------------------------
26
+ * HookLoader.writeScript(id, text) resolves the script path from the spec and
27
+ * THROWS for an unknown id (getSpec must find the row). Since addEntry rejects
28
+ * duplicate ids, this tool only ever creates NEW hooks — so the manifest row
29
+ * must be registered FIRST, then the inline script can be written. If the
30
+ * inline write fails, the just-added entry is rolled back so the add stays
31
+ * atomic (the intent behind a write-then-register ordering).
32
+ *
33
+ * Safety invariants:
34
+ * - Touches only ToolFunnel's own manifest + scripts dir (resolved from
35
+ * __dirname, never a caller-supplied absolute path).
36
+ * - NO network. NEVER throws for ordinary conditions — only JSON on stdout.
37
+ */
38
+
39
+ const path = require('node:path');
40
+ const { loadManifest } = require('../../src/core/hook-loader');
41
+
42
+ /**
43
+ * ToolFunnel root, two dirs up from this script (tools/scripts -> tools -> root).
44
+ */
45
+ const ROOT = path.resolve(__dirname, '..', '..');
46
+
47
+ /** The hook manifest, relative to ROOT. */
48
+ const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
49
+
50
+ /**
51
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. Absent/empty
52
+ * is treated as an empty object. Returns the parsed value or a parse-error
53
+ * sentinel so the caller surfaces a clean { ok:false, error }.
54
+ *
55
+ * @returns {{ value: any } | { parseError: string }}
56
+ */
57
+ function parseStructuredArgs() {
58
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
59
+ if (raw === undefined || raw === null || raw === '') {
60
+ return { value: {} };
61
+ }
62
+ try {
63
+ return { value: JSON.parse(raw) };
64
+ } catch (_err) {
65
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Pure-ish core: validate args, register the hook, optionally write the inline
71
+ * script. Never throws for ordinary conditions (addEntry's own validation errors
72
+ * are caught in main).
73
+ *
74
+ * @param {any} args
75
+ * @returns {object} the JSON-serialisable result
76
+ */
77
+ function run(args) {
78
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
79
+ return { ok: false, error: 'args must be an object { id, event, command, ... }' };
80
+ }
81
+
82
+ const { id, event, matcher, command, script, timeout, description, scriptText } = args;
83
+
84
+ // Clear up-front checks for fields addEntry does not (id/event/command are
85
+ // re-validated by addEntry). scriptText is checked before any mutation so a
86
+ // bad value never leaves a half-added entry behind.
87
+ if (typeof id !== 'string' || id.length === 0) {
88
+ return { ok: false, error: 'id must be a non-empty string' };
89
+ }
90
+ if (scriptText !== undefined && typeof scriptText !== 'string') {
91
+ return { ok: false, error: 'scriptText must be a string when provided' };
92
+ }
93
+
94
+ // enabled defaults to TRUE (opt-out) for this tool, unlike the loader's
95
+ // addEntry default of false — so it is passed explicitly.
96
+ const enabled = args.enabled === undefined ? true : args.enabled === true;
97
+
98
+ // Build the spec from only the supplied optional fields (keeps the manifest
99
+ // tidy); addEntry stores them verbatim.
100
+ const spec = { id, event, command, enabled };
101
+ if (matcher !== undefined) spec.matcher = matcher;
102
+ if (script !== undefined) spec.script = script;
103
+ if (timeout !== undefined) spec.timeout = timeout;
104
+ if (description !== undefined) spec.description = description;
105
+
106
+ const loader = loadManifest(MANIFEST);
107
+
108
+ // Register first (see Ordering note in the header). addEntry validates
109
+ // id/event/command and rejects duplicate ids by throwing — caught in main.
110
+ const stored = loader.addEntry(spec);
111
+
112
+ let scriptWritten = false;
113
+ if (typeof scriptText === 'string') {
114
+ try {
115
+ loader.writeScript(id, scriptText);
116
+ scriptWritten = true;
117
+ } catch (err) {
118
+ // Roll the entry back so a failed inline write leaves no orphan row.
119
+ try {
120
+ loader.removeEntry(id);
121
+ } catch (_) {
122
+ /* best-effort rollback */
123
+ }
124
+ return {
125
+ ok: false,
126
+ error: `script write failed (entry rolled back): ${(err && err.message) || String(err)}`,
127
+ };
128
+ }
129
+ }
130
+
131
+ return { ok: true, id, hook: stored, scriptWritten };
132
+ }
133
+
134
+ function main() {
135
+ const parsed = parseStructuredArgs();
136
+ let payload;
137
+ if (parsed.parseError) {
138
+ payload = { ok: false, error: parsed.parseError };
139
+ } else {
140
+ try {
141
+ payload = run(parsed.value);
142
+ } catch (err) {
143
+ // addEntry throws on validation / duplicate-id failures — report them as
144
+ // a logical error, still exit 0.
145
+ payload = { ok: false, error: (err && err.message) || String(err) };
146
+ }
147
+ }
148
+ process.stdout.write(JSON.stringify(payload) + '\n');
149
+ process.exit(0);
150
+ }
151
+
152
+ main();
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-hook-set.js — management tool: enable, disable, or remove a hook entry.
6
+ *
7
+ * A first-party "gateway-run" management function: it configures ToolFunnel's
8
+ * OWN hook manifest / enabled-state overlay. Discovered via list_tools and
9
+ * executed via run_tool through the PreToolUse gate — it is NOT a new
10
+ * MCP-protocol command.
11
+ *
12
+ * Contract with the register (`defaultRunScript`)
13
+ * ----------------------------------------------------------
14
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} if absent).
15
+ * - Args: { id, action: "enable" | "disable" | "remove" }.
16
+ * - Prints EXACTLY ONE JSON object to stdout and exits 0 — ALWAYS exit 0, even
17
+ * on a logical error, which is reported as { ok:false, error }.
18
+ *
19
+ * Output (stdout), exactly one JSON object:
20
+ * enable/disable: { ok:true, id, action, enabled }
21
+ * remove: { ok:true, id, action, removed }
22
+ * failure: { ok:false, error }
23
+ *
24
+ * Mapping (verified against src/core/hook-loader.js):
25
+ * - enable → loader.setEnabled(id, true) (persists the state overlay + manifest)
26
+ * - disable → loader.setEnabled(id, false)
27
+ * - remove → loader.removeEntry(id) (returns false if the id was absent;
28
+ * that is information, not a fault, so ok stays true)
29
+ *
30
+ * Safety invariants:
31
+ * - Touches only ToolFunnel's own manifest / state (resolved from __dirname).
32
+ * - NO network. NEVER throws for ordinary conditions — only JSON on stdout.
33
+ */
34
+
35
+ const path = require('node:path');
36
+ const { loadManifest } = require('../../src/core/hook-loader');
37
+
38
+ /**
39
+ * ToolFunnel root, two dirs up from this script (tools/scripts -> tools -> root).
40
+ */
41
+ const ROOT = path.resolve(__dirname, '..', '..');
42
+
43
+ /** The hook manifest, relative to ROOT. */
44
+ const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
45
+
46
+ /**
47
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. Absent/empty
48
+ * is treated as an empty object. Returns the parsed value or a parse-error
49
+ * sentinel so the caller surfaces a clean { ok:false, error }.
50
+ *
51
+ * @returns {{ value: any } | { parseError: string }}
52
+ */
53
+ function parseStructuredArgs() {
54
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
55
+ if (raw === undefined || raw === null || raw === '') {
56
+ return { value: {} };
57
+ }
58
+ try {
59
+ return { value: JSON.parse(raw) };
60
+ } catch (_err) {
61
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Pure-ish core: validate args and apply the requested action. Never throws for
67
+ * ordinary conditions (any loader throw is caught in main).
68
+ *
69
+ * @param {any} args
70
+ * @returns {object} the JSON-serialisable result
71
+ */
72
+ function run(args) {
73
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
74
+ return { ok: false, error: 'args must be an object { id, action }' };
75
+ }
76
+
77
+ const { id, action } = args;
78
+ if (typeof id !== 'string' || id.length === 0) {
79
+ return { ok: false, error: 'id must be a non-empty string' };
80
+ }
81
+ if (action !== 'enable' && action !== 'disable' && action !== 'remove') {
82
+ return { ok: false, error: 'action must be one of "enable", "disable", "remove"' };
83
+ }
84
+
85
+ const loader = loadManifest(MANIFEST);
86
+
87
+ if (action === 'remove') {
88
+ const removed = loader.removeEntry(id);
89
+ return { ok: true, id, action, removed };
90
+ }
91
+
92
+ const desired = action === 'enable';
93
+ loader.setEnabled(id, desired);
94
+ return { ok: true, id, action, enabled: desired };
95
+ }
96
+
97
+ function main() {
98
+ const parsed = parseStructuredArgs();
99
+ let payload;
100
+ if (parsed.parseError) {
101
+ payload = { ok: false, error: parsed.parseError };
102
+ } else {
103
+ try {
104
+ payload = run(parsed.value);
105
+ } catch (err) {
106
+ payload = { ok: false, error: (err && err.message) || String(err) };
107
+ }
108
+ }
109
+ process.stdout.write(JSON.stringify(payload) + '\n');
110
+ process.exit(0);
111
+ }
112
+
113
+ main();
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-list.js — a READ-ONLY management inventory tool (category "management").
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * One first-party register function that answers "what is configured, and what is
10
+ * its live state?" across the three stores the gateway owns:
11
+ * - tools → the persisted Tool Register + the ACTIVE/DISABLED + hidden overlay.
12
+ * - mcps → the expose store: upstream MCPs and their curated-direct exposes.
13
+ * - hooks → the hook manifest + the per-hook enabled-state overlay.
14
+ *
15
+ * It is discovered via list_tools and executed via run_tool through the PreToolUse
16
+ * gate. It does NOT add new MCP-protocol commands; it is a "gateway-run" script
17
+ * that reads the gateway's own config files. READ-ONLY: no writes, no network,
18
+ * no process mutation.
19
+ *
20
+ * Contract with the register (registry.js `defaultRunScript`)
21
+ * -----------------------------------------------------------
22
+ * - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
23
+ * absent). The only arg is { kind: "tools" | "mcps" | "hooks" }.
24
+ * - Prints EXACTLY ONE JSON line to stdout and exits 0 — ALWAYS exit 0. A bad
25
+ * kind, a missing/malformed config, or any other logical error is reported as
26
+ * { ok:false, error } (still exit 0), never a crash.
27
+ *
28
+ * Output (stdout), exactly one JSON object:
29
+ * success: { ok:true, kind, items:[ ... ] }
30
+ * failure: { ok:false, error }
31
+ *
32
+ * Per-kind item shapes:
33
+ * tools → register brief { id, name, summary, category } annotated with the
34
+ * overlay-derived { enabled, hidden }.
35
+ * mcps → one item per upstream MCP (its full config incl. `enabled`) with its
36
+ * curated-direct `exposed` entries nested (each carrying its resolved
37
+ * downstream `name` and `enabled` flag). Uses listUpstreams() +
38
+ * listExposed() and groups exposes under their owning upstream.
39
+ * hooks → one item per manifest hook (full spec) with `enabled` resolved from
40
+ * the persisted overlay (readState) when present, else the manifest flag.
41
+ *
42
+ * Safety invariants (defense-in-depth; mirror the isolation rule):
43
+ * - READ-ONLY: only reads config files INSIDE the toolfunnel root, resolved from
44
+ * THIS script's own location (__dirname) — never a caller-supplied path.
45
+ * - NO network, NO writes, NO process mutation.
46
+ * - NEVER throws for ordinary conditions — only well-shaped JSON on stdout.
47
+ */
48
+
49
+ const path = require('node:path');
50
+
51
+ const { loadRegistry } = require('../../src/tools/registry');
52
+ const { loadToolState, isToolEnabled, isToolHidden } = require('../../src/tools/tool-state');
53
+ const { loadExposeStore } = require('../../src/mcp/expose-store');
54
+ const { loadManifest } = require('../../src/core/hook-loader');
55
+
56
+ /**
57
+ * The toolfunnel root, resolved from THIS file's location so the tool stays inside
58
+ * the sandbox and is portable wherever the tree is copied.
59
+ *
60
+ * This script lives at: <root>/tools/scripts/tf-list.js
61
+ * so the root is two directories up from __dirname: scripts -> tools -> <root>
62
+ */
63
+ const ROOT = path.resolve(__dirname, '..', '..');
64
+
65
+ // Canonical store paths (see the script contract).
66
+ const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
67
+ const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
68
+ const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
69
+ const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
70
+ const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
71
+
72
+ /**
73
+ * Parse the structured args from env. Never throws: a missing/blank/malformed
74
+ * TOOLFUNNEL_TOOL_ARGS yields {} so the kind check below produces a clean
75
+ * { ok:false, error } rather than a crash.
76
+ * @returns {object}
77
+ */
78
+ function readArgs() {
79
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
80
+ if (typeof raw !== 'string' || raw.length === 0) return {};
81
+ try {
82
+ const parsed = JSON.parse(raw);
83
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
84
+ } catch (_err) {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ /**
90
+ * tools → register briefs annotated with the overlay's enabled + hidden state. HIDDEN tools are a
91
+ * manager-view declutter: they are OMITTED by default and only included when args.includeHidden is
92
+ * true. (This is the manager surface only — `hidden` never affects the lean list / top-level surface
93
+ * the connected AI sees.)
94
+ * @param {object} [args] { includeHidden?: boolean }
95
+ * @returns {Array<{id,name,summary,category,enabled,hidden}>}
96
+ */
97
+ function listTools(args) {
98
+ const includeHidden = !!(args && args.includeHidden === true);
99
+ const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
100
+ const state = loadToolState(STATE_PATH);
101
+ return registry.list()
102
+ .map((brief) => ({
103
+ ...brief,
104
+ enabled: isToolEnabled(state, brief.id),
105
+ hidden: isToolHidden(state, brief.id),
106
+ }))
107
+ .filter((t) => includeHidden || !t.hidden); // declutter: omit hidden unless explicitly asked
108
+ }
109
+
110
+ /**
111
+ * mcps → one item per upstream MCP, with its curated-direct exposes nested.
112
+ * Uses listUpstreams() for the connection config (incl. `enabled`) and
113
+ * listExposed() for the surfaced tools, grouping each expose under its owning
114
+ * upstream and resolving the downstream name via exposedName().
115
+ * @returns {Array<object>}
116
+ */
117
+ function listMcps() {
118
+ const store = loadExposeStore(EXPOSE_PATH);
119
+ const upstreams = store.listUpstreams();
120
+ const exposed = store.listExposed();
121
+
122
+ // Group exposes by their upstream id (every expose references an existing
123
+ // upstream — addExpose requires it and removeUpstream cascades — but default
124
+ // to an empty list so an unmatched entry never throws).
125
+ const byUpstream = new Map();
126
+ for (const e of exposed) {
127
+ const annotated = { ...e, name: store.exposedName(e) };
128
+ const bucket = byUpstream.get(e.upstream);
129
+ if (bucket) bucket.push(annotated);
130
+ else byUpstream.set(e.upstream, [annotated]);
131
+ }
132
+
133
+ return upstreams.map((u) => ({
134
+ ...u,
135
+ exposed: byUpstream.get(u.id) || [],
136
+ }));
137
+ }
138
+
139
+ /**
140
+ * hooks → one item per manifest hook with `enabled` resolved from the persisted
141
+ * overlay (readState) when it carries the hook id, else the manifest's own flag.
142
+ * loadManifest already applies the overlay over the in-memory specs, but we read
143
+ * the overlay explicitly so the resolved `enabled` is correct regardless of that.
144
+ * @returns {Array<object>}
145
+ */
146
+ function listHooks() {
147
+ const loader = loadManifest(MANIFEST_PATH);
148
+ const overlay = loader.readState();
149
+ return loader.hooks.map((h) => {
150
+ const hasOverride = h && typeof h.id === 'string' &&
151
+ Object.prototype.hasOwnProperty.call(overlay, h.id);
152
+ const enabled = hasOverride ? overlay[h.id] === true : h.enabled === true;
153
+ return { ...h, enabled };
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Build the inventory for the requested kind. Throws on an unknown kind so main()
159
+ * can surface it as { ok:false, error }.
160
+ * @param {object} args
161
+ * @returns {{ ok:true, kind:string, items:Array<object> }}
162
+ */
163
+ function build(args) {
164
+ const kind = args && args.kind;
165
+ let items;
166
+ switch (kind) {
167
+ case 'tools':
168
+ items = listTools(args); // honours args.includeHidden (hidden tools omitted by default)
169
+ break;
170
+ case 'mcps':
171
+ items = listMcps();
172
+ break;
173
+ case 'hooks':
174
+ items = listHooks();
175
+ break;
176
+ default:
177
+ throw new Error(`tf-list: kind must be one of "tools"|"mcps"|"hooks" (got ${JSON.stringify(kind)})`);
178
+ }
179
+ return { ok: true, kind, items };
180
+ }
181
+
182
+ function main() {
183
+ let payload;
184
+ try {
185
+ payload = build(readArgs());
186
+ } catch (err) {
187
+ // Any logical error (bad kind, missing/malformed store) becomes a well-shaped
188
+ // failure line. Still exit 0 — the gated runner treats parseable stdout as ok.
189
+ payload = { ok: false, error: (err && err.message) || String(err) };
190
+ }
191
+ process.stdout.write(JSON.stringify(payload) + '\n');
192
+ process.exit(0);
193
+ }
194
+
195
+ main();
@@ -0,0 +1,83 @@
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
+ const logger = require('../../src/core/logger');
25
+
26
+ /**
27
+ * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed JSON
28
+ * or a non-object payload so the caller reports { ok:false, error }.
29
+ * @returns {object}
30
+ */
31
+ function parseArgs() {
32
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
33
+ if (raw === undefined || raw === null || raw === '') return {};
34
+ const parsed = JSON.parse(raw);
35
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
36
+ throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ /**
42
+ * Perform the action. May throw — main() turns any throw into { ok:false, error }.
43
+ * @returns {object} the success payload
44
+ */
45
+ function run() {
46
+ const args = parseArgs();
47
+ const action = args.action;
48
+
49
+ switch (action) {
50
+ case 'enable': {
51
+ const patch = { enabled: true };
52
+ if (typeof args.path === 'string' && args.path.length > 0) patch.path = args.path;
53
+ const cfg = logger.setConfig(patch);
54
+ return { ok: true, action, enabled: cfg.enabled, path: cfg.path };
55
+ }
56
+ case 'disable': {
57
+ const cfg = logger.setConfig({ enabled: false });
58
+ return { ok: true, action, enabled: cfg.enabled, path: cfg.path };
59
+ }
60
+ case 'status': {
61
+ const cfg = logger.getConfig();
62
+ const entries = logger.tail();
63
+ return { ok: true, action, enabled: cfg.enabled, path: cfg.path, count: entries.length };
64
+ }
65
+ default:
66
+ throw new Error(
67
+ `args.action must be one of enable|disable|status (got ${JSON.stringify(action)})`
68
+ );
69
+ }
70
+ }
71
+
72
+ function main() {
73
+ let payload;
74
+ try {
75
+ payload = run();
76
+ } catch (err) {
77
+ payload = { ok: false, error: (err && err.message) || String(err) };
78
+ }
79
+ process.stdout.write(JSON.stringify(payload) + '\n');
80
+ process.exit(0);
81
+ }
82
+
83
+ main();
@@ -0,0 +1,119 @@
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
+ const { loadExposeStore } = require('../../src/mcp/expose-store');
45
+
46
+ /**
47
+ * ToolFunnel root, resolved from THIS file's location:
48
+ * <root>/tools/scripts/tf-mcp-add.js -> root is two dirs up.
49
+ */
50
+ const ROOT = path.resolve(__dirname, '..', '..');
51
+
52
+ /** The MCP config (upstreams + expose), per the host path contract. */
53
+ const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
54
+
55
+ /**
56
+ * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed
57
+ * JSON or a non-object payload so the caller reports { ok:false, error }.
58
+ * @returns {object}
59
+ */
60
+ function parseArgs() {
61
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
62
+ if (raw === undefined || raw === null || raw === '') return {};
63
+ const parsed = JSON.parse(raw);
64
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
65
+ throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
66
+ }
67
+ return parsed;
68
+ }
69
+
70
+ /**
71
+ * Perform the add. May throw — main() turns any throw into { ok:false, error }.
72
+ * @returns {object} the success payload
73
+ */
74
+ function run() {
75
+ const args = parseArgs();
76
+ const store = loadExposeStore(EXPOSE_PATH);
77
+
78
+ // The store's normaliseUpstream already defaults transport/enabled/args/env, but
79
+ // apply the documented defaults explicitly so the intent is visible here too.
80
+ const upstream = store.addUpstream({
81
+ id: args.id,
82
+ command: args.command,
83
+ args: args.args,
84
+ env: args.env,
85
+ transport: typeof args.transport === 'string' ? args.transport : 'stdio',
86
+ enabled: typeof args.enabled === 'boolean' ? args.enabled : true,
87
+ description: args.description,
88
+ });
89
+
90
+ const exposed = [];
91
+ const exposeList = Array.isArray(args.expose) ? args.expose : [];
92
+ for (const item of exposeList) {
93
+ const e = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
94
+ exposed.push(
95
+ store.addExpose({
96
+ upstream: upstream.id,
97
+ tool: e.tool,
98
+ as: e.as,
99
+ category: e.category,
100
+ enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
101
+ })
102
+ );
103
+ }
104
+
105
+ return { ok: true, upstream, exposed };
106
+ }
107
+
108
+ function main() {
109
+ let payload;
110
+ try {
111
+ payload = run();
112
+ } catch (err) {
113
+ payload = { ok: false, error: (err && err.message) || String(err) };
114
+ }
115
+ process.stdout.write(JSON.stringify(payload) + '\n');
116
+ process.exit(0);
117
+ }
118
+
119
+ main();