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,152 +1,168 @@
|
|
|
1
|
-
# How to add a tool (self-extension)
|
|
2
|
-
|
|
3
|
-
This is the instruction served by `toolfunnel_howto({ topic: "create-tool" })`. It tells you
|
|
4
|
-
model
|
|
5
|
-
(and listable via `toolfunnel_list_tools` / `toolfunnel_tool_instructions`). No restart is needed: a
|
|
6
|
-
running gateway watches `tools.register.json` and hot-reloads its register on change (debounced
|
|
7
|
-
~150 ms), the same way `mcp/expose.json` and the hook files reload.
|
|
8
|
-
|
|
9
|
-
A tool is exactly two things:
|
|
10
|
-
1. **A register entry** in `tools/tools.register.json` (structured metadata + how to invoke it).
|
|
11
|
-
2. **A script** under `tools/scripts/` that the entry's `invoke` points at (only for
|
|
12
|
-
`invoke.type === "script"`; a `shell` tool needs no file).
|
|
13
|
-
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
## 1. The register entry shape
|
|
17
|
-
|
|
18
|
-
`tools.register.json` is a JSON file with one top-level array of entries. Each entry:
|
|
19
|
-
|
|
20
|
-
```json
|
|
21
|
-
{
|
|
22
|
-
"id": "echo-upper",
|
|
23
|
-
"name": "Echo Upper",
|
|
24
|
-
"summary": "Uppercases the supplied text and echoes it back.",
|
|
25
|
-
"category": "demo",
|
|
26
|
-
"instructions": "Pass { text: string }. Returns the text uppercased. Use this only as a sanity check of the run path.",
|
|
27
|
-
"inputSchema": {
|
|
28
|
-
"type": "object",
|
|
29
|
-
"properties": { "text": { "type": "string", "description": "The text to uppercase." } },
|
|
30
|
-
"required": ["text"],
|
|
31
|
-
"additionalProperties": false
|
|
32
|
-
},
|
|
33
|
-
"invoke": { "type": "script", "path": "scripts/echo-upper.sh" }
|
|
34
|
-
}
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
- `type: "
|
|
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
|
-
|
|
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
|
-
|
|
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 tool (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "create-tool" })`. It tells you - the
|
|
4
|
+
model - how to add a new tool to the register so it becomes callable through `toolfunnel_run_tool`
|
|
5
|
+
(and listable via `toolfunnel_list_tools` / `toolfunnel_tool_instructions`). No restart is needed: a
|
|
6
|
+
running gateway watches `tools.register.json` and hot-reloads its register on change (debounced
|
|
7
|
+
~150 ms), the same way `mcp/expose.json` and the hook files reload.
|
|
8
|
+
|
|
9
|
+
A tool is exactly two things:
|
|
10
|
+
1. **A register entry** in `tools/tools.register.json` (structured metadata + how to invoke it).
|
|
11
|
+
2. **A script** under `tools/scripts/` that the entry's `invoke` points at (only for
|
|
12
|
+
`invoke.type === "script"`; a `shell` tool needs no file).
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. The register entry shape
|
|
17
|
+
|
|
18
|
+
`tools.register.json` is a JSON file with one top-level array of entries. Each entry:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"id": "echo-upper",
|
|
23
|
+
"name": "Echo Upper",
|
|
24
|
+
"summary": "Uppercases the supplied text and echoes it back.",
|
|
25
|
+
"category": "demo",
|
|
26
|
+
"instructions": "Pass { text: string }. Returns the text uppercased. Use this only as a sanity check of the run path.",
|
|
27
|
+
"inputSchema": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": { "text": { "type": "string", "description": "The text to uppercase." } },
|
|
30
|
+
"required": ["text"],
|
|
31
|
+
"additionalProperties": false
|
|
32
|
+
},
|
|
33
|
+
"invoke": { "type": "script", "path": "scripts/echo-upper.sh" }
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A third shape exists - the **reference-mode** tool. It has `"mode": "reference"`, NO `invoke`
|
|
38
|
+
and no script: its `instructions` ARE the tool. Running it hands the instructions back to the
|
|
39
|
+
caller (still through the PreToolUse gate - a deny withholds them) instead of executing anything
|
|
40
|
+
server-side. Use it for run-it-yourself recipes: a CLI invocation the agent should run in its own
|
|
41
|
+
environment, a checklist, a procedure.
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"id": "deploy-runbook",
|
|
46
|
+
"name": "Deploy Runbook",
|
|
47
|
+
"summary": "Step-by-step deploy procedure for this project.",
|
|
48
|
+
"mode": "reference",
|
|
49
|
+
"instructions": "1. npm test - suite must be green. 2. npm run build. 3. ..."
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Field-by-field - these names are fixed (registry.js reads exactly these):
|
|
54
|
+
|
|
55
|
+
| Field | Type | Meaning |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `id` | string | Stable unique key, kebab-case. `toolfunnel_run_tool({ name })` and `registry.run(id, args)` look up by this. Never reuse an existing `id`. |
|
|
58
|
+
| `name` | string | Human/model-facing display name. Shown in the manager UI. |
|
|
59
|
+
| `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool - keep it tight and discriminating so the model can pick the right tool from the brief alone. |
|
|
60
|
+
| `category` | string | Grouping label (e.g. `demo`, `memory`, `messaging`, `media`). Used by `toolfunnel_list_tools({ category })` to filter. |
|
|
61
|
+
| `instructions` | string | Full usage doc returned by `toolfunnel_tool_instructions({ name })`. Describe the args object, what comes back, and any safety caveats. This is the long-tail payload - be complete here, terse in `summary`. |
|
|
62
|
+
| `inputSchema` | object (optional) | A JSON Schema for the tool's args. **This is what makes the tool a first-class MCP citizen when promoted hot:** the top-level `tools/list` advertises it VERBATIM, so a connected client knows the exact arg shape without reading `instructions`. Omit it and a hot-promoted tool falls back to a free-form `{ "type": "object" }` (callable, but the client gets no arg hints). Must be an object - the register rejects any other shape at authoring time. |
|
|
63
|
+
| `invoke` | object | How `run` executes the tool. See below. |
|
|
64
|
+
|
|
65
|
+
### The `invoke` object - two forms
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{ "type": "script", "path": "scripts/echo-upper.sh" }
|
|
69
|
+
```
|
|
70
|
+
- `type: "script"` - run a file under `tools/scripts/`. `path` is **relative to `tools/`**
|
|
71
|
+
and MUST stay inside `tools/scripts/` (registry.js path-guards it, same defense-in-depth as the
|
|
72
|
+
hook loader's `writeScript`). The args object is delivered to the script (e.g. as a JSON blob on
|
|
73
|
+
stdin or argv per the registry's convention) and the script's stdout is the tool output.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{ "type": "shell", "command": "node \"${TOOLS_DIR}/scripts/echo-upper.js\"" }
|
|
77
|
+
```
|
|
78
|
+
- `type: "shell"` - run a command line. `${TOOLS_DIR}` expands to the absolute `tools` path at run
|
|
79
|
+
time (mirrors `${HOOKS_DIR}` in the hook manifest). Prefer `script` for anything reusable; reserve
|
|
80
|
+
`shell` for thin one-liners.
|
|
81
|
+
|
|
82
|
+
> Isolation rule: an `invoke` must only reference scripts/commands that live **inside the gateway's
|
|
83
|
+
> own tree**. Never point `invoke` at a live tool that lives outside it (e.g. in a host's external
|
|
84
|
+
> tool directory). If you want an external tool's behaviour, clone its script into
|
|
85
|
+
> `tools/scripts/` first, then point at the clone.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 2. Where the script goes
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
tools/
|
|
93
|
+
tools.register.json # the entries (above)
|
|
94
|
+
scripts/ # your tool scripts live HERE and nowhere else
|
|
95
|
+
echo-upper.sh
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
(`registry.js` - the engine that loads/persists the register and runs list/instructions/run - is
|
|
99
|
+
NOT user content. It lives in `src/tools/registry.js` and is not part of this tree.)
|
|
100
|
+
|
|
101
|
+
The script reads its arguments (the `args` object passed to `run`) and writes its result to stdout.
|
|
102
|
+
Exit non-zero with a message on stderr to signal failure - the registry surfaces that as the tool's
|
|
103
|
+
error. Keep scripts pure and side-effect-honest: anything destructive is exactly what the PreToolUse
|
|
104
|
+
gate exists to catch (next section).
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 3. How `run` works - and why it is gated
|
|
109
|
+
|
|
110
|
+
`toolfunnel_run_tool({ name, args })` does **not** execute the script directly. It calls
|
|
111
|
+
`registry.run(id, args)`, which routes the call through the gateway's **hook engine** before - and after -
|
|
112
|
+
the tool actually runs:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
toolfunnel_run_tool(name, args)
|
|
116
|
+
│
|
|
117
|
+
▼
|
|
118
|
+
registry.run(id, args)
|
|
119
|
+
│
|
|
120
|
+
├─► hookEngine.fire("PreToolUse", ctx, { tool_name, tool_input: args })
|
|
121
|
+
│ └─ if result.blocked → STOP. Return { ok:false, error: <reason> }. Script never runs.
|
|
122
|
+
│
|
|
123
|
+
├─► execute the invoke (script/shell) only if NOT blocked
|
|
124
|
+
│
|
|
125
|
+
└─► hookEngine.fire("PostToolUse", ctx, { tool_name, tool_input: args, tool_response })
|
|
126
|
+
└─ feedback only; the tool already ran (cannot un-run it)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- **The PreToolUse gate is the whole safety case.** A hook denies a call by returning, on exit 0,
|
|
130
|
+
`{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
131
|
+
"permissionDecisionReason": "..." } }` - OR by `exit 2` with the reason on stderr. (PreToolUse uses
|
|
132
|
+
`permissionDecision`, NOT a top-level `decision` . When that fires,
|
|
133
|
+
`run` returns an error result and the script is never spawned.
|
|
134
|
+
- `permissionDecision: "allow"` passes; `"ask"` is non-blocking in the autonomous host (no
|
|
135
|
+
interactive prompt) - the reason is captured but the tool proceeds.
|
|
136
|
+
- The result shape `run` returns matches the meta-tool contract: `{ ok, output, error? }`.
|
|
137
|
+
|
|
138
|
+
So adding a tool grants real power, but every invocation is mediated by the same gate that protects
|
|
139
|
+
every other tool. If your new tool is dangerous, the correct control is a PreToolUse hook that
|
|
140
|
+
matches its `tool_name` (see `toolfunnel_howto({ topic: "add-hook" })`), not omitting the tool.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 4. Steps to add a tool
|
|
145
|
+
|
|
146
|
+
1. Pick a unique `id` (check `toolfunnel_list_tools` first so you don't collide).
|
|
147
|
+
2. If `invoke.type === "script"`, write the script into `tools/scripts/<id>.sh` (or `.js`).
|
|
148
|
+
3. Add the entry to the `tools.register.json` array (via `registry.js`'s add/edit path so the write
|
|
149
|
+
is atomic - never hand-edit and risk a torn file).
|
|
150
|
+
4. Confirm with `toolfunnel_list_tools` (your `summary` appears) and `toolfunnel_tool_instructions({ name })`
|
|
151
|
+
(your `instructions` appear).
|
|
152
|
+
5. Test with `toolfunnel_run_tool({ name, args })`. Verify the gate behaves: a benign call returns
|
|
153
|
+
`{ ok:true, output }`; if a matching PreToolUse hook should deny it, confirm it does.
|
|
154
|
+
|
|
155
|
+
No reconnect is needed - the register is dynamic: the running gateway hot-reloads
|
|
156
|
+
`tools.register.json` on change and emits `notifications/tools/list_changed`, exactly like an
|
|
157
|
+
`expose.json` or hook edit.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 5. Promoting your tool to the top-level surface (the "instant MCP" recipe)
|
|
162
|
+
|
|
163
|
+
By default your tool lives behind the lean meta-tools (discovered via `toolfunnel_list_tools`).
|
|
164
|
+
Set `hot: true` for its id in `tools/tools.state.json` (or via the UI / `tf_tool_set`) and it is
|
|
165
|
+
advertised DIRECTLY in the MCP `tools/list` - under its own `id`, with your `summary` as the
|
|
166
|
+
description and your `inputSchema` as the advertised schema. Author a real `inputSchema` before
|
|
167
|
+
promoting: that schema is the difference between a client calling your tool correctly first time
|
|
168
|
+
and it guessing at a free-form object.
|
package/src/extend/howto.js
CHANGED
|
@@ -1,98 +1,102 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* howto.js
|
|
5
|
-
*
|
|
6
|
-
* `toolfunnel_howto({ topic })` returns the self-extension instructions for one topic. This module
|
|
7
|
-
* resolves a topic to its authored markdown file (the topic -> file map below) and reads it.
|
|
8
|
-
*
|
|
9
|
-
* howto(topic) -> string // the markdown content for that topic
|
|
10
|
-
*
|
|
11
|
-
* Topics
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* howto.js - the loader behind the `toolfunnel_howto` meta-tool.
|
|
5
|
+
*
|
|
6
|
+
* `toolfunnel_howto({ topic })` returns the self-extension instructions for one topic. This module
|
|
7
|
+
* resolves a topic to its authored markdown file (the topic -> file map below) and reads it.
|
|
8
|
+
*
|
|
9
|
+
* howto(topic) -> string // the markdown content for that topic
|
|
10
|
+
*
|
|
11
|
+
* Topics: create-tool | add-mcp | add-hook | package | wrap | configure
|
|
12
|
+
* (the original four topics, plus the 0.6.0 pair - the transparent wrap and the no-code
|
|
13
|
+
* config map - so a primer-less agent can learn the headline feature from inside).
|
|
14
|
+
* Unknown topics THROW (the protocol layer turns that into a clean tool error, not a crash).
|
|
15
|
+
*
|
|
16
|
+
* Isolation: reads are confined to THIS directory (src/extend). The topic map is a fixed
|
|
17
|
+
* allow-list of basenames - there is no path interpolation from the caller, so an untrusted
|
|
18
|
+
* `topic` cannot traverse out of src/extend. We additionally re-verify the resolved path is
|
|
19
|
+
* inside this directory (defense-in-depth, mirroring the hook loader's writeScript guard).
|
|
20
|
+
*
|
|
21
|
+
* CommonJS only. Node built-ins only. No transport, pure file read - unit-testable directly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
// The directory these instruction files live in (this module's own directory).
|
|
28
|
+
const EXTEND_DIR = __dirname;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fixed topic -> filename map. The set of keys IS the list of valid topics; the values are
|
|
32
|
+
* plain basenames (no separators) so nothing the caller passes is ever joined into a path.
|
|
33
|
+
* @type {Readonly<Record<string,string>>}
|
|
34
|
+
*/
|
|
35
|
+
const TOPIC_FILES = Object.freeze({
|
|
36
|
+
'create-tool': 'create-tool.md',
|
|
37
|
+
'add-mcp': 'add-mcp.md',
|
|
38
|
+
'add-hook': 'add-hook.md',
|
|
39
|
+
package: 'package.md',
|
|
40
|
+
wrap: 'wrap.md',
|
|
41
|
+
configure: 'configure.md',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The valid topic names, for error messages and for callers that want to enumerate
|
|
46
|
+
* (e.g. the protocol layer validating args, or a UI listing the howto topics).
|
|
47
|
+
* @returns {string[]}
|
|
48
|
+
*/
|
|
49
|
+
function topics() {
|
|
50
|
+
return Object.keys(TOPIC_FILES);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a topic to the instruction markdown content.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} topic one of: create-tool | add-mcp | add-hook | package | wrap | configure
|
|
57
|
+
* @returns {string} the UTF-8 markdown content of the matching file
|
|
58
|
+
* @throws {Error} if `topic` is not a string, is unknown, or (defensively) resolves outside
|
|
59
|
+
* src/extend or the file is missing.
|
|
60
|
+
*/
|
|
61
|
+
function howto(topic) {
|
|
62
|
+
if (typeof topic !== 'string' || topic.length === 0) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`howto: topic must be a non-empty string. Known topics: ${topics().join(', ')}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Look up against the fixed allow-list - own property only (guards against "constructor",
|
|
69
|
+
// "__proto__", etc. resolving via the prototype chain).
|
|
70
|
+
if (!Object.prototype.hasOwnProperty.call(TOPIC_FILES, topic)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`howto: unknown topic "${topic}". Known topics: ${topics().join(', ')}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fileName = TOPIC_FILES[topic];
|
|
77
|
+
const filePath = path.join(EXTEND_DIR, fileName);
|
|
78
|
+
|
|
79
|
+
// Defense-in-depth: confirm the resolved path is still inside EXTEND_DIR. With a fixed
|
|
80
|
+
// basename map this can never fail, but the guard documents and enforces the isolation rule.
|
|
81
|
+
const rel = path.relative(EXTEND_DIR, filePath);
|
|
82
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
83
|
+
throw new Error(`howto: refusing to read outside src/extend ("${fileName}")`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// A known topic whose file is missing is a packaging error, not an unknown-topic error.
|
|
90
|
+
throw new Error(
|
|
91
|
+
`howto: instruction file for topic "${topic}" is missing or unreadable ` +
|
|
92
|
+
`(${filePath}): ${(err && err.message) || String(err)}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
howto,
|
|
99
|
+
topics,
|
|
100
|
+
TOPIC_FILES,
|
|
101
|
+
EXTEND_DIR,
|
|
102
|
+
};
|