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.
Files changed (78) hide show
  1. package/README.md +102 -213
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -225
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -981
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1322
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -511
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
  70. package/docs/admin-ui.md +0 -404
  71. package/docs/configuration.md +0 -402
  72. package/docs/hooks-and-gating.md +0 -428
  73. package/docs/logging.md +0 -223
  74. package/docs/management.md +0 -429
  75. package/docs/oauth.md +0 -103
  76. package/docs/releasing.md +0 -31
  77. package/docs/setup.md +0 -352
  78. package/docs/tools-and-upstreams.md +0 -469
@@ -1,237 +1,237 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * tf-pack.js — a first-party MANAGEMENT tool that packages the LIVE setup for deployment.
6
- *
7
- * Purpose
8
- * -------
9
- * Everything you built in this gateway — your tools + scripts, your hot/hidden curation, your
10
- * upstream selections, your policy hooks, your identity — IS a config home. This tool snapshots
11
- * it into a deployment-ready artifact in a SEPARATE location (never the live tree), so "ship what
12
- * I made" is one call instead of a hand-rolled copy job:
13
- *
14
- * format "home" (default): <home>/dist/<name>/ = a portable config home. Zip it, git-init it,
15
- * or point any toolfunnel at it via --config-dir / TOOLFUNNEL_HOME.
16
- * format "npm": <home>/dist/<name>/ = a PUBLISHABLE npm package: package.json (with toolfunnel
17
- * as a caret DEPENDENCY — depend, never copy: your users' installs count as toolfunnel
18
- * downloads and our fixes reach them via npm update), a 2-line bin launcher pointing
19
- * --config-dir at the bundled home/, and a README stub. `cd dist/<name> && npm publish` is
20
- * your own MCP server on npm.
21
- *
22
- * Args (env TOOLFUNNEL_TOOL_ARGS, mirrors the other tf-* scripts):
23
- * { format?: 'home'|'npm' (default 'home'),
24
- * name?: string, // pack/package name (default: the home's serverName, else 'my-mcp')
25
- * version?: string, // default: the home's explicit serverVersion, else '0.1.0'
26
- * description?: string, // npm format: package.json description
27
- * out?: string, // FOLDER NAME under <home>/dist/ (basename only; default: name)
28
- * force?: boolean } // overwrite files in an existing non-empty out dir
29
- *
30
- * What travels: tools/ (register + state overlay + scripts — the hot/hidden curation IS the
31
- * product), mcp/ (expose.json upstream references + any vendored servers), hooks/ (THE GATE
32
- * TRAVELS — the shipped pack enforces its policy on the recipient's machine regardless of
33
- * client), toolfunnel.json (identity + requires; rewritten with the pack's name/version).
34
- * What NEVER travels: auth/ (environment-specific OAuth config), logs/, dist/ (recursion),
35
- * packages/, node_modules, .git.
36
- *
37
- * Output (stdout), exactly one JSON object:
38
- * success: { ok:true, format, out, files, next:[…] }
39
- * failure: { ok:false, error }
40
- * Safety: writes ONLY under <home>/dist/<basename> (the live config is read, never touched);
41
- * refuses a non-empty destination without force:true; ALWAYS exits 0.
42
- */
43
-
44
- const fs = require('node:fs');
45
- const path = require('node:path');
46
- // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
47
- const { HOME, PKG } = require('./tf-env');
48
-
49
- const EXCLUDE_DIRS = new Set(['dist', 'logs', 'auth', 'packages', 'node_modules', '.git', '.idea', '.vscode']);
50
-
51
- function parseStructuredArgs() {
52
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
53
- if (raw === undefined || raw === null || raw === '') return { value: {} };
54
- try {
55
- return { value: JSON.parse(raw) };
56
- } catch (_err) {
57
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
58
- }
59
- }
60
-
61
- /** The home's toolfunnel.json as raw JSON (or {}). Read directly — we want the EXPLICIT fields,
62
- * not the loader's folded-in defaults (a pack must not inherit toolfunnel's own version). */
63
- function homeIdentity() {
64
- try {
65
- const d = JSON.parse(fs.readFileSync(path.join(HOME, 'toolfunnel.json'), 'utf8'));
66
- return d && typeof d === 'object' && !Array.isArray(d) ? d : {};
67
- } catch (_e) {
68
- return {};
69
- }
70
- }
71
-
72
- /** npm-legal name: lowercase, spaces/illegals → '-', collapsed. */
73
- function sanitizeName(name) {
74
- const s = String(name).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-._]+|[-._]+$/g, '');
75
- return s || 'my-mcp';
76
- }
77
-
78
- /** Recursively copy `srcDir` → `destDir`, skipping EXCLUDE_DIRS at the top level and dotfiles
79
- * everywhere. Overwrites only when `force`. Returns the number of files written. */
80
- function copyTree(srcDir, destDir, force, topLevel) {
81
- let count = 0;
82
- let entries;
83
- try {
84
- entries = fs.readdirSync(srcDir, { withFileTypes: true });
85
- } catch (_e) {
86
- return count;
87
- }
88
- fs.mkdirSync(destDir, { recursive: true });
89
- for (const e of entries) {
90
- if (topLevel && EXCLUDE_DIRS.has(e.name)) continue;
91
- if (e.name.startsWith('.')) continue;
92
- const from = path.join(srcDir, e.name);
93
- const to = path.join(destDir, e.name);
94
- if (e.isDirectory()) {
95
- count += copyTree(from, to, force, false);
96
- } else if (e.isFile()) {
97
- if (!force && fs.existsSync(to)) continue;
98
- fs.copyFileSync(from, to);
99
- count += 1;
100
- }
101
- }
102
- return count;
103
- }
104
-
105
- /** The 4 config pillars → destHome. Identity is rewritten (name/version/description overlay). */
106
- function snapshotHome(destHome, force, identity) {
107
- let files = 0;
108
- for (const dir of ['tools', 'mcp', 'hooks']) {
109
- files += copyTree(path.join(HOME, dir), path.join(destHome, dir), force, true);
110
- }
111
- fs.writeFileSync(path.join(destHome, 'toolfunnel.json'), JSON.stringify(identity, null, 2) + '\n');
112
- return files + 1;
113
- }
114
-
115
- function run(args) {
116
- const a = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
117
- const format = a.format === 'npm' ? 'npm' : a.format === 'home' || a.format === undefined ? 'home' : null;
118
- if (format === null) return { ok: false, error: 'format must be "home" or "npm"' };
119
- const force = a.force === true;
120
-
121
- const id = homeIdentity();
122
- const name = sanitizeName(a.name || id.serverName || 'my-mcp');
123
- const version = typeof a.version === 'string' && a.version.trim() ? a.version.trim()
124
- : (typeof id.serverVersion === 'string' && id.serverVersion.trim() ? id.serverVersion.trim() : '0.1.0');
125
- const description = typeof a.description === 'string' && a.description.trim()
126
- ? a.description.trim()
127
- : `${name} — an MCP server built on toolfunnel.`;
128
-
129
- // The destination: ALWAYS <home>/dist/<basename> — the separate-location guarantee. `out` is a
130
- // folder NAME (basename strips traversal), never a path, so the live tree can't be targeted.
131
- const outName = path.basename(String(a.out || name));
132
- const outDir = path.join(HOME, 'dist', outName);
133
- const rel = path.relative(path.join(HOME, 'dist'), outDir);
134
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
135
- return { ok: false, error: `out "${a.out}" escapes <home>/dist` };
136
- }
137
- if (!force && fs.existsSync(outDir) && fs.readdirSync(outDir).length > 0) {
138
- return { ok: false, error: `destination is not empty: ${outDir} — pass force:true to overwrite (files are replaced, extras are kept)` };
139
- }
140
-
141
- // The identity the pack CARRIES (preserves requires + any custom fields; stamps name/version).
142
- const identity = Object.assign({}, id, { serverName: name, serverVersion: version });
143
-
144
- let files = 0;
145
- const next = [];
146
- if (format === 'home') {
147
- files = snapshotHome(outDir, force, identity);
148
- next.push(`portable config home written — zip it, git-init it, or run: toolfunnel --config-dir "${outDir}"`);
149
- } else {
150
- // npm: bundled home + generated package.json + 2-line bin + README stub.
151
- files = snapshotHome(path.join(outDir, 'home'), force, identity);
152
-
153
- let tfVersion = '0.4.0';
154
- try { tfVersion = require(path.join(PKG, 'package.json')).version || tfVersion; } catch (_e) { /* keep fallback */ }
155
- const envOverride = name.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_HOME';
156
-
157
- const pkgJson = {
158
- name,
159
- version,
160
- description,
161
- license: 'MIT',
162
- type: 'commonjs',
163
- bin: { [name]: `bin/${name}.js` },
164
- files: ['bin/', 'home/'],
165
- // DEPEND, never copy: installs of this package count as toolfunnel downloads, and
166
- // toolfunnel's fixes reach it through a normal `npm update` instead of a stale fork.
167
- dependencies: { toolfunnel: `^${tfVersion}` },
168
- engines: { node: '>=18' },
169
- };
170
- fs.writeFileSync(path.join(outDir, 'package.json'), JSON.stringify(pkgJson, null, 2) + '\n');
171
-
172
- const binDir = path.join(outDir, 'bin');
173
- fs.mkdirSync(binDir, { recursive: true });
174
- fs.writeFileSync(path.join(binDir, `${name}.js`), [
175
- '#!/usr/bin/env node',
176
- "'use strict';",
177
- `// ${name} — an MCP server built on toolfunnel, launched against its own bundled config home.`,
178
- `// Set ${envOverride} to run from an external copy of the home (survives npm updates).`,
179
- "const path = require('node:path');",
180
- `const home = process.env.${envOverride} || path.join(__dirname, '..', 'home');`,
181
- "process.argv.splice(2, 0, '--config-dir', home);",
182
- "require('toolfunnel/bin/toolfunnel.js');",
183
- '',
184
- ].join('\n'));
185
-
186
- const readmePath = path.join(outDir, 'README.md');
187
- if (force || !fs.existsSync(readmePath)) {
188
- fs.writeFileSync(readmePath, [
189
- `# ${name}`,
190
- '',
191
- description,
192
- '',
193
- 'An MCP server packaged with [toolfunnel](https://github.com/Rendeverance/toolfunnel) —',
194
- 'the bundled `home/` carries the tools, curation, upstream references, AND the policy',
195
- 'hooks: the gate travels with the pack and enforces itself on any MCP client.',
196
- '',
197
- '## Run',
198
- '```bash',
199
- `npx ${name}`,
200
- '```',
201
- '',
202
- '## Use an external home (survives npm updates)',
203
- `Set \`${envOverride}\` to a directory holding a copy of \`home/\`.`,
204
- '',
205
- '## Audit honesty',
206
- 'Packs spawn commands. Read `home/mcp/expose.json`, `home/tools/tools.register.json`,',
207
- 'and `home/toolfunnel.json` (its `requires` probes run at startup) of anything you',
208
- 'install — including this.',
209
- '',
210
- ].join('\n'));
211
- files += 1;
212
- }
213
- files += 2; // package.json + bin
214
- next.push(`publishable npm package written — review it, then: cd "${outDir}" && npm publish`);
215
- next.push(`try it locally first: node "${path.join(outDir, 'bin', `${name}.js`)}"`);
216
- }
217
-
218
- return { ok: true, format, out: outDir, files, next };
219
- }
220
-
221
- function main() {
222
- const parsed = parseStructuredArgs();
223
- let payload;
224
- if (parsed.parseError) {
225
- payload = { ok: false, error: parsed.parseError };
226
- } else {
227
- try {
228
- payload = run(parsed.value);
229
- } catch (err) {
230
- payload = { ok: false, error: (err && err.message) || String(err) };
231
- }
232
- }
233
- process.stdout.write(JSON.stringify(payload) + '\n');
234
- process.exit(0);
235
- }
236
-
237
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * tf-pack.js - a first-party MANAGEMENT tool that packages the LIVE setup for deployment.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * Everything you built in this gateway - your tools + scripts, your hot/hidden curation, your
10
+ * upstream selections, your policy hooks, your identity - IS a config home. This tool snapshots
11
+ * it into a deployment-ready artifact in a SEPARATE location (never the live tree), so "ship what
12
+ * I made" is one call instead of a hand-rolled copy job:
13
+ *
14
+ * format "home" (default): <home>/dist/<name>/ = a portable config home. Zip it, git-init it,
15
+ * or point any toolfunnel at it via --config-dir / TOOLFUNNEL_HOME.
16
+ * format "npm": <home>/dist/<name>/ = a PUBLISHABLE npm package: package.json (with toolfunnel
17
+ * as a caret DEPENDENCY - depend, never copy: your users' installs count as toolfunnel
18
+ * downloads and our fixes reach them via npm update), a 2-line bin launcher pointing
19
+ * --config-dir at the bundled home/, and a README stub. `cd dist/<name> && npm publish` is
20
+ * your own MCP server on npm.
21
+ *
22
+ * Args (env TOOLFUNNEL_TOOL_ARGS, mirrors the other tf-* scripts):
23
+ * { format?: 'home'|'npm' (default 'home'),
24
+ * name?: string, // pack/package name (default: the home's serverName, else 'my-mcp')
25
+ * version?: string, // default: the home's explicit serverVersion, else '0.1.0'
26
+ * description?: string, // npm format: package.json description
27
+ * out?: string, // FOLDER NAME under <home>/dist/ (basename only; default: name)
28
+ * force?: boolean } // overwrite files in an existing non-empty out dir
29
+ *
30
+ * What travels: tools/ (register + state overlay + scripts - the hot/hidden curation IS the
31
+ * product), mcp/ (expose.json upstream references + any vendored servers), hooks/ (THE GATE
32
+ * TRAVELS - the shipped pack enforces its policy on the recipient's machine regardless of
33
+ * client), toolfunnel.json (identity + requires; rewritten with the pack's name/version).
34
+ * What NEVER travels: auth/ (environment-specific OAuth config), logs/, dist/ (recursion),
35
+ * packages/, node_modules, .git.
36
+ *
37
+ * Output (stdout), exactly one JSON object:
38
+ * success: { ok:true, format, out, files, next:[...] }
39
+ * failure: { ok:false, error }
40
+ * Safety: writes ONLY under <home>/dist/<basename> (the live config is read, never touched);
41
+ * refuses a non-empty destination without force:true; ALWAYS exits 0.
42
+ */
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
47
+ const { HOME, PKG } = require('./tf-env');
48
+
49
+ const EXCLUDE_DIRS = new Set(['dist', 'logs', 'auth', 'packages', 'node_modules', '.git', '.idea', '.vscode']);
50
+
51
+ function parseStructuredArgs() {
52
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
53
+ if (raw === undefined || raw === null || raw === '') return { value: {} };
54
+ try {
55
+ return { value: JSON.parse(raw) };
56
+ } catch (_err) {
57
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
58
+ }
59
+ }
60
+
61
+ /** The home's toolfunnel.json as raw JSON (or {}). Read directly - we want the EXPLICIT fields,
62
+ * not the loader's folded-in defaults (a pack must not inherit toolfunnel's own version). */
63
+ function homeIdentity() {
64
+ try {
65
+ const d = JSON.parse(fs.readFileSync(path.join(HOME, 'toolfunnel.json'), 'utf8'));
66
+ return d && typeof d === 'object' && !Array.isArray(d) ? d : {};
67
+ } catch (_e) {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ /** npm-legal name: lowercase, spaces/illegals -> '-', collapsed. */
73
+ function sanitizeName(name) {
74
+ const s = String(name).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-._]+|[-._]+$/g, '');
75
+ return s || 'my-mcp';
76
+ }
77
+
78
+ /** Recursively copy `srcDir` -> `destDir`, skipping EXCLUDE_DIRS at the top level and dotfiles
79
+ * everywhere. Overwrites only when `force`. Returns the number of files written. */
80
+ function copyTree(srcDir, destDir, force, topLevel) {
81
+ let count = 0;
82
+ let entries;
83
+ try {
84
+ entries = fs.readdirSync(srcDir, { withFileTypes: true });
85
+ } catch (_e) {
86
+ return count;
87
+ }
88
+ fs.mkdirSync(destDir, { recursive: true });
89
+ for (const e of entries) {
90
+ if (topLevel && EXCLUDE_DIRS.has(e.name)) continue;
91
+ if (e.name.startsWith('.')) continue;
92
+ const from = path.join(srcDir, e.name);
93
+ const to = path.join(destDir, e.name);
94
+ if (e.isDirectory()) {
95
+ count += copyTree(from, to, force, false);
96
+ } else if (e.isFile()) {
97
+ if (!force && fs.existsSync(to)) continue;
98
+ fs.copyFileSync(from, to);
99
+ count += 1;
100
+ }
101
+ }
102
+ return count;
103
+ }
104
+
105
+ /** The 4 config pillars -> destHome. Identity is rewritten (name/version/description overlay). */
106
+ function snapshotHome(destHome, force, identity) {
107
+ let files = 0;
108
+ for (const dir of ['tools', 'mcp', 'hooks']) {
109
+ files += copyTree(path.join(HOME, dir), path.join(destHome, dir), force, true);
110
+ }
111
+ fs.writeFileSync(path.join(destHome, 'toolfunnel.json'), JSON.stringify(identity, null, 2) + '\n');
112
+ return files + 1;
113
+ }
114
+
115
+ function run(args) {
116
+ const a = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
117
+ const format = a.format === 'npm' ? 'npm' : a.format === 'home' || a.format === undefined ? 'home' : null;
118
+ if (format === null) return { ok: false, error: 'format must be "home" or "npm"' };
119
+ const force = a.force === true;
120
+
121
+ const id = homeIdentity();
122
+ const name = sanitizeName(a.name || id.serverName || 'my-mcp');
123
+ const version = typeof a.version === 'string' && a.version.trim() ? a.version.trim()
124
+ : (typeof id.serverVersion === 'string' && id.serverVersion.trim() ? id.serverVersion.trim() : '0.1.0');
125
+ const description = typeof a.description === 'string' && a.description.trim()
126
+ ? a.description.trim()
127
+ : `${name} - an MCP server built on toolfunnel.`;
128
+
129
+ // The destination: ALWAYS <home>/dist/<basename> - the separate-location guarantee. `out` is a
130
+ // folder NAME (basename strips traversal), never a path, so the live tree can't be targeted.
131
+ const outName = path.basename(String(a.out || name));
132
+ const outDir = path.join(HOME, 'dist', outName);
133
+ const rel = path.relative(path.join(HOME, 'dist'), outDir);
134
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
135
+ return { ok: false, error: `out "${a.out}" escapes <home>/dist` };
136
+ }
137
+ if (!force && fs.existsSync(outDir) && fs.readdirSync(outDir).length > 0) {
138
+ return { ok: false, error: `destination is not empty: ${outDir} - pass force:true to overwrite (files are replaced, extras are kept)` };
139
+ }
140
+
141
+ // The identity the pack CARRIES (preserves requires + any custom fields; stamps name/version).
142
+ const identity = Object.assign({}, id, { serverName: name, serverVersion: version });
143
+
144
+ let files = 0;
145
+ const next = [];
146
+ if (format === 'home') {
147
+ files = snapshotHome(outDir, force, identity);
148
+ next.push(`portable config home written - zip it, git-init it, or run: toolfunnel --config-dir "${outDir}"`);
149
+ } else {
150
+ // npm: bundled home + generated package.json + 2-line bin + README stub.
151
+ files = snapshotHome(path.join(outDir, 'home'), force, identity);
152
+
153
+ let tfVersion = '0.4.0';
154
+ try { tfVersion = require(path.join(PKG, 'package.json')).version || tfVersion; } catch (_e) { /* keep fallback */ }
155
+ const envOverride = name.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_HOME';
156
+
157
+ const pkgJson = {
158
+ name,
159
+ version,
160
+ description,
161
+ license: 'MIT',
162
+ type: 'commonjs',
163
+ bin: { [name]: `bin/${name}.js` },
164
+ files: ['bin/', 'home/'],
165
+ // DEPEND, never copy: installs of this package count as toolfunnel downloads, and
166
+ // toolfunnel's fixes reach it through a normal `npm update` instead of a stale fork.
167
+ dependencies: { toolfunnel: `^${tfVersion}` },
168
+ engines: { node: '>=18' },
169
+ };
170
+ fs.writeFileSync(path.join(outDir, 'package.json'), JSON.stringify(pkgJson, null, 2) + '\n');
171
+
172
+ const binDir = path.join(outDir, 'bin');
173
+ fs.mkdirSync(binDir, { recursive: true });
174
+ fs.writeFileSync(path.join(binDir, `${name}.js`), [
175
+ '#!/usr/bin/env node',
176
+ "'use strict';",
177
+ `// ${name} - an MCP server built on toolfunnel, launched against its own bundled config home.`,
178
+ `// Set ${envOverride} to run from an external copy of the home (survives npm updates).`,
179
+ "const path = require('node:path');",
180
+ `const home = process.env.${envOverride} || path.join(__dirname, '..', 'home');`,
181
+ "process.argv.splice(2, 0, '--config-dir', home);",
182
+ "require('toolfunnel/bin/toolfunnel.js');",
183
+ '',
184
+ ].join('\n'));
185
+
186
+ const readmePath = path.join(outDir, 'README.md');
187
+ if (force || !fs.existsSync(readmePath)) {
188
+ fs.writeFileSync(readmePath, [
189
+ `# ${name}`,
190
+ '',
191
+ description,
192
+ '',
193
+ 'An MCP server packaged with [toolfunnel](https://github.com/Rendeverance/toolfunnel) -',
194
+ 'the bundled `home/` carries the tools, curation, upstream references, AND the policy',
195
+ 'hooks: the gate travels with the pack and enforces itself on any MCP client.',
196
+ '',
197
+ '## Run',
198
+ '```bash',
199
+ `npx ${name}`,
200
+ '```',
201
+ '',
202
+ '## Use an external home (survives npm updates)',
203
+ `Set \`${envOverride}\` to a directory holding a copy of \`home/\`.`,
204
+ '',
205
+ '## Audit honesty',
206
+ 'Packs spawn commands. Read `home/mcp/expose.json`, `home/tools/tools.register.json`,',
207
+ 'and `home/toolfunnel.json` (its `requires` probes run at startup) of anything you',
208
+ 'install - including this.',
209
+ '',
210
+ ].join('\n'));
211
+ files += 1;
212
+ }
213
+ files += 2; // package.json + bin
214
+ next.push(`publishable npm package written - review it, then: cd "${outDir}" && npm publish`);
215
+ next.push(`try it locally first: node "${path.join(outDir, 'bin', `${name}.js`)}"`);
216
+ }
217
+
218
+ return { ok: true, format, out: outDir, files, next };
219
+ }
220
+
221
+ function main() {
222
+ const parsed = parseStructuredArgs();
223
+ let payload;
224
+ if (parsed.parseError) {
225
+ payload = { ok: false, error: parsed.parseError };
226
+ } else {
227
+ try {
228
+ payload = run(parsed.value);
229
+ } catch (err) {
230
+ payload = { ok: false, error: (err && err.message) || String(err) };
231
+ }
232
+ }
233
+ process.stdout.write(JSON.stringify(payload) + '\n');
234
+ process.exit(0);
235
+ }
236
+
237
+ main();