toolfunnel 0.4.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 +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- 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 -981
- 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 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- 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/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
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
|
+
});
|
package/mcp/expose.example.json
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_README": "SAMPLE config
|
|
3
|
-
"version": 1,
|
|
4
|
-
"upstreams": [
|
|
5
|
-
{
|
|
6
|
-
"id": "mock",
|
|
7
|
-
"transport": "stdio",
|
|
8
|
-
"command": "node",
|
|
9
|
-
"args": ["mcp/servers/mock-upstream/server.js"],
|
|
10
|
-
"enabled": true,
|
|
11
|
-
"description": "Bundled demo upstream MCP (zero-dependency, stdio): ping, add, echo. No network, no fs writes, no process mutation."
|
|
12
|
-
}
|
|
13
|
-
],
|
|
14
|
-
"expose": [
|
|
15
|
-
{
|
|
16
|
-
"upstream": "mock",
|
|
17
|
-
"tool": "ping",
|
|
18
|
-
"as": "mock_ping",
|
|
19
|
-
"category": "demo",
|
|
20
|
-
"enabled": true
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"upstream": "mock",
|
|
24
|
-
"tool": "add",
|
|
25
|
-
"as": "mock_add",
|
|
26
|
-
"category": "demo",
|
|
27
|
-
"enabled": true
|
|
28
|
-
}
|
|
29
|
-
]
|
|
30
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"_README": "SAMPLE config - NOT the live expose.json. This wires the bundled demo upstream at mcp/servers/mock-upstream/server.js so the MCP Manager has a real target. TO USE: either (A) copy this file over mcp/expose.json to pre-seed the host (drop the _README key - the loader ignores it, but copy cleanly), OR (B) leave expose.json empty and add the same upstream + expose entries through the MCP Manager UI (Add upstream -> Discover -> tick to expose). The upstream offers ping, add, echo; this sample exposes only TWO (ping, add) to demonstrate curation. IMPORTANT: the args path MUST resolve INSIDE the gateway root - the aggregator's isolation guard rejects any arg path that escapes the sandbox. A relative path like 'mcp/servers/mock-upstream/server.js' is resolved against the gateway root and stays inside it; 'node' in the command slot is the interpreter (not guarded). Do NOT edit the live mcp/expose.json to point outside the gateway root.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"upstreams": [
|
|
5
|
+
{
|
|
6
|
+
"id": "mock",
|
|
7
|
+
"transport": "stdio",
|
|
8
|
+
"command": "node",
|
|
9
|
+
"args": ["mcp/servers/mock-upstream/server.js"],
|
|
10
|
+
"enabled": true,
|
|
11
|
+
"description": "Bundled demo upstream MCP (zero-dependency, stdio): ping, add, echo. No network, no fs writes, no process mutation."
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"expose": [
|
|
15
|
+
{
|
|
16
|
+
"upstream": "mock",
|
|
17
|
+
"tool": "ping",
|
|
18
|
+
"as": "mock_ping",
|
|
19
|
+
"category": "demo",
|
|
20
|
+
"enabled": true
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"upstream": "mock",
|
|
24
|
+
"tool": "add",
|
|
25
|
+
"as": "mock_add",
|
|
26
|
+
"category": "demo",
|
|
27
|
+
"enabled": true
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mcp/servers/mock-modern/server.js
|
|
6
|
+
*
|
|
7
|
+
* A tiny, ZERO-DEPENDENCY stdio MCP server that speaks the 2026-07-28 ("modern") protocol -
|
|
8
|
+
* the companion to mock-upstream (which speaks the legacy 2024-11-05 protocol). It exists to
|
|
9
|
+
* prove ToolFunnel's DUAL-ERA CLIENT: attach it as an upstream and ToolFunnel negotiates modern
|
|
10
|
+
* (probes server/discover, skips the initialize handshake, and puts a per-request `_meta` on
|
|
11
|
+
* every call). Over stdio the modern era needs only `_meta` - the Mcp-* headers are HTTP-only.
|
|
12
|
+
*
|
|
13
|
+
* Modern-only, deliberately STRICT so it proves the client is really speaking modern:
|
|
14
|
+
* - `initialize` -> -32601 (removed in the modern era; a modern-only server has no handshake)
|
|
15
|
+
* - `server/discover` -> { resultType, supportedVersions:['2026-07-28'], capabilities, _meta
|
|
16
|
+
* (serverInfo), instructions, ttlMs, cacheScope }
|
|
17
|
+
* - `tools/list` -> REQUIRES per-request `_meta` (else -32602); returns resultType + tools
|
|
18
|
+
* + ttlMs/cacheScope
|
|
19
|
+
* - `tools/call` -> REQUIRES per-request `_meta` (else -32602); returns resultType + content
|
|
20
|
+
*
|
|
21
|
+
* Tools (real upstream names): ping -> "pong", add {a,b} -> a+b, echo {text} -> text.
|
|
22
|
+
*
|
|
23
|
+
* JSON-RPC 2.0 over stdio, newline-delimited (one compact JSON object per line). Node built-ins
|
|
24
|
+
* only, CommonJS. Built to the RC spec (locked 2026-05-21); reconcile minor final deltas 28 July.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const MODERN_VERSION = '2026-07-28';
|
|
28
|
+
const SERVER_INFO = { name: 'mock-modern', version: '2.0.0' };
|
|
29
|
+
const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
30
|
+
const META_SERVERINFO = 'io.modelcontextprotocol/serverInfo';
|
|
31
|
+
|
|
32
|
+
const TOOLS = [
|
|
33
|
+
{ name: 'ping', description: 'Health check - returns "pong".', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
34
|
+
{ name: 'add', description: 'Add two numbers.', inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'], additionalProperties: false } },
|
|
35
|
+
{ name: 'echo', description: 'Echo the given text.', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false } },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function writeMessage(obj) {
|
|
39
|
+
try { process.stdout.write(JSON.stringify(obj) + '\n'); } catch (_e) { /* never crash on a write */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A modern result carries resultType + the server's serverInfo in _meta. */
|
|
43
|
+
function modernResult(extra) {
|
|
44
|
+
return Object.assign({ resultType: 'complete', _meta: { [META_SERVERINFO]: SERVER_INFO } }, extra);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function textResult(text, isError) {
|
|
48
|
+
return modernResult({ content: [{ type: 'text', text: String(text) }], isError: isError === true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A modern request MUST carry params._meta with BOTH the EXACT protocol version AND a
|
|
52
|
+
* clientCapabilities object - the spec's two per-request MUSTs. Strict on purpose: this
|
|
53
|
+
* fixture's job is to catch a client regression in EITHER field (a lax check here would let
|
|
54
|
+
* "tools/call works with _meta" pass while the client sent the wrong version or no caps). */
|
|
55
|
+
function hasModernMeta(params) {
|
|
56
|
+
const meta = params && params._meta;
|
|
57
|
+
if (!meta || typeof meta !== 'object') return false;
|
|
58
|
+
if (meta[META_VERSION] !== MODERN_VERSION) return false;
|
|
59
|
+
const caps = meta['io.modelcontextprotocol/clientCapabilities'];
|
|
60
|
+
return !!(caps && typeof caps === 'object' && !Array.isArray(caps));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handleMessage(msg) {
|
|
64
|
+
if (!msg || typeof msg !== 'object') return;
|
|
65
|
+
const id = msg.id;
|
|
66
|
+
const method = msg.method;
|
|
67
|
+
const params = msg.params || {};
|
|
68
|
+
if (id === undefined || id === null) return; // notifications are never answered
|
|
69
|
+
|
|
70
|
+
switch (method) {
|
|
71
|
+
case 'server/discover':
|
|
72
|
+
return writeMessage({
|
|
73
|
+
jsonrpc: '2.0', id,
|
|
74
|
+
result: modernResult({
|
|
75
|
+
supportedVersions: [MODERN_VERSION],
|
|
76
|
+
capabilities: { tools: {} },
|
|
77
|
+
instructions: 'A modern (2026-07-28) mock MCP. Tools: ping, add, echo.',
|
|
78
|
+
ttlMs: 3600000,
|
|
79
|
+
cacheScope: 'private',
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
case 'initialize':
|
|
84
|
+
// Modern-only: the initialize handshake was removed. Reject so a legacy client fails clearly.
|
|
85
|
+
return writeMessage({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: initialize (this is a modern 2026-07-28 server - use server/discover)' } });
|
|
86
|
+
|
|
87
|
+
case 'tools/list':
|
|
88
|
+
if (!hasModernMeta(params)) {
|
|
89
|
+
return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: modern requests require params._meta with a protocol version' } });
|
|
90
|
+
}
|
|
91
|
+
return writeMessage({ jsonrpc: '2.0', id, result: modernResult({ tools: TOOLS, ttlMs: 60000, cacheScope: 'private' }) });
|
|
92
|
+
|
|
93
|
+
case 'resources/list':
|
|
94
|
+
// A CacheableResult surface - proves non-tool forwarding under a wrap (and that the wrap adds
|
|
95
|
+
// the required ttlMs/cacheScope when the upstream's own hints are absent).
|
|
96
|
+
if (!hasModernMeta(params)) {
|
|
97
|
+
return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: modern requests require params._meta with a protocol version' } });
|
|
98
|
+
}
|
|
99
|
+
return writeMessage({ jsonrpc: '2.0', id, result: modernResult({ resources: [{ uri: 'mock://modern/example', name: 'example' }] }) });
|
|
100
|
+
|
|
101
|
+
case 'tools/call': {
|
|
102
|
+
if (!hasModernMeta(params)) {
|
|
103
|
+
return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: modern requests require params._meta with a protocol version' } });
|
|
104
|
+
}
|
|
105
|
+
const name = params.name;
|
|
106
|
+
const args = params.arguments || {};
|
|
107
|
+
if (name === 'ping') {
|
|
108
|
+
// PLUMBING FIXTURE, not conformant behaviour: a real modern server emits notifications
|
|
109
|
+
// only inside a subscriptions/listen stream, which ToolFunnel's client does not yet open
|
|
110
|
+
// (28-July item). This unsolicited emission exists solely so BR-A2 can prove the
|
|
111
|
+
// notification->hook plumbing; the ACK-level honesty (never counting a modern upstream as
|
|
112
|
+
// able to honour resourceSubscriptions) is asserted by BR-A3.
|
|
113
|
+
writeMessage({ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: 'mock://modern/example' } });
|
|
114
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult('pong') });
|
|
115
|
+
}
|
|
116
|
+
if (name === 'add') {
|
|
117
|
+
const a = Number(args.a), b = Number(args.b);
|
|
118
|
+
if (!isFinite(a) || !isFinite(b)) return writeMessage({ jsonrpc: '2.0', id, result: textResult('add requires numeric a and b', true) });
|
|
119
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(a + b)) });
|
|
120
|
+
}
|
|
121
|
+
if (name === 'echo') {
|
|
122
|
+
const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
|
|
123
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
|
|
124
|
+
}
|
|
125
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
return writeMessage({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let buffer = '';
|
|
134
|
+
process.stdin.on('data', (chunk) => {
|
|
135
|
+
buffer += chunk.toString('utf8');
|
|
136
|
+
let nl;
|
|
137
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
138
|
+
const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
|
|
139
|
+
buffer = buffer.slice(nl + 1);
|
|
140
|
+
if (line.length === 0) continue;
|
|
141
|
+
let msg;
|
|
142
|
+
try { msg = JSON.parse(line); } catch (_e) { continue; }
|
|
143
|
+
try { handleMessage(msg); } catch (_e) { /* never take the server down */ }
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
process.stdin.on('end', () => process.exit(0));
|
|
147
|
+
process.stdin.resume();
|