toolfunnel 0.7.0 → 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 +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- 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-accept-change.js +67 -0
- 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 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +16 -5
package/src/extend/add-hook.md
CHANGED
|
@@ -1,152 +1,155 @@
|
|
|
1
|
-
# How to add a hook (self-extension)
|
|
2
|
-
|
|
3
|
-
This is the instruction served by `toolfunnel_howto({ topic: "add-hook" })`. It explains how to add a hook
|
|
4
|
-
to the Hook Engine: the manifest entry shape, where the script goes, and how the enabled/disabled
|
|
5
|
-
state is persisted separately in `hooks.state.json`. This is
|
|
6
|
-
the authoring view.
|
|
7
|
-
|
|
8
|
-
A hook is exactly two things:
|
|
9
|
-
1. **A manifest entry** in `hooks/hooks.manifest.json` (its config: event, matcher, command, ...).
|
|
10
|
-
2. **A script** under `hooks/scripts/` that the entry's `command` runs.
|
|
11
|
-
|
|
12
|
-
Activation (on/off) is NOT in the manifest's source of truth at runtime - it lives in an overlay,
|
|
13
|
-
`hooks/hooks.state.json` (see §4).
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## 1. The manifest entry shape
|
|
18
|
-
|
|
19
|
-
`hooks.manifest.json` has `version`, `hooksDir`, and a `hooks` array. Each entry:
|
|
20
|
-
|
|
21
|
-
```json
|
|
22
|
-
{
|
|
23
|
-
"id": "pre-tool-use/deny-dangerous",
|
|
24
|
-
"event": "PreToolUse",
|
|
25
|
-
"matcher": "Bash|toolfunnel_run_tool",
|
|
26
|
-
"type": "command",
|
|
27
|
-
"command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
|
|
28
|
-
"script": "scripts/deny-dangerous.sh",
|
|
29
|
-
"timeout": 5,
|
|
30
|
-
"enabled": true,
|
|
31
|
-
"description": "Denies any tool call whose args contain a destructive pattern (rm -rf, etc)."
|
|
32
|
-
}
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
| Field | Type | Meaning |
|
|
36
|
-
|---|---|---|
|
|
37
|
-
| `id` | string | Unique key, format `"<kebab-event>/<script-basename-without-ext>"` (e.g. `pre-tool-use/deny-dangerous`). The loader and `hooks.state.json` key on this. |
|
|
38
|
-
| `event` | string | One of the six lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`. |
|
|
39
|
-
| `matcher` | string | Regex anchored full-match against `tool_name`. `""`, `undefined`, or `"*"` = always fire. Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore it. Example: `"Bash|Write|Edit"`. |
|
|
40
|
-
| `type` | string | `"command"` (the only type today). |
|
|
41
|
-
| `command` | string | The command line the runner spawns. `${HOOKS_DIR}` expands to the absolute `hooks` path at load time. |
|
|
42
|
-
| `script` | string | The script path relative to `hooksDir`, e.g. `scripts/deny-dangerous.sh`. Used by the manager's open/edit (`readScript`/`writeScript`). |
|
|
43
|
-
| `timeout` | number | **Seconds** (the runner multiplies by 1000). The runner kills the child past this. |
|
|
44
|
-
| `enabled` | boolean | The manifest's default/seed state. The live value is the overlay in `hooks.state.json` - see §4. |
|
|
45
|
-
| `description` | string | What the hook does, for the manager UI. |
|
|
46
|
-
|
|
47
|
-
One entry **per hook command** - a settings.json event with N hooks becomes N entries.
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## 2. Where the script goes
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
hooks/
|
|
55
|
-
hooks.manifest.json # config entries (above)
|
|
56
|
-
hooks.state.json # enabled/disabled overlay, keyed by hook id
|
|
57
|
-
scripts/ # your hook scripts live HERE
|
|
58
|
-
deny-dangerous.sh
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
(The loader itself - `hook-loader.js` - is engine code and is NOT user content; it lives in
|
|
62
|
-
`src/core/`, not in `hooks/`.)
|
|
63
|
-
|
|
64
|
-
`writeScript` (the manager's save path) refuses any path that resolves outside
|
|
65
|
-
`hooks/scripts/` - defense-in-depth for the isolation rule. Author your script there only;
|
|
66
|
-
never point a hook at a script in a live host hooks directory. Clone the behaviour in if you need it.
|
|
67
|
-
|
|
68
|
-
---
|
|
69
|
-
|
|
70
|
-
## 3. What the script returns (the two protocols)
|
|
71
|
-
|
|
72
|
-
The runner spawns the command, writes the event payload as JSON on stdin, then reads the result.
|
|
73
|
-
The payload a tool event receives on stdin:
|
|
74
|
-
|
|
75
|
-
```json
|
|
76
|
-
{ "tool_name": "note_slow", "tool_input": { "arg": "value" } }
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
(`PreToolUse` → `tool_name` + `tool_input`; `PostToolUse` adds `tool_response`. Tool-less
|
|
80
|
-
events carry their own fields - `prompt`, `source` - instead.)
|
|
81
|
-
|
|
82
|
-
Two protocols:
|
|
83
|
-
|
|
84
|
-
**A) Exit-code (simple):**
|
|
85
|
-
- `exit 0` → success. On `SessionStart`/`UserPromptSubmit`, stdout becomes **injected context**;
|
|
86
|
-
on other events stdout is advisory.
|
|
87
|
-
- `exit 2` → **blocking**. stderr is the reason - on a blocked tool call the client receives it
|
|
88
|
-
as the result text with `isError: true`.
|
|
89
|
-
- any other code → non-blocking error (stderr captured, loop continues; never throws).
|
|
90
|
-
|
|
91
|
-
**B) JSON (advanced)** - honored **only on exit 0**, when stdout
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
- **The
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
1
|
+
# How to add a hook (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "add-hook" })`. It explains how to add a hook
|
|
4
|
+
to the Hook Engine: the manifest entry shape, where the script goes, and how the enabled/disabled
|
|
5
|
+
state is persisted separately in `hooks.state.json`. This is
|
|
6
|
+
the authoring view.
|
|
7
|
+
|
|
8
|
+
A hook is exactly two things:
|
|
9
|
+
1. **A manifest entry** in `hooks/hooks.manifest.json` (its config: event, matcher, command, ...).
|
|
10
|
+
2. **A script** under `hooks/scripts/` that the entry's `command` runs.
|
|
11
|
+
|
|
12
|
+
Activation (on/off) is NOT in the manifest's source of truth at runtime - it lives in an overlay,
|
|
13
|
+
`hooks/hooks.state.json` (see §4).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. The manifest entry shape
|
|
18
|
+
|
|
19
|
+
`hooks.manifest.json` has `version`, `hooksDir`, and a `hooks` array. Each entry:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"id": "pre-tool-use/deny-dangerous",
|
|
24
|
+
"event": "PreToolUse",
|
|
25
|
+
"matcher": "Bash|toolfunnel_run_tool",
|
|
26
|
+
"type": "command",
|
|
27
|
+
"command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
|
|
28
|
+
"script": "scripts/deny-dangerous.sh",
|
|
29
|
+
"timeout": 5,
|
|
30
|
+
"enabled": true,
|
|
31
|
+
"description": "Denies any tool call whose args contain a destructive pattern (rm -rf, etc)."
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Field | Type | Meaning |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `id` | string | Unique key, format `"<kebab-event>/<script-basename-without-ext>"` (e.g. `pre-tool-use/deny-dangerous`). The loader and `hooks.state.json` key on this. |
|
|
38
|
+
| `event` | string | One of the six lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`. |
|
|
39
|
+
| `matcher` | string | Regex anchored full-match against `tool_name`. `""`, `undefined`, or `"*"` = always fire. Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore it. Example: `"Bash|Write|Edit"`. |
|
|
40
|
+
| `type` | string | `"command"` (the only type today). |
|
|
41
|
+
| `command` | string | The command line the runner spawns. `${HOOKS_DIR}` expands to the absolute `hooks` path at load time. |
|
|
42
|
+
| `script` | string | The script path relative to `hooksDir`, e.g. `scripts/deny-dangerous.sh`. Used by the manager's open/edit (`readScript`/`writeScript`). |
|
|
43
|
+
| `timeout` | number | **Seconds** (the runner multiplies by 1000). The runner kills the child past this. |
|
|
44
|
+
| `enabled` | boolean | The manifest's default/seed state. The live value is the overlay in `hooks.state.json` - see §4. |
|
|
45
|
+
| `description` | string | What the hook does, for the manager UI. |
|
|
46
|
+
|
|
47
|
+
One entry **per hook command** - a settings.json event with N hooks becomes N entries.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 2. Where the script goes
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
hooks/
|
|
55
|
+
hooks.manifest.json # config entries (above)
|
|
56
|
+
hooks.state.json # enabled/disabled overlay, keyed by hook id
|
|
57
|
+
scripts/ # your hook scripts live HERE
|
|
58
|
+
deny-dangerous.sh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
(The loader itself - `hook-loader.js` - is engine code and is NOT user content; it lives in
|
|
62
|
+
`src/core/`, not in `hooks/`.)
|
|
63
|
+
|
|
64
|
+
`writeScript` (the manager's save path) refuses any path that resolves outside
|
|
65
|
+
`hooks/scripts/` - defense-in-depth for the isolation rule. Author your script there only;
|
|
66
|
+
never point a hook at a script in a live host hooks directory. Clone the behaviour in if you need it.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 3. What the script returns (the two protocols)
|
|
71
|
+
|
|
72
|
+
The runner spawns the command, writes the event payload as JSON on stdin, then reads the result.
|
|
73
|
+
The payload a tool event receives on stdin:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{ "tool_name": "note_slow", "tool_input": { "arg": "value" } }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
(`PreToolUse` → `tool_name` + `tool_input`; `PostToolUse` adds `tool_response`. Tool-less
|
|
80
|
+
events carry their own fields - `prompt`, `source` - instead.)
|
|
81
|
+
|
|
82
|
+
Two protocols:
|
|
83
|
+
|
|
84
|
+
**A) Exit-code (simple):**
|
|
85
|
+
- `exit 0` → success. On `SessionStart`/`UserPromptSubmit`, stdout becomes **injected context**;
|
|
86
|
+
on other events stdout is advisory.
|
|
87
|
+
- `exit 2` → **blocking**. stderr is the reason - on a blocked tool call the client receives it
|
|
88
|
+
as the result text with `isError: true`.
|
|
89
|
+
- any other code → non-blocking error (stderr captured, loop continues; never throws).
|
|
90
|
+
|
|
91
|
+
**B) JSON (advanced)** - honored **only on exit 0**, when stdout is a JSON object carrying protocol
|
|
92
|
+
keys - or ends with one on its own line after other output (a printed startup line is fine). A
|
|
93
|
+
decision that cannot be read (truncated, followed by stray text) **denies**; a printed object that
|
|
94
|
+
is not a decision (a diagnostic dump, a JSON log line) is ignored:
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"continue": true,
|
|
98
|
+
"decision": "block",
|
|
99
|
+
"reason": "...",
|
|
100
|
+
"hookSpecificOutput": {
|
|
101
|
+
"hookEventName": "PreToolUse",
|
|
102
|
+
"permissionDecision": "allow" | "deny" | "ask",
|
|
103
|
+
"permissionDecisionReason": "...",
|
|
104
|
+
"additionalContext": "..."
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
- **PreToolUse blocks via `hookSpecificOutput.permissionDecision: "deny"`** - NOT the top-level
|
|
109
|
+
`decision` field. `"allow"` passes; `"ask"` is non-blocking in the autonomous host.
|
|
110
|
+
- **Top-level `decision: "block"`** is for the other blockable events (UserPromptSubmit, PostToolUse,
|
|
111
|
+
Stop, PreCompact). There is no `"approve"`.
|
|
112
|
+
- `continue: false` stops the whole loop. `hookSpecificOutput.additionalContext` → injected text.
|
|
113
|
+
|
|
114
|
+
This is the mechanism that makes a PreToolUse hook gate `toolfunnel_run_tool`: match its `tool_name`, and
|
|
115
|
+
return `permissionDecision: "deny"` to block the call before the tool runs.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 4. Activation state lives in `hooks.state.json`
|
|
120
|
+
|
|
121
|
+
The Hook Manager design splits **what exists** from **what's on**:
|
|
122
|
+
|
|
123
|
+
- **The `scripts/` folder is the source of truth for what exists.** On startup the loader scans it
|
|
124
|
+
and reconciles with the manifest: newly-found scripts are added as entries (event/matcher inferred
|
|
125
|
+
or flagged "unconfigured"); manifest entries whose script is missing are flagged.
|
|
126
|
+
- **The manifest carries config** (event, matcher, command, timeout, description, seed `enabled`).
|
|
127
|
+
- **`hooks.state.json` is the activation overlay** - an object keyed by hook `id` recording the live
|
|
128
|
+
enabled/disabled value:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"version": 1,
|
|
133
|
+
"enabled": {
|
|
134
|
+
"pre-tool-use/deny-dangerous": true,
|
|
135
|
+
"user-prompt-submit/inject-context": false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Toggling a hook writes `hooks.state.json` **atomically** (temp + rename). It survives restarts and is
|
|
141
|
+
kept separate from the auto-detected inventory, so re-scanning the folder never clobbers your toggles.
|
|
142
|
+
At load, the overlay wins over the manifest's seed `enabled` for any id it lists.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 5. Steps to add a hook
|
|
147
|
+
|
|
148
|
+
1. Write the script into `hooks/scripts/<name>.sh` (or a node script). Read the stdin JSON
|
|
149
|
+
payload; emit one of the two protocols above.
|
|
150
|
+
2. Add the manifest entry: `id` = `"<kebab-event>/<name>"`, set `event`, `matcher`, `command`
|
|
151
|
+
(`bash "${HOOKS_DIR}/scripts/<name>.sh"`), `script`, `timeout` (seconds), `description`, and a
|
|
152
|
+
seed `enabled`.
|
|
153
|
+
3. Set its live state in `hooks.state.json` via the loader's `setEnabled(id, bool)` (atomic write).
|
|
154
|
+
4. Verify: the manager lists it under its event; a run-once test fires it with a sample payload and
|
|
155
|
+
shows the result. For a gate, confirm a matching `toolfunnel_run_tool` call is denied with your reason.
|
package/src/extend/add-mcp.md
CHANGED
|
@@ -1,119 +1,119 @@
|
|
|
1
|
-
# How to register an upstream MCP (self-extension)
|
|
2
|
-
|
|
3
|
-
This is the instruction served by `toolfunnel_howto({ topic: "add-mcp" })`. It explains how to connect an
|
|
4
|
-
**upstream MCP server** (a browser-automation MCP, a chat bridge, ...) to the aggregator and choose **which of its
|
|
5
|
-
tools to expose downstream** to the CLI. All of this is configured in one persisted file:
|
|
6
|
-
`mcp/expose.json`.
|
|
7
|
-
|
|
8
|
-
The architecture: many upstream MCPs each expose many tools; the
|
|
9
|
-
aggregator connects to them and the **MCP Manager picks which tools to surface** to the CLI as
|
|
10
|
-
*curated-direct* tools - alongside the four lean meta-tools (`toolfunnel_list_tools`,
|
|
11
|
-
`toolfunnel_tool_instructions`, `toolfunnel_run_tool`, `toolfunnel_howto`). The CLI sees a small surface; everything
|
|
12
|
-
else stays reachable through the register.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## 1. `expose.json` - the two blocks
|
|
18
|
-
|
|
19
|
-
`expose.json` persists (a) the upstream MCP connections and (b) the curated-direct selection drawn
|
|
20
|
-
from them:
|
|
21
|
-
|
|
22
|
-
```json
|
|
23
|
-
{
|
|
24
|
-
"version": 1,
|
|
25
|
-
"upstreams": [
|
|
26
|
-
{
|
|
27
|
-
"id": "chat",
|
|
28
|
-
"transport": "stdio",
|
|
29
|
-
"command": "node",
|
|
30
|
-
"args": ["vendor/chat-mcp/server.js"],
|
|
31
|
-
"env": { "CHAT_DB": "..." },
|
|
32
|
-
"enabled": true,
|
|
33
|
-
"description": "Chat bridge MCP - chats, messages, send."
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
"id": "browser",
|
|
37
|
-
"transport": "stdio",
|
|
38
|
-
"command": "npx",
|
|
39
|
-
"args": ["@example/browser-mcp"],
|
|
40
|
-
"enabled": false,
|
|
41
|
-
"description": "Browser automation MCP."
|
|
42
|
-
}
|
|
43
|
-
],
|
|
44
|
-
"expose": [
|
|
45
|
-
{
|
|
46
|
-
"upstream": "chat",
|
|
47
|
-
"tool": "list_chats",
|
|
48
|
-
"as": "chat_list_chats",
|
|
49
|
-
"category": "chat",
|
|
50
|
-
"enabled": true
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"upstream": "chat",
|
|
54
|
-
"tool": "send_message",
|
|
55
|
-
"as": "chat_send_message",
|
|
56
|
-
"category": "chat",
|
|
57
|
-
"enabled": true
|
|
58
|
-
}
|
|
59
|
-
]
|
|
60
|
-
}
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### `upstreams[]` - one entry per upstream MCP
|
|
64
|
-
|
|
65
|
-
| Field | Type | Meaning |
|
|
66
|
-
|---|---|---|
|
|
67
|
-
| `id` | string | Stable key for this upstream. Referenced by `expose[].upstream`. Unique. |
|
|
68
|
-
| `transport` | string | How the aggregator connects. `"stdio"` (spawn `command`+`args`) is the baseline. |
|
|
69
|
-
| `command` / `args` / `env` | string / string[] / object | The process the aggregator spawns for a `stdio` upstream. The isolation guard refuses to connect an upstream whose path-shaped **args** (including `--flag=value` values), **cwd**, or code-loading **env** vars (`NODE_OPTIONS`, `NODE_PATH`, and their equivalents) reach outside the config home. (The `command` itself, an executable, is not guarded. Opt-outs, each warned loudly by name: the currently-WRAPPED upstream is exempt; `allowOutsidePaths: true` on the entry exempts its paths; `allowCodeLoadingEnv: true` exempts the env class.) |
|
|
70
|
-
| `enabled` | boolean | Whether the aggregator connects to this upstream at startup. |
|
|
71
|
-
| `description` | string | Human-facing note for the MCP Manager UI. |
|
|
72
|
-
|
|
73
|
-
### `expose[]` - which upstream tools become curated-direct
|
|
74
|
-
|
|
75
|
-
| Field | Type | Meaning |
|
|
76
|
-
|---|---|---|
|
|
77
|
-
| `upstream` | string | Must match an `upstreams[].id`. |
|
|
78
|
-
| `tool` | string | The upstream tool's own name (as the upstream's `tools/list` reports it). |
|
|
79
|
-
| `as` | string | The downstream name the CLI sees (namespaced so two upstreams can't collide, e.g. `chat_send_message`). Optional - defaults to `<upstream>_<tool>`. |
|
|
80
|
-
| `category` | string | Grouping label, same convention as register entries. |
|
|
81
|
-
| `enabled` | boolean | Whether this tool is actually exposed downstream right now. |
|
|
82
|
-
|
|
83
|
-
Only the tools listed in `expose[]` with `enabled: true` are surfaced to the CLI as curated-direct
|
|
84
|
-
tools. Everything else on the upstream stays connected but hidden - reachable, if needed, by adding
|
|
85
|
-
an `expose` entry (no upstream reconnect required to *list*, though see §3 on the CLI surface).
|
|
86
|
-
|
|
87
|
-
---
|
|
88
|
-
|
|
89
|
-
## 2. Steps to register an upstream and expose a tool
|
|
90
|
-
|
|
91
|
-
1. Add an entry to `upstreams[]` with a unique `id`, the `stdio` `command`/`args`, and
|
|
92
|
-
`enabled: true`. Keep the server's path-shaped args **inside the config home** (vendor the
|
|
93
|
-
server in if it isn't already) - the isolation guard refuses args that escape it.
|
|
94
|
-
2. Let the aggregator connect and run the upstream's `tools/list` (the MCP Manager UI surfaces the
|
|
95
|
-
discovered tools).
|
|
96
|
-
3. For each tool you want hot, add an `expose[]` entry: `{ upstream, tool, as, category, enabled:true }`.
|
|
97
|
-
4. Leave the long tail unexposed - the model can still reach those via the register/meta-tools if you
|
|
98
|
-
later mirror them, but the *curated-direct* surface stays lean by design (the dial in §1 of the
|
|
99
|
-
architecture: directly-exposed = the HOT tools; the register meta-tools = the long tail).
|
|
100
|
-
|
|
101
|
-
---
|
|
102
|
-
|
|
103
|
-
## 3. Curated-direct vs. the register (update cost)
|
|
104
|
-
|
|
105
|
-
This matters because the two layers refresh differently:
|
|
106
|
-
|
|
107
|
-
- **Curated-direct tools** (what `expose[]` produces) are negotiated with the CLI **at connect time**.
|
|
108
|
-
Changing the exposed set therefore needs a **reconnect**, or a `notifications/tools/list_changed`
|
|
109
|
-
message *if the CLI honours it* (VERIFY per host; restart is the safe baseline).
|
|
110
|
-
- **Register tools** (the `toolfunnel_list_tools`/`run` path) are **always live** - the model re-reads the
|
|
111
|
-
register on every `toolfunnel_list_tools` call, so no reconnect is needed.
|
|
112
|
-
|
|
113
|
-
Rule of thumb: expose a handful of HOT, high-reliability tools curated-direct; everything else lives
|
|
114
|
-
in the register and is reached through the meta-tools.
|
|
115
|
-
|
|
116
|
-
> Safety: a curated-direct upstream tool is still ultimately invoked by the model. When you wire
|
|
117
|
-
> upstream execution through the host, route it through the same PreToolUse gate the register's `run`
|
|
118
|
-
> uses (see `toolfunnel_howto({ topic: "add-hook" })`) so the safety invariant holds uniformly. The gate
|
|
119
|
-
> firing on every execution path is a tested invariant, not an assumption.
|
|
1
|
+
# How to register an upstream MCP (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "add-mcp" })`. It explains how to connect an
|
|
4
|
+
**upstream MCP server** (a browser-automation MCP, a chat bridge, ...) to the aggregator and choose **which of its
|
|
5
|
+
tools to expose downstream** to the CLI. All of this is configured in one persisted file:
|
|
6
|
+
`mcp/expose.json`.
|
|
7
|
+
|
|
8
|
+
The architecture: many upstream MCPs each expose many tools; the
|
|
9
|
+
aggregator connects to them and the **MCP Manager picks which tools to surface** to the CLI as
|
|
10
|
+
*curated-direct* tools - alongside the four lean meta-tools (`toolfunnel_list_tools`,
|
|
11
|
+
`toolfunnel_tool_instructions`, `toolfunnel_run_tool`, `toolfunnel_howto`). The CLI sees a small surface; everything
|
|
12
|
+
else stays reachable through the register.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. `expose.json` - the two blocks
|
|
18
|
+
|
|
19
|
+
`expose.json` persists (a) the upstream MCP connections and (b) the curated-direct selection drawn
|
|
20
|
+
from them:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"version": 1,
|
|
25
|
+
"upstreams": [
|
|
26
|
+
{
|
|
27
|
+
"id": "chat",
|
|
28
|
+
"transport": "stdio",
|
|
29
|
+
"command": "node",
|
|
30
|
+
"args": ["vendor/chat-mcp/server.js"],
|
|
31
|
+
"env": { "CHAT_DB": "..." },
|
|
32
|
+
"enabled": true,
|
|
33
|
+
"description": "Chat bridge MCP - chats, messages, send."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "browser",
|
|
37
|
+
"transport": "stdio",
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["@example/browser-mcp"],
|
|
40
|
+
"enabled": false,
|
|
41
|
+
"description": "Browser automation MCP."
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"expose": [
|
|
45
|
+
{
|
|
46
|
+
"upstream": "chat",
|
|
47
|
+
"tool": "list_chats",
|
|
48
|
+
"as": "chat_list_chats",
|
|
49
|
+
"category": "chat",
|
|
50
|
+
"enabled": true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"upstream": "chat",
|
|
54
|
+
"tool": "send_message",
|
|
55
|
+
"as": "chat_send_message",
|
|
56
|
+
"category": "chat",
|
|
57
|
+
"enabled": true
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `upstreams[]` - one entry per upstream MCP
|
|
64
|
+
|
|
65
|
+
| Field | Type | Meaning |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `id` | string | Stable key for this upstream. Referenced by `expose[].upstream`. Unique. |
|
|
68
|
+
| `transport` | string | How the aggregator connects. `"stdio"` (spawn `command`+`args`) is the baseline. |
|
|
69
|
+
| `command` / `args` / `env` | string / string[] / object | The process the aggregator spawns for a `stdio` upstream. The isolation guard refuses to connect an upstream whose path-shaped **args** (including `--flag=value` values), **cwd**, or code-loading **env** vars (`NODE_OPTIONS`, `NODE_PATH`, and their equivalents) reach outside the config home. (The `command` itself, an executable, is not guarded. Opt-outs, each warned loudly by name: the currently-WRAPPED upstream is exempt; `allowOutsidePaths: true` on the entry exempts its paths; `allowCodeLoadingEnv: true` exempts the env class.) |
|
|
70
|
+
| `enabled` | boolean | Whether the aggregator connects to this upstream at startup. |
|
|
71
|
+
| `description` | string | Human-facing note for the MCP Manager UI. |
|
|
72
|
+
|
|
73
|
+
### `expose[]` - which upstream tools become curated-direct
|
|
74
|
+
|
|
75
|
+
| Field | Type | Meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `upstream` | string | Must match an `upstreams[].id`. |
|
|
78
|
+
| `tool` | string | The upstream tool's own name (as the upstream's `tools/list` reports it). |
|
|
79
|
+
| `as` | string | The downstream name the CLI sees (namespaced so two upstreams can't collide, e.g. `chat_send_message`). Optional - defaults to `<upstream>_<tool>`. |
|
|
80
|
+
| `category` | string | Grouping label, same convention as register entries. |
|
|
81
|
+
| `enabled` | boolean | Whether this tool is actually exposed downstream right now. |
|
|
82
|
+
|
|
83
|
+
Only the tools listed in `expose[]` with `enabled: true` are surfaced to the CLI as curated-direct
|
|
84
|
+
tools. Everything else on the upstream stays connected but hidden - reachable, if needed, by adding
|
|
85
|
+
an `expose` entry (no upstream reconnect required to *list*, though see §3 on the CLI surface).
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 2. Steps to register an upstream and expose a tool
|
|
90
|
+
|
|
91
|
+
1. Add an entry to `upstreams[]` with a unique `id`, the `stdio` `command`/`args`, and
|
|
92
|
+
`enabled: true`. Keep the server's path-shaped args **inside the config home** (vendor the
|
|
93
|
+
server in if it isn't already) - the isolation guard refuses args that escape it.
|
|
94
|
+
2. Let the aggregator connect and run the upstream's `tools/list` (the MCP Manager UI surfaces the
|
|
95
|
+
discovered tools).
|
|
96
|
+
3. For each tool you want hot, add an `expose[]` entry: `{ upstream, tool, as, category, enabled:true }`.
|
|
97
|
+
4. Leave the long tail unexposed - the model can still reach those via the register/meta-tools if you
|
|
98
|
+
later mirror them, but the *curated-direct* surface stays lean by design (the dial in §1 of the
|
|
99
|
+
architecture: directly-exposed = the HOT tools; the register meta-tools = the long tail).
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 3. Curated-direct vs. the register (update cost)
|
|
104
|
+
|
|
105
|
+
This matters because the two layers refresh differently:
|
|
106
|
+
|
|
107
|
+
- **Curated-direct tools** (what `expose[]` produces) are negotiated with the CLI **at connect time**.
|
|
108
|
+
Changing the exposed set therefore needs a **reconnect**, or a `notifications/tools/list_changed`
|
|
109
|
+
message *if the CLI honours it* (VERIFY per host; restart is the safe baseline).
|
|
110
|
+
- **Register tools** (the `toolfunnel_list_tools`/`run` path) are **always live** - the model re-reads the
|
|
111
|
+
register on every `toolfunnel_list_tools` call, so no reconnect is needed.
|
|
112
|
+
|
|
113
|
+
Rule of thumb: expose a handful of HOT, high-reliability tools curated-direct; everything else lives
|
|
114
|
+
in the register and is reached through the meta-tools.
|
|
115
|
+
|
|
116
|
+
> Safety: a curated-direct upstream tool is still ultimately invoked by the model. When you wire
|
|
117
|
+
> upstream execution through the host, route it through the same PreToolUse gate the register's `run`
|
|
118
|
+
> uses (see `toolfunnel_howto({ topic: "add-hook" })`) so the safety invariant holds uniformly. The gate
|
|
119
|
+
> firing on every execution path is a tested invariant, not an assumption.
|