toolfunnel 0.5.0 → 0.6.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 +231 -167
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -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 +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -1012
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1405
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -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 +112 -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 +227 -216
package/docs/KNOWN_BUGS.md
CHANGED
|
@@ -1,33 +1,99 @@
|
|
|
1
|
-
# Known issues & roadmap
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
##
|
|
7
|
-
|
|
8
|
-
The 2026-07-28 MCP revision is a **breaking** change to the protocol: it removes the `initialize`
|
|
9
|
-
handshake, protocol-level sessions, and the standalone GET/SSE endpoint, and adds new required
|
|
10
|
-
behaviours
|
|
11
|
-
`server/discover` endpoint, a `subscriptions/listen` change-notification stream, and
|
|
12
|
-
`resultType` / `ttlMs` / `cacheScope` result fields.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- **
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
# Known issues & roadmap
|
|
2
|
+
|
|
3
|
+
This file tracks the state of the 0.6.0 dual-era work, the deliberately-deferred items, and any
|
|
4
|
+
limitations worth knowing about.
|
|
5
|
+
|
|
6
|
+
## 0.6.0 - the 2026-07-28 MCP specification
|
|
7
|
+
|
|
8
|
+
The 2026-07-28 MCP revision is a **breaking** change to the protocol: it removes the `initialize`
|
|
9
|
+
handshake, protocol-level sessions, and the standalone GET/SSE endpoint, and adds new required
|
|
10
|
+
behaviours - per-request protocol metadata, `Mcp-Method` / `Mcp-Name` request headers, a
|
|
11
|
+
`server/discover` endpoint, a `subscriptions/listen` change-notification stream, and
|
|
12
|
+
`resultType` / `ttlMs` / `cacheScope` result fields.
|
|
13
|
+
|
|
14
|
+
Implemented in 0.6.0 (built against the RC spec; a reconciliation pass against the finalised
|
|
15
|
+
spec lands after 28 July):
|
|
16
|
+
|
|
17
|
+
- **Dual-era transport** (2024-11-05 + 2026-07-28) - old and new clients both work.
|
|
18
|
+
- `server/discover`, request-header validation, and the `subscriptions/listen` stream.
|
|
19
|
+
- A **dual-era client**: ToolFunnel speaks modern to modern upstream servers (stdio).
|
|
20
|
+
- An **opt-in, per-upstream legacy-protocol shim** (`legacyPin`) - off by default, names the exact
|
|
21
|
+
version it drops to, warns at startup and on every call, and is enforced (skips the era probe).
|
|
22
|
+
- **Passthrough wrap** (`toolfunnel wrap <upstreamId>`): one command turns the gateway into a
|
|
23
|
+
transparent, both-era wrapper for one upstream MCP - verbatim identity, tool definitions,
|
|
24
|
+
results, errors, notifications, and per-URI resource subscriptions (forwarded, and replayed
|
|
25
|
+
across silent upstream reconnects), with every call still passing the PreToolUse gate.
|
|
26
|
+
- **Elicitation bridging**: a wrapped legacy server's mid-call questions are translated into the
|
|
27
|
+
modern `input_required` + retry pattern and back - multi-round, single-use tokens, TTL'd -
|
|
28
|
+
tested end-to-end against real published elicitation servers.
|
|
29
|
+
- **Cancel translation** (stdio): a client's cancel reaches the upstream in the upstream's own
|
|
30
|
+
request-id space, for forwarded methods and wrapped tool calls alike.
|
|
31
|
+
- **Identity settings + wrap mirroring**: `toolfunnel.json` gains `clientName`/`clientVersion`
|
|
32
|
+
(the identity upstreams see); under a wrap on stdio the real downstream client's identity is
|
|
33
|
+
mirrored upstream automatically.
|
|
34
|
+
- **UI**: a Settings tab (identity + ports), a per-upstream legacy-pin toggle, and the wrap
|
|
35
|
+
security notice surfaced on the UI wrap path (parity with the CLI).
|
|
36
|
+
- **Agent-facing docs**: `toolfunnel_howto` gains `wrap` and `configure` topics, so a plain agent
|
|
37
|
+
can learn the wrap and the whole no-code config map from inside the protocol.
|
|
38
|
+
- **Method-class timeouts**: tool calls (`tools/call`, `prompts/get`, `resources/read`) wait
|
|
39
|
+
120 s by default - configurable per upstream via `"timeoutMs"` - and a progress report from
|
|
40
|
+
the tool re-arms the window, so a slow-but-alive tool never dies to the clock. The 10 s
|
|
41
|
+
handshake/list window stays fixed as the dead-upstream detector.
|
|
42
|
+
- **Async shell execution**: `shell`-invoke register tools no longer block the event loop
|
|
43
|
+
(was `spawnSync` since 0.5.0) - concurrent HTTP clients keep being served during a long
|
|
44
|
+
shell tool.
|
|
45
|
+
- **Config-home visibility**: every start prints the resolved config home to stderr, with a
|
|
46
|
+
relocation hint when it defaulted to the package root - running from a git clone no longer
|
|
47
|
+
writes config into the repo silently.
|
|
48
|
+
- **Bounded listen streams**: the HTTP transport caps concurrent `subscriptions/listen`
|
|
49
|
+
streams (64) and refuses new ones past the cap with a clear error; existing streams are
|
|
50
|
+
never evicted.
|
|
51
|
+
|
|
52
|
+
## Deliberately deferred (documented dispositions, target: the 28-July reconciliation pass)
|
|
53
|
+
|
|
54
|
+
- **Legacy version family**: ToolFunnel currently speaks the oldest legacy dialect (`2024-11-05`)
|
|
55
|
+
to legacy upstreams and clamps a wrapped handshake to what the upstream negotiated. A client and
|
|
56
|
+
upstream that both speak a newer legacy revision (e.g. `2025-06-18`) are negotiated down.
|
|
57
|
+
Fidelity of the *fields* is unaffected (verified against real servers - nothing version-gated is
|
|
58
|
+
dropped), but the advertised version string is older than either end requires. DECIDED
|
|
59
|
+
(2026-07-17): best-version negotiation in BOTH directions - newest each peer speaks, modern
|
|
60
|
+
first, legacy fallback. Lands with the reconciliation pass, sequenced after the elicitation
|
|
61
|
+
bridge (a newer legacy offer invites server-initiated requests the client must handle first).
|
|
62
|
+
- **Modern-upstream subscriptions**: the client does not yet open a `subscriptions/listen` stream
|
|
63
|
+
to modern upstreams, so their change-notifications are not received; the listen ack honestly
|
|
64
|
+
refuses `resourceSubscriptions` when no legacy subscribe-capable upstream is in scope.
|
|
65
|
+
- **Elicitation bridge scope**: a wrapped upstream's mid-call `elicitation/create` bridges to
|
|
66
|
+
modern clients as MRTR (`input_required` + retry - built and wire-tested). A LEGACY client
|
|
67
|
+
gets an automatic decline instead (relaying backwards requests to legacy clients is future
|
|
68
|
+
work), and `sampling/createMessage` / `roots/list` from upstreams are answered -32601 for now.
|
|
69
|
+
An elicitation can only be bound to a call when exactly ONE wrapped call is in flight for that
|
|
70
|
+
upstream (always true on stdio); ambiguous concurrent HTTP calls decline rather than guess.
|
|
71
|
+
- Header validation reports `-32020` where `-32022` would be more specific in one path.
|
|
72
|
+
- Two-store wrap state read (expose.json + tools.state.json) has a benign TOCTOU window.
|
|
73
|
+
- A meta-less `server/discover` is answered rather than refused (permissive-by-design).
|
|
74
|
+
- The wrap's identity-mirror reconnect re-runs the full era negotiation - worst case ~3 s extra
|
|
75
|
+
on the first handshake against an upstream that ignores `server/discover`. Reusing the
|
|
76
|
+
already-known era on reconnect is a deferred optimisation.
|
|
77
|
+
- HTTP cancel translation: the sessionless modern era gives a POST no connection identity, so a
|
|
78
|
+
client `notifications/cancelled` over HTTP is dropped rather than risk cancelling another
|
|
79
|
+
client's call. stdio (one client per pipe) translates and forwards cancels for both forwarded
|
|
80
|
+
methods and wrapped tools/call; the remaining best-effort windows are sub-millisecond (a cancel
|
|
81
|
+
racing the instant a forward is issued) plus the PreToolUse gate-evaluation phase of a tool
|
|
82
|
+
call (registered at upstream-issue time, post-gate).
|
|
83
|
+
- `io.modelcontextprotocol/logLevel` (the modern per-request log level) is stripped with the rest
|
|
84
|
+
of the protocol `_meta` keys on wrapped forwards and not re-injected for modern upstreams -
|
|
85
|
+
part of the modern-upstream work above.
|
|
86
|
+
- Discover on a DISABLED upstream works (inspect its tools before enabling - an explicit
|
|
87
|
+
allowance on the discover path only; the disabled upstream never reaches the tool surface).
|
|
88
|
+
|
|
89
|
+
## Current limitations
|
|
90
|
+
|
|
91
|
+
- The OAuth 2.1 resource-server and Streamable-HTTP support are recent and less battle-tested than
|
|
92
|
+
the core - test your setup before relying on them in a networked deployment.
|
|
93
|
+
- No Prometheus / OpenTelemetry metrics - a toggleable JSONL audit log and in-memory `/health`
|
|
94
|
+
call counters only.
|
|
95
|
+
- One gateway instance per process: the bridge/cancel state is process-global, so embedding two
|
|
96
|
+
gateway instances in one Node process is not a supported configuration (running two processes
|
|
97
|
+
is fine, and is the normal deployment).
|
|
98
|
+
|
|
99
|
+
Found a bug, or want to help with the 0.6.0 work? Issues and PRs are welcome.
|
package/docs/MANUAL.pdf
CHANGED
|
Binary file
|
package/docs/design.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Design decisions
|
|
2
|
+
|
|
3
|
+
Short notes on the decisions that shape ToolFunnel, so you can judge the reasoning without
|
|
4
|
+
reading the source. Each one is also documented at the place in the code where it bites.
|
|
5
|
+
|
|
6
|
+
## 1. Zero runtime dependencies
|
|
7
|
+
|
|
8
|
+
ToolFunnel sits in a privileged position: it executes tools and enforces policy. Every
|
|
9
|
+
transitive dependency in that position is something you have to trust or audit. So the gateway
|
|
10
|
+
uses Node built-ins only; "dependencies" in package.json is empty and stays empty. The one
|
|
11
|
+
exception is deliberate and opt-in: OAuth token validation uses jose, installed only when you
|
|
12
|
+
run `toolfunnel install-oauth`, version-pinned, because hand-rolled JWT crypto is how security
|
|
13
|
+
holes are born. Dev tooling for the test suite (jose, the MCP SDK for interop tests) never
|
|
14
|
+
ships.
|
|
15
|
+
|
|
16
|
+
## 2. No SDK
|
|
17
|
+
|
|
18
|
+
The gateway hand-rolls JSON-RPC over stdio and HTTP rather than building on the official MCP
|
|
19
|
+
SDK. Two reasons. First, the zero-dependency stance above. Second, ToolFunnel has to speak two
|
|
20
|
+
protocol eras at once, on both sides, which the SDK does not do; owning the wire layer is what
|
|
21
|
+
makes the dual-era serving and the wrap possible at all.
|
|
22
|
+
|
|
23
|
+
## 3. The gate fails closed
|
|
24
|
+
|
|
25
|
+
Every server-side execution path goes through one function, gatedRun, and that function treats
|
|
26
|
+
every failure as a deny: a hook that blocks, a hook engine that crashes, a malformed engine
|
|
27
|
+
result, all of them mean the tool does not run. Denies carry a flag distinguishing operator
|
|
28
|
+
policy ("your hook said no") from wiring failure ("the gate itself broke"), so a broken gate
|
|
29
|
+
can never be mistaken for an approving one. The invariant has its own test.
|
|
30
|
+
|
|
31
|
+
## 4. The isolation boundary
|
|
32
|
+
|
|
33
|
+
Upstream server definitions can name any executable but their path-shaped arguments must stay
|
|
34
|
+
inside the config home. The command is the interpreter you chose; the args are what it can
|
|
35
|
+
reach, so the args are what the guard checks. Wrapping suspends the guard for the wrapped
|
|
36
|
+
server only, because a wrap is an explicit statement that this one server is your entire
|
|
37
|
+
surface, and it warns you when that happens.
|
|
38
|
+
|
|
39
|
+
## 5. Two eras, served per request
|
|
40
|
+
|
|
41
|
+
The 2026-07-28 revision removes the handshake that defines the older protocol, so "which era"
|
|
42
|
+
is a property of each request, and that is how the gateway treats it: era detection per
|
|
43
|
+
request, no mode switch, no configuration. Legacy answers stay byte-identical to what a
|
|
44
|
+
pre-0.6.0 client saw; the gateway's own modern decoration only ever appears on modern
|
|
45
|
+
requests. (A wrapped modern upstream's results are relayed verbatim, whatever era the
|
|
46
|
+
caller speaks - stripping a server's own fields would be data loss and a wrapper tell.)
|
|
47
|
+
|
|
48
|
+
## 6. Config lives in one folder
|
|
49
|
+
|
|
50
|
+
Everything mutable (tools, upstreams, hooks, identity, logs) lives in the config home,
|
|
51
|
+
relocatable with `--config-dir` or `TOOLFUNNEL_HOME`, seeded once, never overwritten by
|
|
52
|
+
updates. One folder to back up, one folder to package, one folder to hand a colleague. The
|
|
53
|
+
gateway prints the resolved home at every start so there is never any doubt about where state
|
|
54
|
+
lives.
|
|
55
|
+
|
|
56
|
+
## 7. Single operator by design
|
|
57
|
+
|
|
58
|
+
Each ToolFunnel instance serves one operator. That is a decision, not an oversight: one
|
|
59
|
+
instance per user gives process isolation, crash containment, per-user wrap state, and a
|
|
60
|
+
security boundary you can reason about in one sentence. Multi-user deployment composes *in
|
|
61
|
+
front of* the gateway rather than inside it - an authenticating proxy at the OAuth boundary
|
|
62
|
+
(ToolFunnel is already an OAuth 2.1 resource server) routing each authenticated user to their
|
|
63
|
+
own instance, with the per-request `_meta` lane carrying user context through to upstreams
|
|
64
|
+
that want it. Nothing in the core couples the operator to a token subject, so that door stays
|
|
65
|
+
open without the core ever growing user-shaped complexity.
|
|
66
|
+
|
|
67
|
+
A Team edition built on this pattern - per-user identity, audit trails, quotas, per-user
|
|
68
|
+
policy - is on the roadmap if there is interest. If your team wants that sooner, or you need a
|
|
69
|
+
bespoke gateway built on ToolFunnel for your own requirements, open an issue on this
|
|
70
|
+
repository titled `enquiry` - tailored builds are available.
|
package/docs/packaging.md
CHANGED
|
@@ -1,115 +1,115 @@
|
|
|
1
|
-
# Packaging: ship what you built
|
|
2
|
-
|
|
3
|
-
ToolFunnel's config is a folder. That single fact is the whole packaging story: everything you
|
|
4
|
-
build in a gateway
|
|
5
|
-
upstream MCPs you attached and the tools you chose to expose from them, the policy hooks that gate
|
|
6
|
-
every call, and the identity the server introduces itself with
|
|
7
|
-
**config home**. Ship the tree and you have shipped the product.
|
|
8
|
-
|
|
9
|
-
## The config home
|
|
10
|
-
|
|
11
|
-
By default the home is the package/clone root
|
|
12
|
-
the gateway elsewhere and the config lives there instead:
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
toolfunnel --config-dir D:\my-setup # flag (wins)
|
|
16
|
-
TOOLFUNNEL_HOME=D:\my-setup toolfunnel # env var
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
An empty home is **seeded** on first use from the shipped defaults (the register + management
|
|
20
|
-
scripts, an empty `mcp/expose.json`, the default hooks manifest) and is **never overwritten**
|
|
21
|
-
afterwards
|
|
22
|
-
can never eat your tools.
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
<home>/
|
|
26
|
-
toolfunnel.json # identity (serverName/serverVersion), port defaults, `requires`
|
|
27
|
-
tools/
|
|
28
|
-
tools.register.json # your tool entries (incl. their inputSchema)
|
|
29
|
-
tools.state.json # the hot/hidden/enabled curation
|
|
30
|
-
scripts/ # the tool scripts
|
|
31
|
-
mcp/expose.json # upstream references + curated exposure
|
|
32
|
-
hooks/ # the policy gate: manifest + hook scripts
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## `tf_pack`
|
|
36
|
-
|
|
37
|
-
The `tf_pack` management tool snapshots the LIVE setup into `<home>/dist/<name>/`
|
|
38
|
-
separate location, never the live tree. `auth/` (environment-specific) and `logs/` never ship.
|
|
39
|
-
|
|
40
|
-
**`{ format: "home" }`** → a portable config home. Zip it, commit it to a repo, or run it as-is:
|
|
41
|
-
|
|
42
|
-
```
|
|
43
|
-
toolfunnel --config-dir path/to/the-pack
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
**`{ format: "npm", name: "my-mcp", version: "1.0.0" }`** → a publishable npm package:
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
dist/my-mcp/
|
|
50
|
-
package.json # name, bin, files, dependencies: { "toolfunnel": "
|
|
51
|
-
bin/my-mcp.js # 2 lines: pin --config-dir at ../home, delegate to toolfunnel
|
|
52
|
-
home/ # the bundled config home (identity rewritten to my-mcp@1.0.0)
|
|
53
|
-
README.md # a stub to make your own
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Review it, then `cd dist/my-mcp && npm publish`. Your users run `npx my-mcp` and get **your**
|
|
57
|
-
server: your name in the MCP `initialize` handshake, your tools with your schemas, your curation,
|
|
58
|
-
and
|
|
59
|
-
your policy on the recipient's machine regardless of which MCP client calls it.
|
|
60
|
-
|
|
61
|
-
### Depend, never copy
|
|
62
|
-
|
|
63
|
-
The generated package **depends on** `toolfunnel` (caret range) rather than vendoring it. Your own
|
|
64
|
-
runtime dependencies go in **your** `package.json` exactly as with any npm package
|
|
65
|
-
the whole tree on `npx my-mcp`; toolfunnel itself adds zero. Why depending matters:
|
|
66
|
-
|
|
67
|
-
- security and bug fixes reach every wrapped MCP through a normal `npm update`
|
|
68
|
-
carrying old bugs;
|
|
69
|
-
- your package stays a few KB of config instead of a copied engine;
|
|
70
|
-
- (and honestly) every install of your MCP counts as a toolfunnel download, which keeps the thing
|
|
71
|
-
you depend on alive.
|
|
72
|
-
|
|
73
|
-
Your users can move the bundled home out of `node_modules` (so *their* edits survive *your*
|
|
74
|
-
updates) via the `<NAME>_HOME` env var the generated launcher supports.
|
|
75
|
-
|
|
76
|
-
## Declaring runtimes: `requires`
|
|
77
|
-
|
|
78
|
-
Your tools may need runtimes the gateway doesn't (python, git, ffmpeg
|
|
79
|
-
home's `toolfunnel.json` and they travel with every pack:
|
|
80
|
-
|
|
81
|
-
```json
|
|
82
|
-
{
|
|
83
|
-
"serverName": "my-mcp",
|
|
84
|
-
"requires": [
|
|
85
|
-
{ "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
|
|
86
|
-
{ "command": "git" }
|
|
87
|
-
]
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
The gateway probes them once at startup and prints one clear stderr line per problem
|
|
92
|
-
version vs needed, plus your `why`. Advisory by design: a missing runtime breaks only the tools
|
|
93
|
-
that need it, so the server still starts and everything else keeps working.
|
|
94
|
-
|
|
95
|
-
## Composite packs
|
|
96
|
-
|
|
97
|
-
A pack is not just your own scripts. Because `mcp/expose.json` lives in the home, a pack carries
|
|
98
|
-
**upstream references too**: "attach `@example/weather-mcp` (pin the version) and expose exactly
|
|
99
|
-
these four tools under these names." The upstream itself travels as a *reference*
|
|
100
|
-
it on the recipient's machine
|
|
101
|
-
pack = your tools + other people's servers, curated, renamed, and policed, as a single artifact.
|
|
102
|
-
|
|
103
|
-
## Audit honesty
|
|
104
|
-
|
|
105
|
-
Packs spawn commands
|
|
106
|
-
ours), read **three files**
|
|
107
|
-
|
|
108
|
-
- `tools/tools.register.json`
|
|
109
|
-
- `mcp/expose.json`
|
|
110
|
-
- `toolfunnel.json`
|
|
111
|
-
program is executed once with its version flag), which is why a `requires` command must be a
|
|
112
|
-
bare program name
|
|
113
|
-
|
|
114
|
-
Ship your own packs expecting the same scrutiny, and say so in your README (the `tf_pack` stub
|
|
115
|
-
already does).
|
|
1
|
+
# Packaging: ship what you built
|
|
2
|
+
|
|
3
|
+
ToolFunnel's config is a folder. That single fact is the whole packaging story: everything you
|
|
4
|
+
build in a gateway - your tools and their scripts, which of them are promoted hot or hidden, the
|
|
5
|
+
upstream MCPs you attached and the tools you chose to expose from them, the policy hooks that gate
|
|
6
|
+
every call, and the identity the server introduces itself with - lives in one directory tree, the
|
|
7
|
+
**config home**. Ship the tree and you have shipped the product.
|
|
8
|
+
|
|
9
|
+
## The config home
|
|
10
|
+
|
|
11
|
+
By default the home is the package/clone root - a git clone works exactly as it always did. Point
|
|
12
|
+
the gateway elsewhere and the config lives there instead:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
toolfunnel --config-dir D:\my-setup # flag (wins)
|
|
16
|
+
TOOLFUNNEL_HOME=D:\my-setup toolfunnel # env var
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
An empty home is **seeded** on first use from the shipped defaults (the register + management
|
|
20
|
+
scripts, an empty `mcp/expose.json`, the default hooks manifest) and is **never overwritten**
|
|
21
|
+
afterwards - which is the point: with the home outside the package, `npm update toolfunnel`
|
|
22
|
+
can never eat your tools.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
<home>/
|
|
26
|
+
toolfunnel.json # identity (serverName/serverVersion), port defaults, `requires`
|
|
27
|
+
tools/
|
|
28
|
+
tools.register.json # your tool entries (incl. their inputSchema)
|
|
29
|
+
tools.state.json # the hot/hidden/enabled curation - part of the product
|
|
30
|
+
scripts/ # the tool scripts
|
|
31
|
+
mcp/expose.json # upstream references + curated exposure
|
|
32
|
+
hooks/ # the policy gate: manifest + hook scripts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## `tf_pack` - one call to a deployment artifact
|
|
36
|
+
|
|
37
|
+
The `tf_pack` management tool snapshots the LIVE setup into `<home>/dist/<name>/` - always a
|
|
38
|
+
separate location, never the live tree. `auth/` (environment-specific) and `logs/` never ship.
|
|
39
|
+
|
|
40
|
+
**`{ format: "home" }`** → a portable config home. Zip it, commit it to a repo, or run it as-is:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
toolfunnel --config-dir path/to/the-pack
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**`{ format: "npm", name: "my-mcp", version: "1.0.0" }`** → a publishable npm package:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
dist/my-mcp/
|
|
50
|
+
package.json # name, bin, files, dependencies: { "toolfunnel": "^<current version>" }
|
|
51
|
+
bin/my-mcp.js # 2 lines: pin --config-dir at ../home, delegate to toolfunnel
|
|
52
|
+
home/ # the bundled config home (identity rewritten to my-mcp@1.0.0)
|
|
53
|
+
README.md # a stub to make your own
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Review it, then `cd dist/my-mcp && npm publish`. Your users run `npx my-mcp` and get **your**
|
|
57
|
+
server: your name in the MCP `initialize` handshake, your tools with your schemas, your curation,
|
|
58
|
+
and - the part nobody else can say - **your gate**. The hooks travel inside the pack and enforce
|
|
59
|
+
your policy on the recipient's machine regardless of which MCP client calls it.
|
|
60
|
+
|
|
61
|
+
### Depend, never copy
|
|
62
|
+
|
|
63
|
+
The generated package **depends on** `toolfunnel` (caret range) rather than vendoring it. Your own
|
|
64
|
+
runtime dependencies go in **your** `package.json` exactly as with any npm package - npm installs
|
|
65
|
+
the whole tree on `npx my-mcp`; toolfunnel itself adds zero. Why depending matters:
|
|
66
|
+
|
|
67
|
+
- security and bug fixes reach every wrapped MCP through a normal `npm update` (the pack pins the caret range of the version that built it, so patch releases flow automatically) - no stale forks
|
|
68
|
+
carrying old bugs;
|
|
69
|
+
- your package stays a few KB of config instead of a copied engine;
|
|
70
|
+
- (and honestly) every install of your MCP counts as a toolfunnel download, which keeps the thing
|
|
71
|
+
you depend on alive.
|
|
72
|
+
|
|
73
|
+
Your users can move the bundled home out of `node_modules` (so *their* edits survive *your*
|
|
74
|
+
updates) via the `<NAME>_HOME` env var the generated launcher supports.
|
|
75
|
+
|
|
76
|
+
## Declaring runtimes: `requires`
|
|
77
|
+
|
|
78
|
+
Your tools may need runtimes the gateway doesn't (python, git, ffmpeg...). Declare them in the
|
|
79
|
+
home's `toolfunnel.json` and they travel with every pack:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"serverName": "my-mcp",
|
|
84
|
+
"requires": [
|
|
85
|
+
{ "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
|
|
86
|
+
{ "command": "git" }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The gateway probes them once at startup and prints one clear stderr line per problem - found
|
|
92
|
+
version vs needed, plus your `why`. Advisory by design: a missing runtime breaks only the tools
|
|
93
|
+
that need it, so the server still starts and everything else keeps working.
|
|
94
|
+
|
|
95
|
+
## Composite packs
|
|
96
|
+
|
|
97
|
+
A pack is not just your own scripts. Because `mcp/expose.json` lives in the home, a pack carries
|
|
98
|
+
**upstream references too**: "attach `@example/weather-mcp` (pin the version) and expose exactly
|
|
99
|
+
these four tools under these names." The upstream itself travels as a *reference* - `npx` fetches
|
|
100
|
+
it on the recipient's machine - while your **curation** and your **gate** travel as config. One
|
|
101
|
+
pack = your tools + other people's servers, curated, renamed, and policed, as a single artifact.
|
|
102
|
+
|
|
103
|
+
## Audit honesty
|
|
104
|
+
|
|
105
|
+
Packs spawn commands - that is what they are for. Before you run anyone's pack (including one of
|
|
106
|
+
ours), read **three files** - every command it can run is declared in plain JSON:
|
|
107
|
+
|
|
108
|
+
- `tools/tools.register.json` - the tool invokes (scripts and shell commands);
|
|
109
|
+
- `mcp/expose.json` - the upstream server commands it will spawn;
|
|
110
|
+
- `toolfunnel.json` - the `requires` probes. Note these run **at gateway startup** (each declared
|
|
111
|
+
program is executed once with its version flag), which is why a `requires` command must be a
|
|
112
|
+
bare program name - the gateway rejects paths, spaces, and shell characters outright.
|
|
113
|
+
|
|
114
|
+
Ship your own packs expecting the same scrutiny, and say so in your README (the `tf_pack` stub
|
|
115
|
+
already does).
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"hooks": [
|
|
4
|
-
{
|
|
5
|
-
"id": "pre-tool-use/example-deny-dangerous",
|
|
6
|
-
"event": "PreToolUse",
|
|
7
|
-
"matcher": "*",
|
|
8
|
-
"type": "command",
|
|
9
|
-
"command": "node \"${HOOKS_DIR}/scripts/example-deny-dangerous.js\"",
|
|
10
|
-
"script": "scripts/example-deny-dangerous.js",
|
|
11
|
-
"timeout": 10,
|
|
12
|
-
"enabled": false,
|
|
13
|
-
"description": "EXAMPLE (disabled by default). A PreToolUse policy gate: denies any tool call whose arguments contain an obviously-destructive shell pattern (rm -rf, mkfs, drive format, fork bomb). A non-zero exit fails closed and blocks the call before it runs. Enable it in the UI Hooks tab (or set enabled:true here) to see the gate bite; edit scripts/example-deny-dangerous.js to encode your own policy."
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
"id": "post-tool-use/example-audit",
|
|
17
|
-
"event": "PostToolUse",
|
|
18
|
-
"matcher": "*",
|
|
19
|
-
"type": "command",
|
|
20
|
-
"command": "node \"${HOOKS_DIR}/scripts/example-audit.js\"",
|
|
21
|
-
"script": "scripts/example-audit.js",
|
|
22
|
-
"timeout": 10,
|
|
23
|
-
"enabled": false,
|
|
24
|
-
"description": "EXAMPLE (disabled by default). A PostToolUse observer: runs AFTER a tool executes (advisory
|
|
25
|
-
}
|
|
26
|
-
]
|
|
27
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": [
|
|
4
|
+
{
|
|
5
|
+
"id": "pre-tool-use/example-deny-dangerous",
|
|
6
|
+
"event": "PreToolUse",
|
|
7
|
+
"matcher": "*",
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${HOOKS_DIR}/scripts/example-deny-dangerous.js\"",
|
|
10
|
+
"script": "scripts/example-deny-dangerous.js",
|
|
11
|
+
"timeout": 10,
|
|
12
|
+
"enabled": false,
|
|
13
|
+
"description": "EXAMPLE (disabled by default). A PreToolUse policy gate: denies any tool call whose arguments contain an obviously-destructive shell pattern (rm -rf, mkfs, drive format, fork bomb). A non-zero exit fails closed and blocks the call before it runs. Enable it in the UI Hooks tab (or set enabled:true here) to see the gate bite; edit scripts/example-deny-dangerous.js to encode your own policy."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "post-tool-use/example-audit",
|
|
17
|
+
"event": "PostToolUse",
|
|
18
|
+
"matcher": "*",
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node \"${HOOKS_DIR}/scripts/example-audit.js\"",
|
|
21
|
+
"script": "scripts/example-audit.js",
|
|
22
|
+
"timeout": 10,
|
|
23
|
+
"enabled": false,
|
|
24
|
+
"description": "EXAMPLE (disabled by default). A PostToolUse observer: runs AFTER a tool executes (advisory - it cannot un-run the call) and receives the tool result on stdin. The stub exits 0 (clean pass); replace its body to forward results to your own audit sink, metrics, or SIEM. Enable it in the UI Hooks tab (or set enabled:true here)."
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* EXAMPLE PostToolUse observer
|
|
6
|
-
*
|
|
7
|
-
* PostToolUse is ADVISORY: it runs AFTER a tool has executed and CANNOT un-run it
|
|
8
|
-
* outcome. The lifecycle event (including the tool result) arrives as JSON on stdin. This stub just
|
|
9
|
-
* exits 0 (a clean pass). Replace the body to forward results to your own audit sink, metrics, an
|
|
10
|
-
* external SIEM, etc. Exit 0 = observed; a non-zero exit on PostToolUse is a non-blocking error.
|
|
11
|
-
*
|
|
12
|
-
* Zero dependencies
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
let raw = '';
|
|
16
|
-
process.stdin.on('data', (c) => { raw += c; });
|
|
17
|
-
process.stdin.on('end', () => {
|
|
18
|
-
// let ev = JSON.parse(raw || '{}'); // { tool_name, tool_input, tool_response, ... }
|
|
19
|
-
// ... forward ev to your audit sink here ...
|
|
20
|
-
process.exit(0);
|
|
21
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EXAMPLE PostToolUse observer - DISABLED by default.
|
|
6
|
+
*
|
|
7
|
+
* PostToolUse is ADVISORY: it runs AFTER a tool has executed and CANNOT un-run it - it observes the
|
|
8
|
+
* outcome. The lifecycle event (including the tool result) arrives as JSON on stdin. This stub just
|
|
9
|
+
* exits 0 (a clean pass). Replace the body to forward results to your own audit sink, metrics, an
|
|
10
|
+
* external SIEM, etc. Exit 0 = observed; a non-zero exit on PostToolUse is a non-blocking error.
|
|
11
|
+
*
|
|
12
|
+
* Zero dependencies - Node built-ins only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let raw = '';
|
|
16
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
17
|
+
process.stdin.on('end', () => {
|
|
18
|
+
// let ev = JSON.parse(raw || '{}'); // { tool_name, tool_input, tool_response, ... }
|
|
19
|
+
// ... forward ev to your audit sink here ...
|
|
20
|
+
process.exit(0);
|
|
21
|
+
});
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* EXAMPLE PreToolUse gate
|
|
6
|
-
* UI Hooks tab / the per-tool "Pre" toggle on the Tools tab).
|
|
7
|
-
*
|
|
8
|
-
* This is the shape of a real policy gate: it runs INSIDE the gateway, BEFORE a tool executes, and a
|
|
9
|
-
* non-zero exit DENIES the call (the gate fails closed). The lifecycle event arrives as JSON on
|
|
10
|
-
* stdin; here we block any call whose arguments contain an obviously-destructive shell pattern.
|
|
11
|
-
*
|
|
12
|
-
* Block protocol (the simple one): write the reason to stderr and `process.exit(2)`. (A richer JSON
|
|
13
|
-
* protocol is also supported
|
|
14
|
-
*
|
|
15
|
-
* Zero dependencies
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
let raw = '';
|
|
19
|
-
process.stdin.on('data', (c) => { raw += c; });
|
|
20
|
-
process.stdin.on('end', () => {
|
|
21
|
-
let ev = {};
|
|
22
|
-
try { ev = JSON.parse(raw || '{}'); } catch (_e) { /* malformed payload
|
|
23
|
-
const args = JSON.stringify((ev && ev.tool_input) || {});
|
|
24
|
-
// A small illustrative denylist: recursive-force delete, mkfs, drive format, fork bomb.
|
|
25
|
-
const DANGER = /\brm\s+-[a-z]*r[a-z]*f|\bmkfs\b|\bformat\s+[a-z]:|:\(\)\s*\{\s*:/i;
|
|
26
|
-
if (DANGER.test(args)) {
|
|
27
|
-
process.stderr.write('example-deny-dangerous: blocked
|
|
28
|
-
process.exit(2); // DENY
|
|
29
|
-
}
|
|
30
|
-
process.exit(0); // allow
|
|
31
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EXAMPLE PreToolUse gate - DISABLED by default (enable it in hooks/hooks.manifest.json, or via the
|
|
6
|
+
* UI Hooks tab / the per-tool "Pre" toggle on the Tools tab).
|
|
7
|
+
*
|
|
8
|
+
* This is the shape of a real policy gate: it runs INSIDE the gateway, BEFORE a tool executes, and a
|
|
9
|
+
* non-zero exit DENIES the call (the gate fails closed). The lifecycle event arrives as JSON on
|
|
10
|
+
* stdin; here we block any call whose arguments contain an obviously-destructive shell pattern.
|
|
11
|
+
*
|
|
12
|
+
* Block protocol (the simple one): write the reason to stderr and `process.exit(2)`. (A richer JSON
|
|
13
|
+
* protocol is also supported - see docs/hooks-and-gating.md.) Exit 0 = allow.
|
|
14
|
+
*
|
|
15
|
+
* Zero dependencies - Node built-ins only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
let raw = '';
|
|
19
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
20
|
+
process.stdin.on('end', () => {
|
|
21
|
+
let ev = {};
|
|
22
|
+
try { ev = JSON.parse(raw || '{}'); } catch (_e) { /* malformed payload -> allow (don't block on a parse error) */ }
|
|
23
|
+
const args = JSON.stringify((ev && ev.tool_input) || {});
|
|
24
|
+
// A small illustrative denylist: recursive-force delete, mkfs, drive format, fork bomb.
|
|
25
|
+
const DANGER = /\brm\s+-[a-z]*r[a-z]*f|\bmkfs\b|\bformat\s+[a-z]:|:\(\)\s*\{\s*:/i;
|
|
26
|
+
if (DANGER.test(args)) {
|
|
27
|
+
process.stderr.write('example-deny-dangerous: blocked - the arguments contain a destructive pattern.\n');
|
|
28
|
+
process.exit(2); // DENY - the gateway refuses to run the tool
|
|
29
|
+
}
|
|
30
|
+
process.exit(0); // allow
|
|
31
|
+
});
|