toolfunnel 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -167
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -1012
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1405
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
package/src/core/requires.js
CHANGED
|
@@ -1,138 +1,138 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* requires.js
|
|
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
|
|
8
|
-
* already travels with every config home
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 };
|
|
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 };
|
|
@@ -1,77 +1,98 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* server-config.js
|
|
5
|
-
*
|
|
6
|
-
* Reads `toolfunnel.json` at the gateway root (absent by default). This is what lets a
|
|
7
|
-
* third party ship an MCP built on toolfunnel that introduces itself as ITSELF: the
|
|
8
|
-
* `initialize` handshake and `/health` report THEIR serverName/serverVersion, and their
|
|
9
|
-
* pack can carry its own default ports so `npx their-mcp` never collides with another
|
|
10
|
-
* gateway on the user's machine. CLI flags still win: precedence is flag > config > default.
|
|
11
|
-
*
|
|
12
|
-
* Shape (every field optional
|
|
13
|
-
* {
|
|
14
|
-
* "serverName": "my-mcp", // initialize serverInfo.name (default "toolfunnel")
|
|
15
|
-
* "serverVersion": "1.0.0", // initialize serverInfo.version (default: package.json)
|
|
16
|
-
* "
|
|
17
|
-
* "
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* server-config.js - OPTIONAL gateway-level configuration: identity + default ports.
|
|
5
|
+
*
|
|
6
|
+
* Reads `toolfunnel.json` at the gateway root (absent by default). This is what lets a
|
|
7
|
+
* third party ship an MCP built on toolfunnel that introduces itself as ITSELF: the
|
|
8
|
+
* `initialize` handshake and `/health` report THEIR serverName/serverVersion, and their
|
|
9
|
+
* pack can carry its own default ports so `npx their-mcp` never collides with another
|
|
10
|
+
* gateway on the user's machine. CLI flags still win: precedence is flag > config > default.
|
|
11
|
+
*
|
|
12
|
+
* Shape (every field optional - an empty/absent file IS the default identity):
|
|
13
|
+
* {
|
|
14
|
+
* "serverName": "my-mcp", // initialize serverInfo.name (default "toolfunnel")
|
|
15
|
+
* "serverVersion": "1.0.0", // initialize serverInfo.version (default: package.json)
|
|
16
|
+
* "clientName": "my-client", // identity presented TO upstreams (default "toolfunnel")
|
|
17
|
+
* "clientVersion": "1.0.0", // ...its version (default built-in)
|
|
18
|
+
* "httpPort": 9998, // --http default port (default 9998)
|
|
19
|
+
* "uiPort": 9777 // --ui default port (default 9777)
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* clientName/clientVersion are the OUTBOUND identity - what upstream MCP servers see in the
|
|
23
|
+
* gateway's own initialize handshake. Under a WRAP on stdio the downstream client's REAL
|
|
24
|
+
* clientInfo is mirrored upstream instead (two-way invisibility for legacy/stdio clients -
|
|
25
|
+
* modern clients send no initialize); these settings are the funnel-mode / HTTP identity and
|
|
26
|
+
* the pre-handshake boot identity.
|
|
27
|
+
*
|
|
28
|
+
* TOLERANT by design, field by field: a missing file, bad JSON, or an invalid FIELD falls
|
|
29
|
+
* back to that field's default - a broken identity file must never stop the gateway from
|
|
30
|
+
* starting as plain "toolfunnel" (the same fail-open-to-default posture as log.config.json;
|
|
31
|
+
* contrast auth config, which fails CLOSED because it guards a boundary). No new deps.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
|
|
37
|
+
/** The compiled-in defaults. version comes from package.json (single source of truth). */
|
|
38
|
+
function defaults() {
|
|
39
|
+
let version = '0.0.0';
|
|
40
|
+
try {
|
|
41
|
+
version = require('../../package.json').version || version;
|
|
42
|
+
} catch (_e) {
|
|
43
|
+
/* unreadable package.json -> a placeholder beats a crash */
|
|
44
|
+
}
|
|
45
|
+
// clientName/clientVersion default null = "use the built-in client identity" (McpClient's
|
|
46
|
+
// CLIENT_INFO) - so absent config produces byte-identical wire behaviour to before.
|
|
47
|
+
// serveLegacy default TRUE: every existing client today speaks the legacy era - refusing it is
|
|
48
|
+
// an explicit hardening opt-in ("modern-only"), never a default.
|
|
49
|
+
return { serverName: 'toolfunnel', serverVersion: version, clientName: null, clientVersion: null, httpPort: 9998, uiPort: 9777, serveLegacy: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A valid TCP port for a bind default: an integer 1..65535 (0 = OS-assigned stays flag-only -
|
|
53
|
+
* an ephemeral DEFAULT would make the gateway un-findable across restarts). */
|
|
54
|
+
function isPort(v) {
|
|
55
|
+
return Number.isInteger(v) && v >= 1 && v <= 65535;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load the gateway config, merged over the defaults.
|
|
60
|
+
* @param {string} rootDir the gateway root (the directory that holds toolfunnel.json)
|
|
61
|
+
* @returns {{serverName:string, serverVersion:string, httpPort:number, uiPort:number}}
|
|
62
|
+
*/
|
|
63
|
+
function loadServerConfig(rootDir) {
|
|
64
|
+
const out = defaults();
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = fs.readFileSync(path.join(rootDir, 'toolfunnel.json'), 'utf8');
|
|
68
|
+
} catch (_e) {
|
|
69
|
+
return out; // absent - the normal case
|
|
70
|
+
}
|
|
71
|
+
let cfg;
|
|
72
|
+
try {
|
|
73
|
+
cfg = JSON.parse(raw);
|
|
74
|
+
} catch (_e) {
|
|
75
|
+
return out; // bad JSON - start as the default identity rather than not at all
|
|
76
|
+
}
|
|
77
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return out;
|
|
78
|
+
if (typeof cfg.serverName === 'string' && cfg.serverName.trim().length > 0) {
|
|
79
|
+
out.serverName = cfg.serverName.trim();
|
|
80
|
+
}
|
|
81
|
+
if (typeof cfg.serverVersion === 'string' && cfg.serverVersion.trim().length > 0) {
|
|
82
|
+
out.serverVersion = cfg.serverVersion.trim();
|
|
83
|
+
}
|
|
84
|
+
if (typeof cfg.clientName === 'string' && cfg.clientName.trim().length > 0) {
|
|
85
|
+
out.clientName = cfg.clientName.trim();
|
|
86
|
+
}
|
|
87
|
+
if (typeof cfg.clientVersion === 'string' && cfg.clientVersion.trim().length > 0) {
|
|
88
|
+
out.clientVersion = cfg.clientVersion.trim();
|
|
89
|
+
}
|
|
90
|
+
if (isPort(cfg.httpPort)) out.httpPort = cfg.httpPort;
|
|
91
|
+
if (isPort(cfg.uiPort)) out.uiPort = cfg.uiPort;
|
|
92
|
+
// Only an EXPLICIT false flips modern-only on; any other value (absent, truthy, garbage)
|
|
93
|
+
// keeps the serve-both default - misconfiguration must never lock real clients out.
|
|
94
|
+
if (cfg.serveLegacy === false) out.serveLegacy = false;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { loadServerConfig };
|