toolfunnel 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -324
- package/bin/toolfunnel.js +39 -4
- package/docs/KNOWN_BUGS.md +33 -0
- package/docs/MANUAL.pdf +0 -0
- package/docs/packaging.md +115 -0
- package/package.json +3 -1
- package/src/auth/config.js +4 -2
- package/src/auth/resource-server.js +28 -3
- package/src/core/config-home.js +134 -0
- package/src/core/logger.js +4 -2
- package/src/core/requires.js +138 -0
- package/src/core/server-config.js +77 -0
- package/src/extend/create-tool.md +24 -4
- package/src/extend/package.md +33 -0
- package/src/mcp/aggregator.js +12 -7
- package/src/mcp/http-transport.js +71 -21
- package/src/mcp/mcp-client.js +9 -3
- package/src/mcp/server.js +181 -16
- package/src/packages/packager.js +5 -3
- package/src/tools/registry.js +47 -2
- package/src/ui/server.js +49 -3
- package/tools/scripts/tf-env.js +43 -0
- package/tools/scripts/tf-hook-add.js +4 -5
- package/tools/scripts/tf-hook-set.js +4 -5
- package/tools/scripts/tf-list.js +9 -12
- package/tools/scripts/tf-log.js +4 -1
- package/tools/scripts/tf-mcp-add.js +4 -6
- package/tools/scripts/tf-mcp-set.js +4 -6
- package/tools/scripts/tf-pack.js +237 -0
- package/tools/scripts/tf-tool-add.js +16 -16
- package/tools/scripts/tf-tool-set.js +5 -11
- package/tools/tools.register.json +46 -1
- 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/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/bin/toolfunnel.js
CHANGED
|
@@ -39,13 +39,40 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
39
39
|
' toolfunnel --ui Start the config web UI (127.0.0.1:9777)',
|
|
40
40
|
' toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)',
|
|
41
41
|
' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
|
|
42
|
+
' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
|
|
42
43
|
' toolfunnel --help Show this help',
|
|
43
44
|
'',
|
|
45
|
+
'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
|
|
46
|
+
'the config home — by default the package root (a git clone works as before), or the value',
|
|
47
|
+
'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
|
|
48
|
+
'defaults on first use and is NEVER overwritten after — so an npm update cannot eat your',
|
|
49
|
+
'tools. This is also how a wrapped MCP ships its own bundled setup.',
|
|
50
|
+
'',
|
|
51
|
+
'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
|
|
52
|
+
' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
|
|
53
|
+
'A --port flag always wins over the config value.',
|
|
54
|
+
'',
|
|
44
55
|
].join('\n')
|
|
45
56
|
);
|
|
46
57
|
process.exit(0);
|
|
47
58
|
}
|
|
48
59
|
|
|
60
|
+
// Resolve + seed the config home FIRST — before any src/ module is required — so every
|
|
61
|
+
// module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
|
|
62
|
+
// same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
|
|
63
|
+
const { initConfigHome } = require('../src/core/config-home');
|
|
64
|
+
const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
|
|
65
|
+
|
|
66
|
+
// Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, …)
|
|
67
|
+
// for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
|
|
68
|
+
// problem is one loud stderr line and the gateway starts anyway. Never fatal, never throws.
|
|
69
|
+
try {
|
|
70
|
+
const { checkRequires } = require('../src/core/requires');
|
|
71
|
+
for (const p of checkRequires(configHome)) {
|
|
72
|
+
process.stderr.write('[toolfunnel] REQUIRES: ' + p.problem + '\n');
|
|
73
|
+
}
|
|
74
|
+
} catch (_e) { /* preflight must never stop the gateway */ }
|
|
75
|
+
|
|
49
76
|
if (args[0] === 'install-oauth') {
|
|
50
77
|
// Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
|
|
51
78
|
// at zero runtime dependencies — you only ever fetch jose if you decide you need OAuth.
|
|
@@ -68,9 +95,13 @@ if (args[0] === 'install-oauth') {
|
|
|
68
95
|
});
|
|
69
96
|
} else if (args.includes('--ui')) {
|
|
70
97
|
const { createUiServer } = require('../src/ui/server');
|
|
98
|
+
const { loadServerConfig } = require('../src/core/server-config');
|
|
99
|
+
const { resolveConfigHome } = require('../src/core/config-home');
|
|
100
|
+
const cfg = loadServerConfig(resolveConfigHome());
|
|
71
101
|
const host = flagValue('--host', '127.0.0.1');
|
|
72
|
-
|
|
73
|
-
const
|
|
102
|
+
// Precedence: --port flag > toolfunnel.json uiPort > 9777 (loadServerConfig folds in the default).
|
|
103
|
+
const parsedPort = parseInt(flagValue('--port', String(cfg.uiPort)), 10);
|
|
104
|
+
const port = Number.isInteger(parsedPort) ? parsedPort : cfg.uiPort;
|
|
74
105
|
const server = createUiServer({ host, port });
|
|
75
106
|
server
|
|
76
107
|
.start()
|
|
@@ -88,9 +119,13 @@ if (args[0] === 'install-oauth') {
|
|
|
88
119
|
process.on('SIGTERM', shutdown);
|
|
89
120
|
} else if (args.includes('--http')) {
|
|
90
121
|
const { createHttpMcpServer } = require('../src/mcp/http-transport');
|
|
122
|
+
const { loadServerConfig } = require('../src/core/server-config');
|
|
123
|
+
const { resolveConfigHome } = require('../src/core/config-home');
|
|
124
|
+
const cfg = loadServerConfig(resolveConfigHome());
|
|
91
125
|
const host = flagValue('--host', '127.0.0.1');
|
|
92
|
-
|
|
93
|
-
const
|
|
126
|
+
// Precedence: --port flag > toolfunnel.json httpPort > 9998 (loadServerConfig folds in the default).
|
|
127
|
+
const parsedPort = parseInt(flagValue('--port', String(cfg.httpPort)), 10);
|
|
128
|
+
const port = Number.isInteger(parsedPort) ? parsedPort : cfg.httpPort;
|
|
94
129
|
const server = createHttpMcpServer({ host, port });
|
|
95
130
|
server
|
|
96
131
|
.start()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Known issues & roadmap
|
|
2
|
+
|
|
3
|
+
ToolFunnel has no known correctness bugs at this release. This file tracks the upcoming
|
|
4
|
+
compatibility work and any limitations worth knowing about.
|
|
5
|
+
|
|
6
|
+
## Coming in 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
|
+
ToolFunnel 0.5.0 speaks the current `2024-11-05` protocol and works with every MCP client shipping
|
|
15
|
+
today. **0.6.0** will add **dual-era** support — answering both the current handshake-based clients
|
|
16
|
+
*and* the 2026-07-28 modern clients — built against the finalised spec after 28 July.
|
|
17
|
+
|
|
18
|
+
Planned for 0.6.0:
|
|
19
|
+
|
|
20
|
+
- **Dual-era transport** (current + 2026-07-28) so old and new clients both keep working.
|
|
21
|
+
- `server/discover`, the new request-header validation, and the `subscriptions/listen` stream.
|
|
22
|
+
- An **opt-in, per-upstream legacy-protocol shim**: keep an ageing MCP server working past the
|
|
23
|
+
cutover by pinning the protocol version ToolFunnel speaks *to that upstream* — off by default,
|
|
24
|
+
it names the exact version it drops to and warns at startup and on every call.
|
|
25
|
+
|
|
26
|
+
## Current limitations
|
|
27
|
+
|
|
28
|
+
- The OAuth 2.1 resource-server and Streamable-HTTP support are recent and less battle-tested than
|
|
29
|
+
the core — test your setup before relying on them in a networked deployment.
|
|
30
|
+
- No Prometheus / OpenTelemetry metrics — a toggleable JSONL audit log and in-memory `/health`
|
|
31
|
+
call counters only.
|
|
32
|
+
|
|
33
|
+
Found a bug, or want to help with the 0.6.0 work? Issues and PRs are welcome.
|
package/docs/MANUAL.pdf
ADDED
|
Binary file
|
|
@@ -0,0 +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 — 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": "^0.4.0" }
|
|
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` — 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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolfunnel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -28,9 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"start": "node bin/toolfunnel.js",
|
|
31
|
+
"build": "echo toolfunnel has no build step - zero-dependency, runs from source",
|
|
31
32
|
"http": "node bin/toolfunnel.js --http",
|
|
32
33
|
"install-oauth": "node bin/toolfunnel.js install-oauth",
|
|
33
34
|
"test": "node test/run-all.js",
|
|
35
|
+
"release": "node scripts/release.js",
|
|
34
36
|
"test:integration": "node test/integration-real-mcp.test.js && node test/integration-http-client.test.js"
|
|
35
37
|
},
|
|
36
38
|
"dependencies": {},
|
package/src/auth/config.js
CHANGED
|
@@ -36,8 +36,10 @@
|
|
|
36
36
|
const fs = require('node:fs');
|
|
37
37
|
const path = require('node:path');
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
|
|
40
|
+
* src/core/config-home.js). Auth config is user-state, so it lives with the home. */
|
|
41
|
+
const { resolveConfigHome } = require('../core/config-home');
|
|
42
|
+
const ROOT = resolveConfigHome();
|
|
41
43
|
|
|
42
44
|
/** The toggle/config file. NOT created until setConfig() writes it. */
|
|
43
45
|
const CONFIG_PATH = path.join(ROOT, 'auth', 'auth.config.json');
|
|
@@ -112,7 +112,27 @@ async function resolveJwksUri(cfg, timeoutMs) {
|
|
|
112
112
|
for (const url of candidates) {
|
|
113
113
|
try {
|
|
114
114
|
const doc = await fetchJson(url, timeoutMs);
|
|
115
|
-
if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0)
|
|
115
|
+
if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0) {
|
|
116
|
+
// ORIGIN-PIN the discovered value. The ISSUER is operator-trusted config, but the discovery
|
|
117
|
+
// DOCUMENT arrives over the network — it must not be able to point key fetching at a
|
|
118
|
+
// foreign origin (key-substitution + SSRF-adjacent surface). Cross-origin JWKS hosting is
|
|
119
|
+
// real (e.g. Google's issuer and JWKS live on different hosts), so the escape hatch is an
|
|
120
|
+
// EXPLICIT trust statement: set "jwksUri" in the auth config and discovery is skipped.
|
|
121
|
+
let sameOrigin = false;
|
|
122
|
+
try {
|
|
123
|
+
sameOrigin = new URL(doc.jwks_uri).origin === new URL(cfg.issuer).origin;
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
/* malformed URL on either side → treat as cross-origin → refuse */
|
|
126
|
+
}
|
|
127
|
+
if (!sameOrigin) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`discovered jwks_uri "${doc.jwks_uri}" is not on the issuer origin — refusing the ` +
|
|
130
|
+
'cross-origin discovery result. If your IdP intentionally hosts JWKS on a separate ' +
|
|
131
|
+
'origin, state that trust explicitly by setting "jwksUri" in auth/auth.config.json.'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return doc.jwks_uri;
|
|
135
|
+
}
|
|
116
136
|
lastErr = new Error(`discovery doc at ${url} has no jwks_uri`);
|
|
117
137
|
} catch (e) {
|
|
118
138
|
lastErr = e;
|
|
@@ -121,13 +141,18 @@ async function resolveJwksUri(cfg, timeoutMs) {
|
|
|
121
141
|
throw lastErr || new Error('OIDC discovery failed');
|
|
122
142
|
}
|
|
123
143
|
|
|
124
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* GET + parse JSON with a hard timeout. Throws on non-2xx, network error, bad JSON — or a REDIRECT:
|
|
146
|
+
* discovery metadata must come from the exact well-known URL derived from the trusted issuer
|
|
147
|
+
* (following a redirect would let a bounced endpoint serve the document from anywhere, hollowing
|
|
148
|
+
* out the origin pin above). Only discovery uses this helper; the JWKS fetch itself is jose's.
|
|
149
|
+
*/
|
|
125
150
|
async function fetchJson(url, timeoutMs) {
|
|
126
151
|
const ac = new AbortController();
|
|
127
152
|
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
|
|
128
153
|
const t = setTimeout(() => ac.abort(), ms);
|
|
129
154
|
try {
|
|
130
|
-
const res = await fetch(url, { signal: ac.signal, redirect: '
|
|
155
|
+
const res = await fetch(url, { signal: ac.signal, redirect: 'error' });
|
|
131
156
|
if (!res || !res.ok) throw new Error(`GET ${url} -> HTTP ${res ? res.status : 'no-response'}`);
|
|
132
157
|
return await res.json();
|
|
133
158
|
} finally {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* config-home.js — WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
|
|
5
|
+
*
|
|
6
|
+
* The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
|
|
7
|
+
* directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
|
|
8
|
+
* hooks, curation, and identity. Config must be able to live in a home of its own:
|
|
9
|
+
*
|
|
10
|
+
* resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
|
|
11
|
+
*
|
|
12
|
+
* The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
|
|
13
|
+
* (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
|
|
14
|
+
* empty directory makes the gateway SEED it on first use — the shipped register + its scripts, an
|
|
15
|
+
* empty expose.json, the default hooks manifest — and every mutable path (tools/ mcp/ hooks/
|
|
16
|
+
* auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
|
|
17
|
+
* existing file, so a home survives package updates by construction.
|
|
18
|
+
*
|
|
19
|
+
* Two env vars are the process-tree contract (children inherit them via defaultRunScript's
|
|
20
|
+
* spawn env):
|
|
21
|
+
* TOOLFUNNEL_HOME — the resolved ABSOLUTE config home (normalised at init so a relative value
|
|
22
|
+
* can't skew between processes with different cwds).
|
|
23
|
+
* TOOLFUNNEL_PKG — the package root of the gateway that spawned the tool, so a management
|
|
24
|
+
* script SEEDED INTO an external home can still require the engine code
|
|
25
|
+
* (src/…) it was copied away from. A script always prefers its own
|
|
26
|
+
* __dirname-derived root when that root contains the engine (the git-clone
|
|
27
|
+
* layout), so a stale inherited value can never shadow a real local install.
|
|
28
|
+
*
|
|
29
|
+
* Zero new dependencies. Node built-ins only.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
/** The package root: this file lives at <pkg>/src/core/config-home.js. */
|
|
36
|
+
const PKG_ROOT = path.resolve(__dirname, '..', '..');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the config home. Pure — no filesystem writes, no env mutation.
|
|
40
|
+
* @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
|
|
41
|
+
* @returns {string} absolute config-home path
|
|
42
|
+
*/
|
|
43
|
+
function resolveConfigHome(opts) {
|
|
44
|
+
const o = opts || {};
|
|
45
|
+
const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
|
|
46
|
+
const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
|
|
47
|
+
? process.env.TOOLFUNNEL_HOME.trim()
|
|
48
|
+
: null;
|
|
49
|
+
const chosen = fromFlag || fromEnv;
|
|
50
|
+
return chosen ? path.resolve(chosen) : PKG_ROOT;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Recursively copy a directory, skipping any destination file that already exists. */
|
|
54
|
+
function copyTreeIfAbsent(srcDir, destDir, seeded) {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
58
|
+
} catch (_e) {
|
|
59
|
+
return; // a missing source dir seeds nothing (defensive)
|
|
60
|
+
}
|
|
61
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
const from = path.join(srcDir, e.name);
|
|
64
|
+
const to = path.join(destDir, e.name);
|
|
65
|
+
if (e.isDirectory()) {
|
|
66
|
+
copyTreeIfAbsent(from, to, seeded);
|
|
67
|
+
} else if (e.isFile() && !fs.existsSync(to)) {
|
|
68
|
+
fs.copyFileSync(from, to);
|
|
69
|
+
seeded.push(to);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
|
|
76
|
+
* Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
|
|
77
|
+
* package updates — that is the whole point). home === package root → a no-op (the git-clone
|
|
78
|
+
* layout is already complete). Throws only if the home cannot be created at all.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
|
|
81
|
+
* off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity — an
|
|
82
|
+
* npm-wrap ships its OWN copy inside its bundled home).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} home absolute config-home path (from resolveConfigHome)
|
|
85
|
+
* @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty = was complete)
|
|
86
|
+
*/
|
|
87
|
+
function ensureConfigHome(home) {
|
|
88
|
+
const seeded = [];
|
|
89
|
+
if (path.resolve(home) === PKG_ROOT) return { home: PKG_ROOT, seeded };
|
|
90
|
+
fs.mkdirSync(home, { recursive: true });
|
|
91
|
+
|
|
92
|
+
// The register + every shipped script (management tf_* + demo tools). The management scripts
|
|
93
|
+
// are config-tree citizens (the register's invoke paths point at them), so they travel.
|
|
94
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
|
|
95
|
+
// The state overlay is user-state, not a shipped default — drop a copied one if the package
|
|
96
|
+
// tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
|
|
97
|
+
const strayState = path.join(home, 'tools', 'tools.state.json');
|
|
98
|
+
if (seeded.includes(strayState)) {
|
|
99
|
+
try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
|
|
100
|
+
seeded.splice(seeded.indexOf(strayState), 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The upstream/curation store and the hooks tree. NOTE the npm tarball ships
|
|
104
|
+
// mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
|
|
105
|
+
// tree copy the live store is synthesised EMPTY when absent. Never seed it from the example —
|
|
106
|
+
// the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
|
|
107
|
+
// connected to nothing, exactly like a fresh clone.
|
|
108
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
|
|
109
|
+
const exposePath = path.join(home, 'mcp', 'expose.json');
|
|
110
|
+
if (!fs.existsSync(exposePath)) {
|
|
111
|
+
fs.mkdirSync(path.dirname(exposePath), { recursive: true });
|
|
112
|
+
fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
|
|
113
|
+
seeded.push(exposePath);
|
|
114
|
+
}
|
|
115
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
|
|
116
|
+
return { home, seeded };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
|
|
121
|
+
* process-tree contract back into the env (see the header) so every module loaded AFTER this —
|
|
122
|
+
* and every child tool spawned later — reads the same resolved home.
|
|
123
|
+
* @param {{dir?: string}} [opts]
|
|
124
|
+
* @returns {{home: string, seeded: string[]}}
|
|
125
|
+
*/
|
|
126
|
+
function initConfigHome(opts) {
|
|
127
|
+
const home = resolveConfigHome(opts);
|
|
128
|
+
const res = ensureConfigHome(home);
|
|
129
|
+
process.env.TOOLFUNNEL_HOME = res.home;
|
|
130
|
+
process.env.TOOLFUNNEL_PKG = PKG_ROOT;
|
|
131
|
+
return res;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
|
package/src/core/logger.js
CHANGED
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
const fs = require('node:fs');
|
|
28
28
|
const path = require('node:path');
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
|
|
31
|
+
* config-home.js). Logs + their toggle are user-state, so they live with the home. */
|
|
32
|
+
const { resolveConfigHome } = require('./config-home');
|
|
33
|
+
const ROOT = resolveConfigHome();
|
|
32
34
|
|
|
33
35
|
/** The toggle/config file. NOT created until setConfig() writes it. */
|
|
34
36
|
const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* requires.js — runtime-requirement preflight (the packaging story's honesty layer).
|
|
5
|
+
*
|
|
6
|
+
* A shipped pack's tools may need runtimes the gateway itself does not (python for a PDF tool,
|
|
7
|
+
* git for a repo tool). The pack DECLARES them in its toolfunnel.json — the identity file that
|
|
8
|
+
* already travels with every config home — and the gateway probes them ONCE at startup:
|
|
9
|
+
*
|
|
10
|
+
* "requires": [
|
|
11
|
+
* { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
|
|
12
|
+
* { "command": "git" }
|
|
13
|
+
* ]
|
|
14
|
+
*
|
|
15
|
+
* Probes are ADVISORY, never fatal: a missing runtime breaks only the tools that need it, so the
|
|
16
|
+
* gateway starts and the operator gets a clear stderr line naming what is missing, the version
|
|
17
|
+
* found vs needed, and the declared "why". Fields per entry (only `command` is required):
|
|
18
|
+
* command the executable to probe (e.g. "python", "node", "git")
|
|
19
|
+
* versionArg the flag that prints a version (default "--version")
|
|
20
|
+
* min minimum version, dotted numerics (e.g. "3.10" / "18" / "3.10.2")
|
|
21
|
+
* why shown in the warning so the operator knows which tools care
|
|
22
|
+
*
|
|
23
|
+
* Zero new dependencies: child_process probes + a hand-rolled dotted-numeric compare. NO semver
|
|
24
|
+
* library — by the packaging hard constraint, and dotted numerics cover real runtime versions.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
|
|
31
|
+
/** Parse the FIRST dotted-numeric version out of a probe's output ("Python 3.12.1" → [3,12,1]).
|
|
32
|
+
* Returns null when no version-shaped token is present. */
|
|
33
|
+
function parseVersion(text) {
|
|
34
|
+
const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text || ''));
|
|
35
|
+
if (!m) {
|
|
36
|
+
const single = /\b(\d+)\b/.exec(String(text || ''));
|
|
37
|
+
return single ? [Number(single[1]), 0, 0] : null;
|
|
38
|
+
}
|
|
39
|
+
return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Hand-rolled dotted-numeric compare: -1 | 0 | 1 for a<b | a==b | a>b. */
|
|
43
|
+
function compareVersions(a, b) {
|
|
44
|
+
for (let i = 0; i < 3; i += 1) {
|
|
45
|
+
const x = a[i] || 0;
|
|
46
|
+
const y = b[i] || 0;
|
|
47
|
+
if (x < y) return -1;
|
|
48
|
+
if (x > y) return 1;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Probe one command for its version string. On Windows a PATHEXT shim (a .cmd like npx) cannot be
|
|
55
|
+
* spawned shell:false (ENOENT/EINVAL — the CVE-2024-27980 mitigation), so a failed direct probe is
|
|
56
|
+
* retried through cmd.exe, mirroring mcp-client's winLaunch.
|
|
57
|
+
* @param {string} command
|
|
58
|
+
* @param {string} versionArg
|
|
59
|
+
* @returns {{found: boolean, version: (number[]|null), raw: string}}
|
|
60
|
+
*/
|
|
61
|
+
function probe(command, versionArg) {
|
|
62
|
+
const runOnce = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
|
|
63
|
+
let res = runOnce(command, [versionArg]);
|
|
64
|
+
if (res.error && process.platform === 'win32') {
|
|
65
|
+
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
66
|
+
res = runOnce(comspec, ['/c', command, versionArg]);
|
|
67
|
+
}
|
|
68
|
+
if (res.error || res.status == null) return { found: false, version: null, raw: '' };
|
|
69
|
+
const raw = `${res.stdout || ''} ${res.stderr || ''}`.trim();
|
|
70
|
+
const version = parseVersion(raw);
|
|
71
|
+
// "Spawned" is not "exists": the cmd.exe fallback launches fine for a NONEXISTENT command and
|
|
72
|
+
// exits non-zero ("is not recognized…"). Existence = a clean exit OR a version-shaped answer.
|
|
73
|
+
const found = res.status === 0 || version !== null;
|
|
74
|
+
return { found, version: found ? version : null, raw };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check the `requires` declared in `<home>/toolfunnel.json`. Returns the problem list — the
|
|
79
|
+
* caller decides how to surface it (the gateway logs each to stderr and starts anyway). NEVER
|
|
80
|
+
* throws; a malformed requires entry is itself reported as a problem rather than crashing.
|
|
81
|
+
* @param {string} home the resolved config home
|
|
82
|
+
* @returns {Array<{command:string, problem:string}>}
|
|
83
|
+
*/
|
|
84
|
+
function checkRequires(home) {
|
|
85
|
+
let cfg;
|
|
86
|
+
try {
|
|
87
|
+
cfg = JSON.parse(fs.readFileSync(path.join(home, 'toolfunnel.json'), 'utf8'));
|
|
88
|
+
} catch (_e) {
|
|
89
|
+
return []; // absent/bad identity file → nothing declared
|
|
90
|
+
}
|
|
91
|
+
const list = cfg && Array.isArray(cfg.requires) ? cfg.requires : [];
|
|
92
|
+
const problems = [];
|
|
93
|
+
for (const entry of list) {
|
|
94
|
+
if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim() === '') {
|
|
95
|
+
problems.push({ command: String(entry && entry.command), problem: 'malformed requires entry (needs a "command" string)' });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const command = entry.command.trim();
|
|
99
|
+
// A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
|
|
100
|
+
// Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
|
|
101
|
+
// metacharacter ("x&evil") would CHAIN commands — a probe must never be able to run anything
|
|
102
|
+
// beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
|
|
103
|
+
// declare tools by PATH name, which is the only portable claim a pack can make anyway).
|
|
104
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
|
|
105
|
+
problems.push({ command, problem: `requires.command "${command}" is not a bare program name (no paths, spaces, or shell characters)` });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const why = typeof entry.why === 'string' && entry.why ? ` (needed for: ${entry.why})` : '';
|
|
109
|
+
const versionArg = typeof entry.versionArg === 'string' && /^--?[A-Za-z][A-Za-z-]*$/.test(entry.versionArg)
|
|
110
|
+
? entry.versionArg
|
|
111
|
+
: '--version'; // same rule: a version FLAG, never a shell fragment
|
|
112
|
+
const p = probe(command, versionArg);
|
|
113
|
+
if (!p.found) {
|
|
114
|
+
problems.push({ command, problem: `"${command}" was not found on PATH${why}` });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (typeof entry.min === 'string' && entry.min.trim() !== '') {
|
|
118
|
+
const min = parseVersion(entry.min);
|
|
119
|
+
if (!min) {
|
|
120
|
+
problems.push({ command, problem: `requires.min "${entry.min}" is not a dotted numeric version` });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!p.version) {
|
|
124
|
+
problems.push({ command, problem: `"${command}" answered but printed no recognisable version (wanted >= ${entry.min})${why}` });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (compareVersions(p.version, min) < 0) {
|
|
128
|
+
problems.push({
|
|
129
|
+
command,
|
|
130
|
+
problem: `"${command}" is ${p.version.join('.')} but this setup declares >= ${entry.min}${why}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return problems;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { checkRequires, parseVersion, compareVersions, probe };
|