toolfunnel 0.5.0 → 0.7.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 +235 -167
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -147
- package/docs/KNOWN_BUGS.md +80 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +222 -12
- package/package.json +67 -65
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +119 -121
- package/src/extend/configure.md +88 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1674 -807
- package/src/mcp/expose-store.js +577 -430
- package/src/mcp/gated-run.js +210 -194
- package/src/mcp/http-transport.js +1563 -1012
- package/src/mcp/mcp-client.js +1070 -530
- package/src/mcp/modern.js +406 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +3112 -1405
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1187 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +14 -3
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
|
@@ -1,112 +1,120 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* tf-hook-set.js
|
|
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
|
|
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
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
* returns false on a miss - no manifest row, no detected script - and
|
|
27
|
+
* writes NOTHING, so a miss is reported as { ok:false, error })
|
|
28
|
+
* - disable -> loader.setEnabled(id, false) (same miss contract)
|
|
29
|
+
* - remove -> loader.removeEntry(id) (returns false if the id was absent;
|
|
30
|
+
* that is information, not a fault, so ok stays true)
|
|
31
|
+
*
|
|
32
|
+
* Safety invariants:
|
|
33
|
+
* - Touches only ToolFunnel's own manifest / state (resolved from __dirname).
|
|
34
|
+
* - NO network. NEVER throws for ordinary conditions - only JSON on stdout.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
// Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
|
|
39
|
+
const { HOME, srcRequire } = require('./tf-env');
|
|
40
|
+
const { loadManifest } = srcRequire('core/hook-loader');
|
|
41
|
+
|
|
42
|
+
const ROOT = HOME;
|
|
43
|
+
|
|
44
|
+
/** The hook manifest, relative to ROOT. */
|
|
45
|
+
const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. Absent/empty
|
|
49
|
+
* is treated as an empty object. Returns the parsed value or a parse-error
|
|
50
|
+
* sentinel so the caller surfaces a clean { ok:false, error }.
|
|
51
|
+
*
|
|
52
|
+
* @returns {{ value: any } | { parseError: string }}
|
|
53
|
+
*/
|
|
54
|
+
function parseStructuredArgs() {
|
|
55
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
56
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
57
|
+
return { value: {} };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return { value: JSON.parse(raw) };
|
|
61
|
+
} catch (_err) {
|
|
62
|
+
return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pure-ish core: validate args and apply the requested action. Never throws for
|
|
68
|
+
* ordinary conditions (any loader throw is caught in main).
|
|
69
|
+
*
|
|
70
|
+
* @param {any} args
|
|
71
|
+
* @returns {object} the JSON-serialisable result
|
|
72
|
+
*/
|
|
73
|
+
function run(args) {
|
|
74
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
75
|
+
return { ok: false, error: 'args must be an object { id, action }' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { id, action } = args;
|
|
79
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
80
|
+
return { ok: false, error: 'id must be a non-empty string' };
|
|
81
|
+
}
|
|
82
|
+
if (action !== 'enable' && action !== 'disable' && action !== 'remove') {
|
|
83
|
+
return { ok: false, error: 'action must be one of "enable", "disable", "remove"' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const loader = loadManifest(MANIFEST);
|
|
87
|
+
|
|
88
|
+
if (action === 'remove') {
|
|
89
|
+
const removed = loader.removeEntry(id);
|
|
90
|
+
return { ok: true, id, action, removed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const desired = action === 'enable';
|
|
94
|
+
// setEnabled reports whether the toggle matched anything (a manifest row or a
|
|
95
|
+
// detected script). A miss writes nothing, so claiming ok:true here would report
|
|
96
|
+
// a gate toggled when nothing changed.
|
|
97
|
+
const applied = loader.setEnabled(id, desired);
|
|
98
|
+
if (!applied) {
|
|
99
|
+
return { ok: false, error: `no hook with id "${id}" - nothing was changed` };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, id, action, enabled: desired };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function main() {
|
|
105
|
+
const parsed = parseStructuredArgs();
|
|
106
|
+
let payload;
|
|
107
|
+
if (parsed.parseError) {
|
|
108
|
+
payload = { ok: false, error: parsed.parseError };
|
|
109
|
+
} else {
|
|
110
|
+
try {
|
|
111
|
+
payload = run(parsed.value);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main();
|
package/tools/scripts/tf-list.js
CHANGED
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* tf-list.js
|
|
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
|
|
12
|
-
* - mcps
|
|
13
|
-
* - hooks
|
|
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
|
|
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
|
|
34
|
-
* overlay-derived { enabled, hidden }.
|
|
35
|
-
* mcps
|
|
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
|
|
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)
|
|
45
|
-
* - NO network, NO writes, NO process mutation.
|
|
46
|
-
* - NEVER throws for ordinary conditions
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
const path = require('node:path');
|
|
50
|
-
|
|
51
|
-
// HOME/engine resolution shared by every tf-* script (see tf-env.js): the config lives beside
|
|
52
|
-
// this script (the git-clone root OR a seeded external config home); the engine code is required
|
|
53
|
-
// from the package that spawned us when it is not local.
|
|
54
|
-
const { HOME, srcRequire } = require('./tf-env');
|
|
55
|
-
const { loadRegistry } = srcRequire('tools/registry');
|
|
56
|
-
const { loadToolState, isToolEnabled, isToolHidden } = srcRequire('tools/tool-state');
|
|
57
|
-
const { loadExposeStore } = srcRequire('mcp/expose-store');
|
|
58
|
-
const { loadManifest } = srcRequire('core/hook-loader');
|
|
59
|
-
|
|
60
|
-
const ROOT = HOME;
|
|
61
|
-
|
|
62
|
-
// Canonical store paths (see the script contract).
|
|
63
|
-
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
64
|
-
const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
65
|
-
const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
|
|
66
|
-
const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
|
|
67
|
-
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Parse the structured args from env. Never throws: a missing/blank/malformed
|
|
71
|
-
* TOOLFUNNEL_TOOL_ARGS yields {} so the kind check below produces a clean
|
|
72
|
-
* { ok:false, error } rather than a crash.
|
|
73
|
-
* @returns {object}
|
|
74
|
-
*/
|
|
75
|
-
function readArgs() {
|
|
76
|
-
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
77
|
-
if (typeof raw !== 'string' || raw.length === 0) return {};
|
|
78
|
-
try {
|
|
79
|
-
const parsed = JSON.parse(raw);
|
|
80
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
81
|
-
} catch (_err) {
|
|
82
|
-
return {};
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* tools
|
|
88
|
-
* manager-view declutter: they are OMITTED by default and only included when args.includeHidden is
|
|
89
|
-
* true. (This is the manager surface only
|
|
90
|
-
* the connected AI sees.)
|
|
91
|
-
* @param {object} [args] { includeHidden?: boolean }
|
|
92
|
-
* @returns {Array<{id,name,summary,category,enabled,hidden}>}
|
|
93
|
-
*/
|
|
94
|
-
function listTools(args) {
|
|
95
|
-
const includeHidden = !!(args && args.includeHidden === true);
|
|
96
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
97
|
-
const state = loadToolState(STATE_PATH);
|
|
98
|
-
return registry.list()
|
|
99
|
-
.map((brief) => ({
|
|
100
|
-
...brief,
|
|
101
|
-
enabled: isToolEnabled(state, brief.id),
|
|
102
|
-
hidden: isToolHidden(state, brief.id),
|
|
103
|
-
}))
|
|
104
|
-
.filter((t) => includeHidden || !t.hidden); // declutter: omit hidden unless explicitly asked
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* mcps
|
|
109
|
-
* Uses listUpstreams() for the connection config (incl. `enabled`) and
|
|
110
|
-
* listExposed() for the surfaced tools, grouping each expose under its owning
|
|
111
|
-
* upstream and resolving the downstream name via exposedName().
|
|
112
|
-
* @returns {Array<object>}
|
|
113
|
-
*/
|
|
114
|
-
function listMcps() {
|
|
115
|
-
const store = loadExposeStore(EXPOSE_PATH);
|
|
116
|
-
const upstreams = store.listUpstreams();
|
|
117
|
-
const exposed = store.listExposed();
|
|
118
|
-
|
|
119
|
-
// Group exposes by their upstream id (every expose references an existing
|
|
120
|
-
// upstream
|
|
121
|
-
// to an empty list so an unmatched entry never throws).
|
|
122
|
-
const byUpstream = new Map();
|
|
123
|
-
for (const e of exposed) {
|
|
124
|
-
const annotated = { ...e, name: store.exposedName(e) };
|
|
125
|
-
const bucket = byUpstream.get(e.upstream);
|
|
126
|
-
if (bucket) bucket.push(annotated);
|
|
127
|
-
else byUpstream.set(e.upstream, [annotated]);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return upstreams.map((u) => ({
|
|
131
|
-
...u,
|
|
132
|
-
exposed: byUpstream.get(u.id) || [],
|
|
133
|
-
}));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* hooks
|
|
138
|
-
* overlay (readState) when it carries the hook id, else the manifest's own flag.
|
|
139
|
-
* loadManifest already applies the overlay over the in-memory specs, but we read
|
|
140
|
-
* the overlay explicitly so the resolved `enabled` is correct regardless of that.
|
|
141
|
-
* @returns {Array<object>}
|
|
142
|
-
*/
|
|
143
|
-
function listHooks() {
|
|
144
|
-
const loader = loadManifest(MANIFEST_PATH);
|
|
145
|
-
const overlay = loader.readState();
|
|
146
|
-
return loader.hooks.map((h) => {
|
|
147
|
-
const hasOverride = h && typeof h.id === 'string' &&
|
|
148
|
-
Object.prototype.hasOwnProperty.call(overlay, h.id);
|
|
149
|
-
const enabled = hasOverride ? overlay[h.id] === true : h.enabled === true;
|
|
150
|
-
return { ...h, enabled };
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Build the inventory for the requested kind. Throws on an unknown kind so main()
|
|
156
|
-
* can surface it as { ok:false, error }.
|
|
157
|
-
* @param {object} args
|
|
158
|
-
* @returns {{ ok:true, kind:string, items:Array<object> }}
|
|
159
|
-
*/
|
|
160
|
-
function build(args) {
|
|
161
|
-
const kind = args && args.kind;
|
|
162
|
-
let items;
|
|
163
|
-
switch (kind) {
|
|
164
|
-
case 'tools':
|
|
165
|
-
items = listTools(args); // honours args.includeHidden (hidden tools omitted by default)
|
|
166
|
-
break;
|
|
167
|
-
case 'mcps':
|
|
168
|
-
items = listMcps();
|
|
169
|
-
break;
|
|
170
|
-
case 'hooks':
|
|
171
|
-
items = listHooks();
|
|
172
|
-
break;
|
|
173
|
-
default:
|
|
174
|
-
throw new Error(`tf-list: kind must be one of "tools"|"mcps"|"hooks" (got ${JSON.stringify(kind)})`);
|
|
175
|
-
}
|
|
176
|
-
return { ok: true, kind, items };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function main() {
|
|
180
|
-
let payload;
|
|
181
|
-
try {
|
|
182
|
-
payload = build(readArgs());
|
|
183
|
-
} catch (err) {
|
|
184
|
-
// Any logical error (bad kind, missing/malformed store) becomes a well-shaped
|
|
185
|
-
// failure line. Still exit 0
|
|
186
|
-
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
187
|
-
}
|
|
188
|
-
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
189
|
-
process.exit(0);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
main();
|
|
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
|
+
// HOME/engine resolution shared by every tf-* script (see tf-env.js): the config lives beside
|
|
52
|
+
// this script (the git-clone root OR a seeded external config home); the engine code is required
|
|
53
|
+
// from the package that spawned us when it is not local.
|
|
54
|
+
const { HOME, srcRequire } = require('./tf-env');
|
|
55
|
+
const { loadRegistry } = srcRequire('tools/registry');
|
|
56
|
+
const { loadToolState, isToolEnabled, isToolHidden } = srcRequire('tools/tool-state');
|
|
57
|
+
const { loadExposeStore } = srcRequire('mcp/expose-store');
|
|
58
|
+
const { loadManifest } = srcRequire('core/hook-loader');
|
|
59
|
+
|
|
60
|
+
const ROOT = HOME;
|
|
61
|
+
|
|
62
|
+
// Canonical store paths (see the script contract).
|
|
63
|
+
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
64
|
+
const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
65
|
+
const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
|
|
66
|
+
const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
|
|
67
|
+
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse the structured args from env. Never throws: a missing/blank/malformed
|
|
71
|
+
* TOOLFUNNEL_TOOL_ARGS yields {} so the kind check below produces a clean
|
|
72
|
+
* { ok:false, error } rather than a crash.
|
|
73
|
+
* @returns {object}
|
|
74
|
+
*/
|
|
75
|
+
function readArgs() {
|
|
76
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
77
|
+
if (typeof raw !== 'string' || raw.length === 0) return {};
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
81
|
+
} catch (_err) {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* tools -> register briefs annotated with the overlay's enabled + hidden state. HIDDEN tools are a
|
|
88
|
+
* manager-view declutter: they are OMITTED by default and only included when args.includeHidden is
|
|
89
|
+
* true. (This is the manager surface only - `hidden` never affects the lean list / top-level surface
|
|
90
|
+
* the connected AI sees.)
|
|
91
|
+
* @param {object} [args] { includeHidden?: boolean }
|
|
92
|
+
* @returns {Array<{id,name,summary,category,enabled,hidden}>}
|
|
93
|
+
*/
|
|
94
|
+
function listTools(args) {
|
|
95
|
+
const includeHidden = !!(args && args.includeHidden === true);
|
|
96
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
97
|
+
const state = loadToolState(STATE_PATH);
|
|
98
|
+
return registry.list()
|
|
99
|
+
.map((brief) => ({
|
|
100
|
+
...brief,
|
|
101
|
+
enabled: isToolEnabled(state, brief.id),
|
|
102
|
+
hidden: isToolHidden(state, brief.id),
|
|
103
|
+
}))
|
|
104
|
+
.filter((t) => includeHidden || !t.hidden); // declutter: omit hidden unless explicitly asked
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* mcps -> one item per upstream MCP, with its curated-direct exposes nested.
|
|
109
|
+
* Uses listUpstreams() for the connection config (incl. `enabled`) and
|
|
110
|
+
* listExposed() for the surfaced tools, grouping each expose under its owning
|
|
111
|
+
* upstream and resolving the downstream name via exposedName().
|
|
112
|
+
* @returns {Array<object>}
|
|
113
|
+
*/
|
|
114
|
+
function listMcps() {
|
|
115
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
116
|
+
const upstreams = store.listUpstreams();
|
|
117
|
+
const exposed = store.listExposed();
|
|
118
|
+
|
|
119
|
+
// Group exposes by their upstream id (every expose references an existing
|
|
120
|
+
// upstream - addExpose requires it and removeUpstream cascades - but default
|
|
121
|
+
// to an empty list so an unmatched entry never throws).
|
|
122
|
+
const byUpstream = new Map();
|
|
123
|
+
for (const e of exposed) {
|
|
124
|
+
const annotated = { ...e, name: store.exposedName(e) };
|
|
125
|
+
const bucket = byUpstream.get(e.upstream);
|
|
126
|
+
if (bucket) bucket.push(annotated);
|
|
127
|
+
else byUpstream.set(e.upstream, [annotated]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return upstreams.map((u) => ({
|
|
131
|
+
...u,
|
|
132
|
+
exposed: byUpstream.get(u.id) || [],
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* hooks -> one item per manifest hook with `enabled` resolved from the persisted
|
|
138
|
+
* overlay (readState) when it carries the hook id, else the manifest's own flag.
|
|
139
|
+
* loadManifest already applies the overlay over the in-memory specs, but we read
|
|
140
|
+
* the overlay explicitly so the resolved `enabled` is correct regardless of that.
|
|
141
|
+
* @returns {Array<object>}
|
|
142
|
+
*/
|
|
143
|
+
function listHooks() {
|
|
144
|
+
const loader = loadManifest(MANIFEST_PATH);
|
|
145
|
+
const overlay = loader.readState();
|
|
146
|
+
return loader.hooks.map((h) => {
|
|
147
|
+
const hasOverride = h && typeof h.id === 'string' &&
|
|
148
|
+
Object.prototype.hasOwnProperty.call(overlay, h.id);
|
|
149
|
+
const enabled = hasOverride ? overlay[h.id] === true : h.enabled === true;
|
|
150
|
+
return { ...h, enabled };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the inventory for the requested kind. Throws on an unknown kind so main()
|
|
156
|
+
* can surface it as { ok:false, error }.
|
|
157
|
+
* @param {object} args
|
|
158
|
+
* @returns {{ ok:true, kind:string, items:Array<object> }}
|
|
159
|
+
*/
|
|
160
|
+
function build(args) {
|
|
161
|
+
const kind = args && args.kind;
|
|
162
|
+
let items;
|
|
163
|
+
switch (kind) {
|
|
164
|
+
case 'tools':
|
|
165
|
+
items = listTools(args); // honours args.includeHidden (hidden tools omitted by default)
|
|
166
|
+
break;
|
|
167
|
+
case 'mcps':
|
|
168
|
+
items = listMcps();
|
|
169
|
+
break;
|
|
170
|
+
case 'hooks':
|
|
171
|
+
items = listHooks();
|
|
172
|
+
break;
|
|
173
|
+
default:
|
|
174
|
+
throw new Error(`tf-list: kind must be one of "tools"|"mcps"|"hooks" (got ${JSON.stringify(kind)})`);
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, kind, items };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function main() {
|
|
180
|
+
let payload;
|
|
181
|
+
try {
|
|
182
|
+
payload = build(readArgs());
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// Any logical error (bad kind, missing/malformed store) becomes a well-shaped
|
|
185
|
+
// failure line. Still exit 0 - the gated runner treats parseable stdout as ok.
|
|
186
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
187
|
+
}
|
|
188
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
main();
|