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.
Files changed (40) hide show
  1. package/README.md +167 -324
  2. package/bin/toolfunnel.js +39 -4
  3. package/docs/KNOWN_BUGS.md +33 -0
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/packaging.md +115 -0
  6. package/package.json +3 -1
  7. package/src/auth/config.js +4 -2
  8. package/src/auth/resource-server.js +28 -3
  9. package/src/core/config-home.js +134 -0
  10. package/src/core/logger.js +4 -2
  11. package/src/core/requires.js +138 -0
  12. package/src/core/server-config.js +77 -0
  13. package/src/extend/create-tool.md +24 -4
  14. package/src/extend/package.md +33 -0
  15. package/src/mcp/aggregator.js +12 -7
  16. package/src/mcp/http-transport.js +71 -21
  17. package/src/mcp/mcp-client.js +9 -3
  18. package/src/mcp/server.js +181 -16
  19. package/src/packages/packager.js +5 -3
  20. package/src/tools/registry.js +47 -2
  21. package/src/ui/server.js +49 -3
  22. package/tools/scripts/tf-env.js +43 -0
  23. package/tools/scripts/tf-hook-add.js +4 -5
  24. package/tools/scripts/tf-hook-set.js +4 -5
  25. package/tools/scripts/tf-list.js +9 -12
  26. package/tools/scripts/tf-log.js +4 -1
  27. package/tools/scripts/tf-mcp-add.js +4 -6
  28. package/tools/scripts/tf-mcp-set.js +4 -6
  29. package/tools/scripts/tf-pack.js +237 -0
  30. package/tools/scripts/tf-tool-add.js +16 -16
  31. package/tools/scripts/tf-tool-set.js +5 -11
  32. package/tools/tools.register.json +46 -1
  33. package/docs/admin-ui.md +0 -404
  34. package/docs/configuration.md +0 -402
  35. package/docs/hooks-and-gating.md +0 -428
  36. package/docs/logging.md +0 -223
  37. package/docs/management.md +0 -429
  38. package/docs/oauth.md +0 -103
  39. package/docs/setup.md +0 -352
  40. 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
- const parsedPort = parseInt(flagValue('--port', '9777'), 10);
73
- const port = Number.isInteger(parsedPort) ? parsedPort : 9777;
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
- const parsedPort = parseInt(flagValue('--port', '9998'), 10);
93
- const port = Number.isInteger(parsedPort) ? parsedPort : 9998;
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.
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.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": {},
@@ -36,8 +36,10 @@
36
36
  const fs = require('node:fs');
37
37
  const path = require('node:path');
38
38
 
39
- /** Repo root: <root>/src/auth/config.js -> two dirs up. */
40
- const ROOT = path.resolve(__dirname, '..', '..');
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) return doc.jwks_uri;
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
- /** GET + parse JSON with a hard timeout. Throws on non-2xx, network error, or bad JSON. */
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: 'follow' });
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 };
@@ -27,8 +27,10 @@
27
27
  const fs = require('node:fs');
28
28
  const path = require('node:path');
29
29
 
30
- /** Repo root: <root>/src/core/logger.js -> two dirs up. */
31
- const ROOT = path.resolve(__dirname, '..', '..');
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 };