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/howto.js
CHANGED
|
@@ -1,102 +1,102 @@
|
|
|
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
|
-
};
|
|
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
|
+
};
|
package/src/extend/package.md
CHANGED
|
@@ -1,190 +1,190 @@
|
|
|
1
|
-
# How to package and share units (self-extension)
|
|
2
|
-
|
|
3
|
-
This is the instruction served by `toolfunnel_howto({ topic: "package" })`. It explains how to bundle
|
|
4
|
-
tools, hooks, and MCP configs into a **shareable package** and how import/export work. Packaging is a
|
|
5
|
-
**byproduct** of the gateway design: because every unit is already structured and
|
|
6
|
-
self-documented, a package is just a folder that lists its units and carries their files.
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## 0. The WHOLE-SETUP path - `tf_pack` (start here)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
config home (tools + scripts, hot/hidden curation, upstream references, policy hooks, identity) is
|
|
14
|
-
already the product. One call packages it into a deployment-ready artifact under `<home>/dist/`:
|
|
15
|
-
|
|
16
|
-
- `tf_pack { format: "home" }` → a **portable config home**. Zip it, git-init it, or run it
|
|
17
|
-
directly: `toolfunnel --config-dir <dir>`. The receiving gateway seeds nothing - the pack IS the
|
|
18
|
-
config, including the hooks: **the gate travels**, enforcing your policy on the recipient's
|
|
19
|
-
machine regardless of which MCP client they use.
|
|
20
|
-
- `tf_pack { format: "npm", name: "my-mcp" }` → a **publishable npm package**: a generated
|
|
21
|
-
`package.json` that depends on `toolfunnel` (caret range - DEPEND, never copy the engine), a
|
|
22
|
-
2-line bin launcher pointing `--config-dir` at the bundled home, and a README stub.
|
|
23
|
-
`cd dist/my-mcp && npm publish` → your users run `npx my-mcp` and get YOUR server, under YOUR
|
|
24
|
-
name (the bundled `toolfunnel.json` identity), with YOUR gate.
|
|
25
|
-
|
|
26
|
-
Declare the runtimes your tools need in the home's `toolfunnel.json` - they travel with the pack
|
|
27
|
-
and the receiving gateway warns at startup about anything missing:
|
|
28
|
-
|
|
29
|
-
```json
|
|
30
|
-
"requires": [ { "command": "python", "min": "3.10", "why": "the pdf tools" } ]
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Audit honesty (both directions): packs spawn commands. Tell your users to read your pack's
|
|
34
|
-
`expose.json`, `tools.register.json`, AND `toolfunnel.json` (its `requires` probes execute at
|
|
35
|
-
gateway startup - bare program names only, the loader rejects anything shell-shaped) - and read
|
|
36
|
-
all three yourself in anything you install.
|
|
37
|
-
|
|
38
|
-
The per-UNIT packaging below remains the right shape for sharing a single tool/hook/MCP selection
|
|
39
|
-
rather than a whole gateway.
|
|
40
|
-
|
|
41
|
-
The per-unit package is a CONVENTION, not machinery: export and import are the structured
|
|
42
|
-
hand-steps below (each one maps onto a management function), so a package needs no importer
|
|
43
|
-
binary - and the whole-setup `tf_pack` above covers the common case with one call.
|
|
44
|
-
|
|
45
|
-
---
|
|
46
|
-
|
|
47
|
-
## 1. What a unit is (and what travels with it)
|
|
48
|
-
|
|
49
|
-
Each unit is self-contained - this is the whole reason packaging is cheap:
|
|
50
|
-
|
|
51
|
-
| Unit | Its metadata | Its files |
|
|
52
|
-
|---|---|---|
|
|
53
|
-
| **tool** | one `tools.register.json` entry (`id/name/summary/category/instructions/invoke`) | the script under `tools/scripts/` (for `invoke.type === "script"`) |
|
|
54
|
-
| **hook** | one `hooks.manifest.json` entry (`id/event/matcher/command/script/timeout/description`) + its `hooks.state.json` enabled value | the script under `hooks/scripts/` |
|
|
55
|
-
| **mcp** | one `expose.json` `upstreams[]` entry + its `expose[]` selections | any vendored server files it spawns (must live inside the package) |
|
|
56
|
-
|
|
57
|
-
A package gathers one or more of these, plus the files they reference.
|
|
58
|
-
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
## 2. Package layout
|
|
62
|
-
|
|
63
|
-
A package is a folder (conventionally `packages/<name>/` inside the config home) containing a
|
|
64
|
-
manifest and the units' files in a mirrored tree so import is a structured copy, not guesswork:
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
packages/
|
|
68
|
-
notify-pack/
|
|
69
|
-
package.json # the package manifest (below)
|
|
70
|
-
tools/
|
|
71
|
-
scripts/
|
|
72
|
-
msg-digest.sh
|
|
73
|
-
hooks/
|
|
74
|
-
scripts/
|
|
75
|
-
rate-limit.sh
|
|
76
|
-
mcp/
|
|
77
|
-
vendor/ # any vendored upstream server files
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### `package.json` - the package manifest
|
|
81
|
-
|
|
82
|
-
A `package.json`-style manifest listing the package's identity and its units (the metadata blocks are
|
|
83
|
-
embedded so import is self-sufficient - it does not need the source register/manifest to reconstruct
|
|
84
|
-
entries):
|
|
85
|
-
|
|
86
|
-
```json
|
|
87
|
-
{
|
|
88
|
-
"name": "notify-pack",
|
|
89
|
-
"version": "1.0.0",
|
|
90
|
-
"description": "Message digest tool + rate-limit gate + upstream MCP exposure.",
|
|
91
|
-
"author": "unknown",
|
|
92
|
-
"units": {
|
|
93
|
-
"tools": [
|
|
94
|
-
{
|
|
95
|
-
"entry": {
|
|
96
|
-
"id": "msg-digest",
|
|
97
|
-
"name": "Message Digest",
|
|
98
|
-
"summary": "Summarises unread chats.",
|
|
99
|
-
"category": "messaging",
|
|
100
|
-
"instructions": "Pass { since? }. Returns a per-chat unread digest.",
|
|
101
|
-
"invoke": { "type": "script", "path": "scripts/msg-digest.sh" }
|
|
102
|
-
},
|
|
103
|
-
"files": ["tools/scripts/msg-digest.sh"]
|
|
104
|
-
}
|
|
105
|
-
],
|
|
106
|
-
"hooks": [
|
|
107
|
-
{
|
|
108
|
-
"entry": {
|
|
109
|
-
"id": "pre-tool-use/rate-limit",
|
|
110
|
-
"event": "PreToolUse",
|
|
111
|
-
"matcher": "msg_send_message",
|
|
112
|
-
"type": "command",
|
|
113
|
-
"command": "bash \"${HOOKS_DIR}/scripts/rate-limit.sh\"",
|
|
114
|
-
"script": "scripts/rate-limit.sh",
|
|
115
|
-
"timeout": 5,
|
|
116
|
-
"description": "Denies sends past a per-minute cap."
|
|
117
|
-
},
|
|
118
|
-
"enabled": true,
|
|
119
|
-
"files": ["hooks/scripts/rate-limit.sh"]
|
|
120
|
-
}
|
|
121
|
-
],
|
|
122
|
-
"mcp": [
|
|
123
|
-
{
|
|
124
|
-
"upstream": {
|
|
125
|
-
"id": "notify",
|
|
126
|
-
"transport": "stdio",
|
|
127
|
-
"command": "node",
|
|
128
|
-
"args": ["mcp/vendor/notify-mcp/server.js"],
|
|
129
|
-
"enabled": true,
|
|
130
|
-
"description": "Messaging bridge MCP."
|
|
131
|
-
},
|
|
132
|
-
"expose": [
|
|
133
|
-
{ "upstream": "notify", "tool": "list_chats", "as": "msg_list_chats", "category": "messaging", "enabled": true },
|
|
134
|
-
{ "upstream": "notify", "tool": "send_message", "as": "msg_send_message", "category": "messaging", "enabled": true }
|
|
135
|
-
],
|
|
136
|
-
"files": ["mcp/vendor/notify-mcp/server.js"]
|
|
137
|
-
}
|
|
138
|
-
]
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
| Field | Meaning |
|
|
144
|
-
|---|---|
|
|
145
|
-
| `name` / `version` / `description` / `author` | Package identity. `name` is the folder name and the import key. |
|
|
146
|
-
| `units.tools[]` | Each has the full register `entry` plus `files` (paths inside the package). |
|
|
147
|
-
| `units.hooks[]` | Each has the full manifest `entry`, its seed `enabled` (for `hooks.state.json`), and `files`. |
|
|
148
|
-
| `units.mcp[]` | Each has an `upstream` block, its `expose[]` selections, and any vendored `files`. |
|
|
149
|
-
|
|
150
|
-
Write vendored-server paths RELATIVE and set the upstream's `cwd` to the installed package
|
|
151
|
-
folder (or write the absolute inside-home path at import time) - the isolation guard requires
|
|
152
|
-
them inside the config home either way.
|
|
153
|
-
|
|
154
|
-
---
|
|
155
|
-
|
|
156
|
-
## 3. Export - bundling a unit (or several)
|
|
157
|
-
|
|
158
|
-
To export, gather a unit's metadata and its files into a package folder:
|
|
159
|
-
|
|
160
|
-
1. Create `packages/<name>/` with a `package.json` manifest.
|
|
161
|
-
2. For each tool: copy its `tools.register.json` entry into `units.tools[].entry`, copy its
|
|
162
|
-
`tools/scripts/<file>` into the package's `tools/scripts/`, and list it in `files`.
|
|
163
|
-
3. For each hook: copy its `hooks.manifest.json` entry into `units.hooks[].entry`, copy its enabled
|
|
164
|
-
value from `hooks.state.json` into `enabled`, copy the script into `hooks/scripts/`, list it.
|
|
165
|
-
4. For each MCP: copy the `upstreams[]` block + matching `expose[]` selections, and vendor any server
|
|
166
|
-
files into `mcp/vendor/`.
|
|
167
|
-
|
|
168
|
-
The package is then a single folder, self-describing and copy-portable. (Everything stays inside
|
|
169
|
-
`packages/` - the isolation rule still holds.)
|
|
170
|
-
|
|
171
|
-
---
|
|
172
|
-
|
|
173
|
-
## 4. Import - installing a package
|
|
174
|
-
|
|
175
|
-
Import is the reverse: **drop a package folder in `packages/` + register its units.**
|
|
176
|
-
|
|
177
|
-
1. Read `packages/<name>/package.json`.
|
|
178
|
-
2. **Tools:** copy each `units.tools[].files` into `tools/scripts/` and add each `entry` to
|
|
179
|
-
`tools.register.json` (atomic write via `registry.js`). Reject id collisions or prompt to rename.
|
|
180
|
-
3. **Hooks:** copy each `units.hooks[].files` into `hooks/scripts/` and add each `entry` to
|
|
181
|
-
`hooks.manifest.json`; write its `enabled` into `hooks.state.json`.
|
|
182
|
-
4. **MCP:** copy vendored `files` into the package's `mcp/vendor/`, add each `upstream` to
|
|
183
|
-
`expose.json` `upstreams[]` and its `expose[]` selections, rewriting the vendored paths for
|
|
184
|
-
their installed location (inside the config home - the isolation rule).
|
|
185
|
-
|
|
186
|
-
After import: tools are live immediately (the register is read fresh on every `toolfunnel_list_tools`).
|
|
187
|
-
Hooks take effect once the loader re-scans (next startup, or a reload). Curated-direct MCP tools need
|
|
188
|
-
a reconnect to reach the CLI surface (register-routed tools do not). Verify with `toolfunnel_list_tools`
|
|
189
|
-
and a `run`/run-once test that the gate still fires on the imported units - the PreToolUse gate
|
|
190
|
-
applies to imported tools exactly as it does to local ones.
|
|
1
|
+
# How to package and share units (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "package" })`. It explains how to bundle
|
|
4
|
+
tools, hooks, and MCP configs into a **shareable package** and how import/export work. Packaging is a
|
|
5
|
+
**byproduct** of the gateway design: because every unit is already structured and
|
|
6
|
+
self-documented, a package is just a folder that lists its units and carries their files.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 0. The WHOLE-SETUP path - `tf_pack` (start here)
|
|
11
|
+
|
|
12
|
+
The fastest way to ship is not per-unit packaging but the **whole setup**: your entire
|
|
13
|
+
config home (tools + scripts, hot/hidden curation, upstream references, policy hooks, identity) is
|
|
14
|
+
already the product. One call packages it into a deployment-ready artifact under `<home>/dist/`:
|
|
15
|
+
|
|
16
|
+
- `tf_pack { format: "home" }` → a **portable config home**. Zip it, git-init it, or run it
|
|
17
|
+
directly: `toolfunnel --config-dir <dir>`. The receiving gateway seeds nothing - the pack IS the
|
|
18
|
+
config, including the hooks: **the gate travels**, enforcing your policy on the recipient's
|
|
19
|
+
machine regardless of which MCP client they use.
|
|
20
|
+
- `tf_pack { format: "npm", name: "my-mcp" }` → a **publishable npm package**: a generated
|
|
21
|
+
`package.json` that depends on `toolfunnel` (caret range - DEPEND, never copy the engine), a
|
|
22
|
+
2-line bin launcher pointing `--config-dir` at the bundled home, and a README stub.
|
|
23
|
+
`cd dist/my-mcp && npm publish` → your users run `npx my-mcp` and get YOUR server, under YOUR
|
|
24
|
+
name (the bundled `toolfunnel.json` identity), with YOUR gate.
|
|
25
|
+
|
|
26
|
+
Declare the runtimes your tools need in the home's `toolfunnel.json` - they travel with the pack
|
|
27
|
+
and the receiving gateway warns at startup about anything missing:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
"requires": [ { "command": "python", "min": "3.10", "why": "the pdf tools" } ]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Audit honesty (both directions): packs spawn commands. Tell your users to read your pack's
|
|
34
|
+
`expose.json`, `tools.register.json`, AND `toolfunnel.json` (its `requires` probes execute at
|
|
35
|
+
gateway startup - bare program names only, the loader rejects anything shell-shaped) - and read
|
|
36
|
+
all three yourself in anything you install.
|
|
37
|
+
|
|
38
|
+
The per-UNIT packaging below remains the right shape for sharing a single tool/hook/MCP selection
|
|
39
|
+
rather than a whole gateway.
|
|
40
|
+
|
|
41
|
+
The per-unit package is a CONVENTION, not machinery: export and import are the structured
|
|
42
|
+
hand-steps below (each one maps onto a management function), so a package needs no importer
|
|
43
|
+
binary - and the whole-setup `tf_pack` above covers the common case with one call.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 1. What a unit is (and what travels with it)
|
|
48
|
+
|
|
49
|
+
Each unit is self-contained - this is the whole reason packaging is cheap:
|
|
50
|
+
|
|
51
|
+
| Unit | Its metadata | Its files |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| **tool** | one `tools.register.json` entry (`id/name/summary/category/instructions/invoke`) | the script under `tools/scripts/` (for `invoke.type === "script"`) |
|
|
54
|
+
| **hook** | one `hooks.manifest.json` entry (`id/event/matcher/command/script/timeout/description`) + its `hooks.state.json` enabled value | the script under `hooks/scripts/` |
|
|
55
|
+
| **mcp** | one `expose.json` `upstreams[]` entry + its `expose[]` selections | any vendored server files it spawns (must live inside the package) |
|
|
56
|
+
|
|
57
|
+
A package gathers one or more of these, plus the files they reference.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 2. Package layout
|
|
62
|
+
|
|
63
|
+
A package is a folder (conventionally `packages/<name>/` inside the config home) containing a
|
|
64
|
+
manifest and the units' files in a mirrored tree so import is a structured copy, not guesswork:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
packages/
|
|
68
|
+
notify-pack/
|
|
69
|
+
package.json # the package manifest (below)
|
|
70
|
+
tools/
|
|
71
|
+
scripts/
|
|
72
|
+
msg-digest.sh
|
|
73
|
+
hooks/
|
|
74
|
+
scripts/
|
|
75
|
+
rate-limit.sh
|
|
76
|
+
mcp/
|
|
77
|
+
vendor/ # any vendored upstream server files
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `package.json` - the package manifest
|
|
81
|
+
|
|
82
|
+
A `package.json`-style manifest listing the package's identity and its units (the metadata blocks are
|
|
83
|
+
embedded so import is self-sufficient - it does not need the source register/manifest to reconstruct
|
|
84
|
+
entries):
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"name": "notify-pack",
|
|
89
|
+
"version": "1.0.0",
|
|
90
|
+
"description": "Message digest tool + rate-limit gate + upstream MCP exposure.",
|
|
91
|
+
"author": "unknown",
|
|
92
|
+
"units": {
|
|
93
|
+
"tools": [
|
|
94
|
+
{
|
|
95
|
+
"entry": {
|
|
96
|
+
"id": "msg-digest",
|
|
97
|
+
"name": "Message Digest",
|
|
98
|
+
"summary": "Summarises unread chats.",
|
|
99
|
+
"category": "messaging",
|
|
100
|
+
"instructions": "Pass { since? }. Returns a per-chat unread digest.",
|
|
101
|
+
"invoke": { "type": "script", "path": "scripts/msg-digest.sh" }
|
|
102
|
+
},
|
|
103
|
+
"files": ["tools/scripts/msg-digest.sh"]
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
"hooks": [
|
|
107
|
+
{
|
|
108
|
+
"entry": {
|
|
109
|
+
"id": "pre-tool-use/rate-limit",
|
|
110
|
+
"event": "PreToolUse",
|
|
111
|
+
"matcher": "msg_send_message",
|
|
112
|
+
"type": "command",
|
|
113
|
+
"command": "bash \"${HOOKS_DIR}/scripts/rate-limit.sh\"",
|
|
114
|
+
"script": "scripts/rate-limit.sh",
|
|
115
|
+
"timeout": 5,
|
|
116
|
+
"description": "Denies sends past a per-minute cap."
|
|
117
|
+
},
|
|
118
|
+
"enabled": true,
|
|
119
|
+
"files": ["hooks/scripts/rate-limit.sh"]
|
|
120
|
+
}
|
|
121
|
+
],
|
|
122
|
+
"mcp": [
|
|
123
|
+
{
|
|
124
|
+
"upstream": {
|
|
125
|
+
"id": "notify",
|
|
126
|
+
"transport": "stdio",
|
|
127
|
+
"command": "node",
|
|
128
|
+
"args": ["mcp/vendor/notify-mcp/server.js"],
|
|
129
|
+
"enabled": true,
|
|
130
|
+
"description": "Messaging bridge MCP."
|
|
131
|
+
},
|
|
132
|
+
"expose": [
|
|
133
|
+
{ "upstream": "notify", "tool": "list_chats", "as": "msg_list_chats", "category": "messaging", "enabled": true },
|
|
134
|
+
{ "upstream": "notify", "tool": "send_message", "as": "msg_send_message", "category": "messaging", "enabled": true }
|
|
135
|
+
],
|
|
136
|
+
"files": ["mcp/vendor/notify-mcp/server.js"]
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
| Field | Meaning |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `name` / `version` / `description` / `author` | Package identity. `name` is the folder name and the import key. |
|
|
146
|
+
| `units.tools[]` | Each has the full register `entry` plus `files` (paths inside the package). |
|
|
147
|
+
| `units.hooks[]` | Each has the full manifest `entry`, its seed `enabled` (for `hooks.state.json`), and `files`. |
|
|
148
|
+
| `units.mcp[]` | Each has an `upstream` block, its `expose[]` selections, and any vendored `files`. |
|
|
149
|
+
|
|
150
|
+
Write vendored-server paths RELATIVE and set the upstream's `cwd` to the installed package
|
|
151
|
+
folder (or write the absolute inside-home path at import time) - the isolation guard requires
|
|
152
|
+
them inside the config home either way.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 3. Export - bundling a unit (or several)
|
|
157
|
+
|
|
158
|
+
To export, gather a unit's metadata and its files into a package folder:
|
|
159
|
+
|
|
160
|
+
1. Create `packages/<name>/` with a `package.json` manifest.
|
|
161
|
+
2. For each tool: copy its `tools.register.json` entry into `units.tools[].entry`, copy its
|
|
162
|
+
`tools/scripts/<file>` into the package's `tools/scripts/`, and list it in `files`.
|
|
163
|
+
3. For each hook: copy its `hooks.manifest.json` entry into `units.hooks[].entry`, copy its enabled
|
|
164
|
+
value from `hooks.state.json` into `enabled`, copy the script into `hooks/scripts/`, list it.
|
|
165
|
+
4. For each MCP: copy the `upstreams[]` block + matching `expose[]` selections, and vendor any server
|
|
166
|
+
files into `mcp/vendor/`.
|
|
167
|
+
|
|
168
|
+
The package is then a single folder, self-describing and copy-portable. (Everything stays inside
|
|
169
|
+
`packages/` - the isolation rule still holds.)
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 4. Import - installing a package
|
|
174
|
+
|
|
175
|
+
Import is the reverse: **drop a package folder in `packages/` + register its units.**
|
|
176
|
+
|
|
177
|
+
1. Read `packages/<name>/package.json`.
|
|
178
|
+
2. **Tools:** copy each `units.tools[].files` into `tools/scripts/` and add each `entry` to
|
|
179
|
+
`tools.register.json` (atomic write via `registry.js`). Reject id collisions or prompt to rename.
|
|
180
|
+
3. **Hooks:** copy each `units.hooks[].files` into `hooks/scripts/` and add each `entry` to
|
|
181
|
+
`hooks.manifest.json`; write its `enabled` into `hooks.state.json`.
|
|
182
|
+
4. **MCP:** copy vendored `files` into the package's `mcp/vendor/`, add each `upstream` to
|
|
183
|
+
`expose.json` `upstreams[]` and its `expose[]` selections, rewriting the vendored paths for
|
|
184
|
+
their installed location (inside the config home - the isolation rule).
|
|
185
|
+
|
|
186
|
+
After import: tools are live immediately (the register is read fresh on every `toolfunnel_list_tools`).
|
|
187
|
+
Hooks take effect once the loader re-scans (next startup, or a reload). Curated-direct MCP tools need
|
|
188
|
+
a reconnect to reach the CLI surface (register-routed tools do not). Verify with `toolfunnel_list_tools`
|
|
189
|
+
and a `run`/run-once test that the gate still fires on the imported units - the PreToolUse gate
|
|
190
|
+
applies to imported tools exactly as it does to local ones.
|