toolfunnel 0.3.0 → 0.5.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 (40) hide show
  1. package/README.md +167 -324
  2. package/bin/toolfunnel.js +39 -4
  3. package/docs/KNOWN_BUGS.md +33 -0
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/packaging.md +115 -0
  6. package/package.json +3 -1
  7. package/src/auth/config.js +4 -2
  8. package/src/auth/resource-server.js +28 -3
  9. package/src/core/config-home.js +134 -0
  10. package/src/core/logger.js +4 -2
  11. package/src/core/requires.js +138 -0
  12. package/src/core/server-config.js +77 -0
  13. package/src/extend/create-tool.md +24 -4
  14. package/src/extend/package.md +33 -0
  15. package/src/mcp/aggregator.js +12 -7
  16. package/src/mcp/http-transport.js +71 -21
  17. package/src/mcp/mcp-client.js +9 -3
  18. package/src/mcp/server.js +181 -16
  19. package/src/packages/packager.js +5 -3
  20. package/src/tools/registry.js +47 -2
  21. package/src/ui/server.js +49 -3
  22. package/tools/scripts/tf-env.js +43 -0
  23. package/tools/scripts/tf-hook-add.js +4 -5
  24. package/tools/scripts/tf-hook-set.js +4 -5
  25. package/tools/scripts/tf-list.js +9 -12
  26. package/tools/scripts/tf-log.js +4 -1
  27. package/tools/scripts/tf-mcp-add.js +4 -6
  28. package/tools/scripts/tf-mcp-set.js +4 -6
  29. package/tools/scripts/tf-pack.js +237 -0
  30. package/tools/scripts/tf-tool-add.js +16 -16
  31. package/tools/scripts/tf-tool-set.js +5 -11
  32. package/tools/tools.register.json +46 -1
  33. package/docs/admin-ui.md +0 -404
  34. package/docs/configuration.md +0 -402
  35. package/docs/hooks-and-gating.md +0 -428
  36. package/docs/logging.md +0 -223
  37. package/docs/management.md +0 -429
  38. package/docs/oauth.md +0 -103
  39. package/docs/setup.md +0 -352
  40. package/docs/tools-and-upstreams.md +0 -469
@@ -1,402 +0,0 @@
1
- # Configuration reference
2
-
3
- ToolFunnel is driven entirely by plain JSON files on disk — no database, no environment-variable
4
- configuration of the surface. Everything the gateway exposes, curates, and gates is read from a
5
- handful of config files. This document is the authoritative, field-by-field reference; it is
6
- derived from the loaders that actually read each file (`src/tools/registry.js`,
7
- `src/tools/tool-state.js`, `src/mcp/expose-store.js`, `src/core/hook-loader.js`), not from intent.
8
-
9
- ## The clean split: `src/` is the engine, the top level is yours
10
-
11
- ToolFunnel keeps a deliberate separation between **the engine** and **your configuration**:
12
-
13
- - **`src/`** is ToolFunnel's own engine code. You never edit it. It holds the server, the protocol,
14
- the transports, the aggregator, the hook engine, and the tool *system* (`src/tools/registry.js`,
15
- `src/tools/tool-state.js`, `src/tools/drift.js`). These modules *read* your config — they are not
16
- your config.
17
- - **`tools/`, `mcp/`, and `hooks/`** at the repo root are **your** stuff — plain JSON plus the
18
- scripts they reference. This is the only place you author anything:
19
-
20
- | Directory | What you put there | Read by (engine) |
21
- |---|---|---|
22
- | `tools/` | `tools.register.json`, `tools.state.json`, `scripts/*.js` — your first-party tools | `src/tools/registry.js`, `src/tools/tool-state.js` |
23
- | `mcp/` | `expose.json` — your upstream MCP servers + the curated tools drawn from them | `src/mcp/expose-store.js` |
24
- | `hooks/` | `hooks.manifest.json`, `hooks.state.json`, `scripts/*` — your policy gate | `src/core/hook-loader.js` |
25
-
26
- Rule of thumb: if you are editing something under `src/`, stop — that is the machinery. Your
27
- register, your upstreams, and your gate all live at the top level.
28
-
29
- ## The files at a glance
30
-
31
- | File | Loader | Purpose |
32
- |---|---|---|
33
- | `tools/tools.register.json` | `src/tools/registry.js` | The tool register — every first-party tool's metadata + how to invoke it. |
34
- | `tools/tools.state.json` | `src/tools/tool-state.js` | Per-tool enable/disable + hidden overlay over the register. |
35
- | `mcp/expose.json` | `src/mcp/expose-store.js` | Upstream MCP connections + the curated-direct tools drawn from them. |
36
- | `hooks/hooks.manifest.json` | `src/core/hook-loader.js` | The hook manifest — the gate. Each entry is one lifecycle hook. |
37
- | `hooks/hooks.state.json` | `src/core/hook-loader.js` | Per-hook enabled overlay over the manifest (sibling of the manifest). |
38
- | `logs/log.config.json` | `src/core/logger.js` | Toggle + path for the optional JSONL audit log (tool runs + gate allow/deny decisions). **Default OFF**; the file is not created until logging is enabled (via the `tf_log` management function or the admin UI Logs tab). Full reference: [`logging.md`](logging.md). |
39
- | `auth/auth.config.json` | `src/auth/config.js` | Toggle + settings for the optional **OAuth 2.1 resource-server** validation. **Default OFF** (loopback-only, unauthenticated). When enabled, the HTTP transport validates a bearer token on every request. Full reference: [`oauth.md`](oauth.md). |
40
-
41
- > **All paths resolve under the repo root.** The server (`src/mcp/server.js`) anchors
42
- > `ROOT = <repo>` from its own location (`path.resolve(__dirname, '..', '..')`) and joins every
43
- > config path off it: `tools/tools.register.json`, `tools/tools.state.json`,
44
- > `hooks/hooks.manifest.json`, `mcp/expose.json`, and the tool scripts root `tools/scripts`.
45
- > Nothing is read from outside the tree, and script/command targets are path-guarded to stay
46
- > inside it (the isolation rule).
47
-
48
- ## Conventions shared by every file
49
-
50
- - **JSON only**, parsed once at load. A malformed register, expose store, or manifest throws a
51
- clear error at load time — misconfiguration is caught at startup, not at model-call time. The
52
- two overlay files (`tools.state.json`, `hooks.state.json`) are the exception: a
53
- missing/unreadable/malformed overlay degrades to "no overrides" and never throws.
54
- - **Atomic writes.** Every write goes to a temp file in the same directory, is fsync'd, then
55
- renamed over the target. A crash leaves either the old file or the new file, never a
56
- half-written config. Prefer the loader's write methods over hand-editing a live file.
57
- - **No restart for register/state edits.** The server reads `tools.register.json` and
58
- `tools.state.json` fresh on every `toolfunnel_list_tools` call, so a tool added or toggled is
59
- visible with no reconnect. (Curated-direct tools from `expose.json` are the exception — see that
60
- section.)
61
-
62
- ---
63
-
64
- ## `tools/tools.register.json` — the tool register
65
-
66
- Top-level shape:
67
-
68
- ```json
69
- {
70
- "version": 1,
71
- "description": "Tool register for the gateway — built-in demo scripts.",
72
- "tools": [ /* entries */ ]
73
- }
74
- ```
75
-
76
- - `version` — number (defaults to `1` if absent). Schema version of the register.
77
- - `description` — string (defaults to `""`). Free-text label, preserved across writes.
78
- - `tools` — array of entries (defaults to `[]`).
79
-
80
- ### Entry shape
81
-
82
- ```json
83
- {
84
- "id": "echo",
85
- "name": "Echo",
86
- "summary": "Return the provided arguments back unchanged.",
87
- "category": "demo",
88
- "instructions": "Echoes its structured args back in the result. Pass any JSON value as args and the same value is returned.",
89
- "invoke": { "type": "script", "path": "scripts/echo.js" }
90
- }
91
- ```
92
-
93
- | Field | Type | Required | Meaning |
94
- |---|---|---|---|
95
- | `id` | string | **yes** | Stable unique key. `toolfunnel_run_tool({ name })` / `toolfunnel_tool_instructions({ name })` look up by this (id **or** display name). Duplicate ids are rejected **at load**. |
96
- | `name` | string | **yes** | Display name shown in `toolfunnel_list_tools` briefs and the manager UI. |
97
- | `summary` | string | no (→ `""`) | One line. This is *all* a brief carries for the tool — keep it tight so the model can pick from the brief alone. |
98
- | `category` | string | no (→ `""`) | Grouping label (`demo`, `crypto`, `data`, …). `toolfunnel_list_tools({ category })` filters on an exact match. |
99
- | `instructions` | string | no (→ `""`) | Full usage doc returned by `toolfunnel_tool_instructions`. The long-tail payload — be complete here, terse in `summary`. |
100
- | `mode` | string | no (→ inferred) | Execution mode — `gateway` or `reference`. See below. |
101
- | `invoke` | object | **yes** (unless `mode: "reference"`) | How the tool runs. Two forms below. |
102
-
103
- Validation (from `registry.js::validateEntry`): `id` and `name` must be non-empty strings, and
104
- `mode`, if present, must be `reference` or `gateway`. `invoke` must be present with `invoke.type`
105
- one of `script` | `shell` — **except** an explicit `mode: "reference"` may omit `invoke` entirely
106
- (a reference tool runs nothing server-side, so there is nothing to invoke). `summary`, `category`,
107
- and `instructions` are optional and default to empty strings.
108
-
109
- ### `mode` — gateway vs reference execution
110
-
111
- The optional `mode` field decides **who runs the tool** (from `registry.js::resolveMode` /
112
- `VALID_MODES`):
113
-
114
- - **`gateway`** — ToolFunnel executes the `invoke` **server-side**, through the gated run path
115
- (PreToolUse → execute → PostToolUse). This is the original behaviour of every `script`/`shell`
116
- tool.
117
- - **`reference`** — ToolFunnel executes **nothing**. `toolfunnel_run_tool` returns the tool's
118
- `instructions` so the **connected AI performs the action in its own environment**. A reference
119
- tool needs no `invoke`.
120
-
121
- When `mode` is absent it is **inferred backward-compatibly**: a `script`/`shell` invoke ⇒
122
- `gateway` (so every existing register entry keeps running exactly as before), anything else ⇒
123
- `reference`. An explicit, valid `mode` always wins over the inference.
124
-
125
- ### `invoke` — two forms
126
-
127
- **Script invoke (the implemented, tested path):**
128
-
129
- ```json
130
- { "type": "script", "path": "scripts/echo.js" }
131
- ```
132
-
133
- - `path` points at a file under **`tools/scripts/`**. The runner takes `path.basename(path)` and
134
- resolves it under the scripts root (`tools/scripts`), then refuses any result that escapes that
135
- directory (defense-in-depth path guard). A `script` invoke needs a non-empty `path` or load
136
- fails. In practice `path` is always written as `scripts/<id>.js`.
137
- - At run, the gateway spawns `node <resolved script>` with the structured args delivered in the
138
- environment variable `TOOLFUNNEL_TOOL_ARGS` (JSON-encoded; `null` if no args). The script prints
139
- exactly **one JSON line** to stdout and exits `0`. A non-zero exit is surfaced as the tool's
140
- failure (the runner never rejects on exit code — only on a spawn failure).
141
-
142
- **Shell invoke:**
143
-
144
- ```json
145
- { "type": "shell", "command": "node tools/scripts/echo-upper.js" }
146
- ```
147
-
148
- - A `shell` invoke needs a non-empty `command` or load fails.
149
- - `registry.js` deliberately does **not** spawn a shell. `resolveExecution` returns a deferred
150
- descriptor (`{ deferred:true, type:"shell", command, args }`) and hands it to the gated runner,
151
- which owns the actual shell spawn — so arbitrary shell execution stays behind the PreToolUse gate
152
- and out of the un-gated register module.
153
- - When the gate allows it, the server runs the `command` **verbatim through the OS shell**
154
- (`spawnSync(command, { shell:true })`) with **`cwd` set to the repo root** and
155
- `TOOLFUNNEL_TOOL_ARGS` (the JSON args) injected into the environment. There is **no token
156
- substitution** — the string is passed to the shell as written, so a relative path such as
157
- `tools/scripts/echo-upper.js` resolves against the repo root. Prefer `script` for anything
158
- reusable; reach for `shell` only for thin one-liners.
159
-
160
- > **Isolation rule.** An `invoke` must only reference scripts/commands that live **inside the
161
- > gateway's own tree** (`tools/scripts/`). Never point `invoke` at a tool living outside it — copy
162
- > the logic into `tools/scripts/` first, then point at the clone. The script path-guard enforces
163
- > this for `script` invokes; for `shell` you are responsible for keeping the command inside the
164
- > tree.
165
-
166
- ### Live example (the shipped register)
167
-
168
- The default register ships **seven** demo `script` tools — `echo`, `base64`, `hash`, `uuid`,
169
- `json`, `text-stats`, `danger` — each
170
- `{ "type": "script", "path": "scripts/<id>.js" }`, with the matching file in `tools/scripts/`.
171
-
172
- ---
173
-
174
- ## `tools/tools.state.json` — the enable/disable + hidden overlay
175
-
176
- A small overlay keyed by tool `id`. The register (`tools.register.json`) holds **all** tools; this
177
- file decides which are surfaced to clients and which are decluttered from the manager list. The
178
- shipped default is just `{}` (no overrides — everything on, nothing hidden).
179
-
180
- ```json
181
- {
182
- "danger": { "enabled": false },
183
- "text-stats": { "hidden": true }
184
- }
185
- ```
186
-
187
- | Per-id field | Default | Axis | Effect |
188
- |---|---|---|---|
189
- | `enabled` | **ON** | MCP visibility | A tool is active unless the overlay explicitly says `enabled: false`. Disabled tools are filtered out of `toolfunnel_list_tools` so the client never sees them. |
190
- | `hidden` | not hidden | manager-list only | `hidden: true` declutters the tool from the **manager** listing only. It is **never** consulted by the MCP server and does not change the client's view. |
191
-
192
- Key behaviours (from `tool-state.js`):
193
-
194
- - **Default ON.** A tool absent from the overlay — or present without `enabled: false` — is active.
195
- Newly-added register tools are live until you untick them, and the file only ever lists the tools
196
- you have toggled.
197
- - **The two axes are independent.** A tool can be active-but-hidden or disabled-but-visible.
198
- `enabled` controls what the client sees; `hidden` only declutters the manager view.
199
- - **Toggles merge.** `setToolEnabled` / `setToolHidden` merge into the existing entry, so flipping
200
- one flag preserves the other, then persist atomically.
201
- - **Tolerant load.** A missing or malformed file loads as `{}` (no overrides) and never throws.
202
- - **Read fresh.** The server re-reads this overlay on every `toolfunnel_list_tools` call, so UI
203
- toggles take effect with no restart.
204
-
205
- ---
206
-
207
- ## `mcp/expose.json` — upstream MCPs + curated-direct selection
208
-
209
- Two blocks: the upstream MCP servers to connect to, and which of their tools to surface downstream
210
- as *curated-direct* tools. The shipped default is empty
211
- (`{ "version": 1, "upstreams": [], "expose": [] }`), so the gateway connects to nothing and the
212
- curated-direct surface is empty until you author entries. An annotated sample lives alongside it at
213
- `mcp/expose.example.json`.
214
-
215
- ```json
216
- {
217
- "version": 1,
218
- "upstreams": [
219
- {
220
- "id": "mock",
221
- "transport": "stdio",
222
- "command": "node",
223
- "args": ["mcp/servers/mock-upstream/server.js"],
224
- "env": {},
225
- "enabled": true,
226
- "description": "Bundled demo upstream MCP (zero-dependency stdio): ping, add, echo."
227
- }
228
- ],
229
- "expose": [
230
- {
231
- "upstream": "mock",
232
- "tool": "ping",
233
- "as": "mock_ping",
234
- "category": "demo",
235
- "enabled": true
236
- }
237
- ]
238
- }
239
- ```
240
-
241
- > The demo upstream that ships with ToolFunnel lives at `mcp/servers/mock-upstream/server.js` and
242
- > offers `ping`, `add`, and `echo`. A ready-to-copy version of the sample above is at
243
- > `mcp/expose.example.json`.
244
-
245
- ### `upstreams[]` — one entry per upstream MCP
246
-
247
- | Field | Type | Default | Meaning |
248
- |---|---|---|---|
249
- | `id` | string | — (required) | Stable unique key, referenced by `expose[].upstream`. Duplicate ids are rejected at load. |
250
- | `transport` | string | `"stdio"` | How the aggregator connects. `"stdio"` (spawn `command` + `args`) is the only valid transport today. |
251
- | `command` | string | `""` | The process to spawn for a `stdio` upstream. **Required and non-empty** for `stdio`. The interpreter slot (`node`, `python`, …) is not path-guarded; the `args` path is. |
252
- | `args` | string[] | `[]` | Command arguments. A script path here must resolve **inside the gateway root** (the aggregator's isolation guard rejects an arg path that escapes the tree — a relative path like `mcp/servers/mock-upstream/server.js` is resolved against the repo root and stays inside it). |
253
- | `env` | object | `{}` | Extra environment for the spawned upstream. |
254
- | `enabled` | boolean | `true` | Whether the aggregator connects to this upstream at startup. |
255
- | `description` | string | `""` | Human-facing note for the MCP Manager UI. |
256
-
257
- ### `expose[]` — which upstream tools become curated-direct
258
-
259
- | Field | Type | Default | Meaning |
260
- |---|---|---|---|
261
- | `upstream` | string | — (required) | Must match an existing `upstreams[].id`. Referential integrity is enforced on write — you cannot expose a tool from an unknown upstream (`addExpose` throws). |
262
- | `tool` | string | — (required) | The upstream tool's own name, as its `tools/list` reports it. |
263
- | `as` | string | `<upstream>_<tool>` | The downstream name the client sees — the **rename**. Optional; defaults to the namespaced `<upstream>_<tool>` so two upstreams can't collide. On write the default is materialised into the stored entry, so the saved entry is self-describing. |
264
- | `category` | string | `""` | Grouping label, same convention as register entries. |
265
- | `enabled` | boolean | `true` | Whether the tool is actually exposed downstream right now. |
266
-
267
- Key behaviours (from `expose-store.js`):
268
-
269
- - **Natural key.** `(upstream, tool)` is the key; duplicate pairs are rejected at load.
270
- - **Cascade delete.** Removing an upstream also removes every `expose[]` entry that references it,
271
- so no expose entry can dangle against a missing upstream.
272
- - **Lenient missing file.** A missing `expose.json` loads as an empty store bound to the path; the
273
- file is **not created until the first write** (the server starts clean with no live upstreams). A
274
- malformed file, a duplicate upstream id, or a duplicate expose key throws at load.
275
- - **Reconnect cost.** Unlike register tools, curated-direct tools are negotiated with the client at
276
- connect time — changing the exposed set needs a reconnect (or a `tools/list_changed`
277
- notification if the host honours it). Register tools reached via the meta-tools are always live.
278
-
279
- ---
280
-
281
- ## `hooks/hooks.manifest.json` — the hook manifest (the gate)
282
-
283
- Every tool execution funnels through the hook engine: a `PreToolUse` hook can block a call before
284
- it runs (fails closed), and a `PostToolUse` hook observes the result (advisory — it cannot un-run
285
- the tool). The manifest is the list of those hooks. The shipped default is
286
- `{ "version": 1, "hooks": [] }` — **an empty `hooks` array is allow-all** (no gate fires).
287
-
288
- Top-level shape:
289
-
290
- ```json
291
- {
292
- "version": 1,
293
- "hooksDir": "optional absolute override",
294
- "hooks": [ /* entries */ ]
295
- }
296
- ```
297
-
298
- - `version` — manifest schema version.
299
- - `hooksDir` — optional. The absolute hooks directory. If omitted (the normal case) it is resolved
300
- from the manifest's own location — the manifest sits inside `hooks/`, so **that directory is the
301
- source of truth** and `${HOOKS_DIR}` expands to the absolute path of `hooks/`.
302
- - `hooks` — array of hook entries.
303
-
304
- ### Hook entry shape
305
-
306
- ```json
307
- {
308
- "id": "pre-tool-use/deny-dangerous",
309
- "event": "PreToolUse",
310
- "matcher": "Bash|toolfunnel_run_tool",
311
- "type": "command",
312
- "command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
313
- "script": "scripts/deny-dangerous.sh",
314
- "timeout": 5,
315
- "enabled": true,
316
- "description": "Denies any tool call whose args contain a destructive pattern."
317
- }
318
- ```
319
-
320
- | Field | Type | Meaning |
321
- |---|---|---|
322
- | `id` | string | Unique key, convention `"<kebab-event>/<script-basename>"`. The loader and `hooks.state.json` key on this. |
323
- | `event` | string | One of the six lifecycle events (see below). |
324
- | `matcher` | string | Regex, anchored as a **full match** against `tool_name` (wrapped `^(?:…)$`). `""`, `undefined`, or `"*"` = always fire. Tool-less events ignore it. A malformed regex fails closed (never matches), never throws. Example: `"Bash\|Write\|Edit"`. |
325
- | `type` | string | `"command"` — the only type today. |
326
- | `command` | string | The command line the runner spawns (with `shell: true`). `${HOOKS_DIR}` expands to the absolute `hooks/` path **at load time**. The event payload is written to the command's **stdin** as JSON. |
327
- | `script` | string | The script path relative to `hooksDir` (e.g. `scripts/deny-dangerous.sh`). Used by the manager's open/edit; `writeScript` refuses any path that resolves outside `hooks/scripts/`. Inline-command hooks (no `script`) are allowed — there is just no file to edit. |
328
- | `timeout` | number | **Seconds** (the runner multiplies by 1000). Defaults to **60s** if absent or non-positive. The child is killed past this. |
329
- | `enabled` | boolean | The manifest's **seed** state. The live value is the overlay in `hooks.state.json` — see below. Normalised to a strict boolean at load (an omitted `enabled` is `false`). |
330
- | `description` | string | What the hook does, for the manager UI. |
331
-
332
- One entry **per hook command** — a single settings-style event with N commands becomes N entries.
333
-
334
- ### The six lifecycle events
335
-
336
- `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact` (from
337
- `src/core/events.js`). `PreToolUse` is the only event that can block a tool *before* it runs. The
338
- matcher applies to the tool-bearing events (`PreToolUse` / `PostToolUse`) and is ignored for the
339
- tool-less ones (`SessionStart`, `UserPromptSubmit`, `Stop`, `PreCompact`), which always fire.
340
-
341
- ### How a hook blocks (the two protocols)
342
-
343
- The runner (`src/core/hook-runner.js`) spawns the command, pipes the event JSON to stdin, then
344
- reads the result:
345
-
346
- - **Exit-code protocol:** `exit 2` blocks; **stderr is the reason**. `exit 0` succeeds (for
347
- `SessionStart` / `UserPromptSubmit`, stdout becomes injected context; otherwise advisory). Any
348
- other non-zero code is a non-blocking error.
349
- - **JSON protocol** (honoured only on `exit 0`, when stdout parses as a JSON object): a `PreToolUse`
350
- hook blocks via `hookSpecificOutput.permissionDecision: "deny"` (`"allow"` passes; `"ask"` is
351
- non-blocking in the autonomous host). The other blockable events block via top-level
352
- `decision: "block"`. `continue: false` stops the whole loop;
353
- `hookSpecificOutput.additionalContext` is injected text.
354
-
355
- ### Activation overlay — `hooks/hooks.state.json`
356
-
357
- What *exists* (the `hooks/scripts/` folder + manifest config) is split from what's *on*. The live
358
- enabled/disabled value lives in a sibling overlay keyed by hook `id`, and **the overlay wins over
359
- the manifest's seed `enabled`** at load.
360
-
361
- `setEnabled(id, bool)` writes the overlay atomically as a flat map:
362
-
363
- ```json
364
- {
365
- "pre-tool-use/deny-dangerous": true,
366
- "user-prompt-submit/inject-context": false
367
- }
368
- ```
369
-
370
- The loader also tolerates a wrapped form on read (forward-compatibility):
371
-
372
- ```json
373
- { "version": 1, "enabled": { "pre-tool-use/deny-dangerous": true } }
374
- ```
375
-
376
- Both read the same way; the flat map is what `setEnabled` writes. The overlay is kept separate from
377
- the auto-detected inventory, so re-scanning `hooks/scripts/` never clobbers your toggles, and
378
- toggles survive restarts. (`setEnabled` also mirrors the flag back into the manifest — re-collapsing
379
- `${HOOKS_DIR}` so the on-disk manifest stays portable — but the overlay is the authoritative,
380
- precedence-winning store.)
381
-
382
- ---
383
-
384
- ## Path resolution summary
385
-
386
- Every config and execution target resolves under the repo root. The server anchors the top-level
387
- config paths; the hook loader anchors the hooks paths to the manifest's own directory.
388
-
389
- | Anchor | Path |
390
- |---|---|
391
- | Tool register | `<repo>/tools/tools.register.json` |
392
- | Tool state overlay | `<repo>/tools/tools.state.json` |
393
- | Tool scripts root | `<repo>/tools/scripts/` |
394
- | Expose store | `<repo>/mcp/expose.json` |
395
- | Hook manifest | `<repo>/hooks/hooks.manifest.json` |
396
- | Hook state overlay | `<repo>/hooks/hooks.state.json` |
397
- | Hook scripts root | `<repo>/hooks/scripts/` |
398
- | Log config | `<repo>/logs/log.config.json` |
399
-
400
- Script and command targets are additionally path-guarded to stay inside their respective
401
- `scripts/` directories — a config entry can never become a path-escape out of the tree. Engine code
402
- under `src/` reads all of the above; you never edit `src/` to configure the gateway.