@omega.js/mcp-router 0.1.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/src/cli.js ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * `omega-mcp` — the management CLI for the router's upstream registry.
3
+ *
4
+ * It reads the LAYERED view (bundled defaults + overlay) and writes ONLY the
5
+ * overlay at `~/.omega/mcp-router/servers/`: enabling, disabling, adding,
6
+ * removing, and caching tool schemas all land there. A bundled default is
7
+ * never edited, only shadowed — for a consumer it lives in node_modules.
8
+ *
9
+ * Registration is not this CLI's job: the omega Claude plugin declares the
10
+ * router in its .mcp.json, so there is nothing to sync into a client config.
11
+ */
12
+
13
+ const registry = require('./lib/registry.js');
14
+ const { resolveSpawn } = require('./lib/env.js');
15
+ // The one-shot spawn-connect-list-close the router's own refresh runs, so this
16
+ // CLI carries the same deadline, read budget, and failure cleanup. The helper
17
+ // requires the SDK only once that path is reached, so commands that don't need
18
+ // it (list, help) still work even when node_modules is missing.
19
+ const { listToolsOnce } = require('./lib/oneshot.js');
20
+
21
+ /**
22
+ * Spawn an upstream once, read its tool list, and cache it to the overlay.
23
+ *
24
+ * @param {string} name - Upstream name
25
+ * @param {object} layers - `{ bundledDir, overlayDir }`
26
+ * @returns {Promise<number>} How many tools were cached
27
+ */
28
+ async function fetchAndCacheSchema(name, layers) {
29
+ const upstream = registry.loadUpstream(name, layers);
30
+ if (!upstream) throw new Error(`Server "${name}" not found`);
31
+ if (!upstream.command) throw new Error(`Server "${name}" has no command`);
32
+
33
+ const spawn = resolveSpawn(upstream);
34
+ const tools = await listToolsOnce({
35
+ command: spawn.command,
36
+ args: spawn.args,
37
+ env: { ...process.env, ...spawn.env },
38
+ });
39
+
40
+ registry.patchOverlayEntry(name, { tools }, layers);
41
+ return tools.length;
42
+ }
43
+
44
+ const HELP = `
45
+ Usage: omega-mcp <command> [name] [args...]
46
+
47
+ Commands:
48
+ list, ls List all upstream servers, status, and cached tool counts
49
+ enable, on <name> Enable an upstream (auto-refreshes schema cache)
50
+ --force overrides a "locked": true upstream
51
+ disable, off <name> Disable an upstream
52
+ add <name> <cmd> ... Add a private upstream (auto-refreshes schema cache)
53
+ remove, rm <name> Remove a private upstream (bundled defaults: disable instead)
54
+ refresh <name> Re-fetch and cache an upstream's tool schemas
55
+
56
+ Architecture:
57
+ All upstreams are proxied through a single MCP server, "mcp-router".
58
+ Tools surface to Claude as: mcp__mcp-router__<upstream>__<tool>
59
+ Upstreams are lazy-spawned: no child processes until a tool is actually called.
60
+
61
+ Config is layered: the defaults bundled with @omega.js/mcp-router, then your
62
+ overlay at ~/.omega/mcp-router/servers/<name>/config.json, whose top-level
63
+ keys win. Every command here writes the overlay only. Secrets go in
64
+ ~/.omega/mcp-router/.env and reach a command as \${NAME} placeholders.
65
+
66
+ Per-chat control (from inside Claude):
67
+ router__list_upstreams, router__enable_upstream, router__disable_upstream,
68
+ router__refresh_upstream
69
+
70
+ Per-server config supports an optional "default": "auto" | "on-demand" field.
71
+ "on-demand" upstreams require Claude to call router__enable_upstream before
72
+ their tools become visible — useful for noisy servers.
73
+
74
+ An overlay entry may also carry "locked": true. A locked upstream refuses
75
+ every enable: this CLI's (unless you pass --force) and the per-chat
76
+ router__enable_upstream, which has no override. Disable and remove stay open.
77
+
78
+ Examples:
79
+ omega-mcp list
80
+ omega-mcp enable chrome-devtools-extension
81
+ omega-mcp disable chrome-devtools
82
+ omega-mcp add my-server npx -y my-mcp-server@latest
83
+ omega-mcp refresh chrome-devtools
84
+ `;
85
+
86
+ /**
87
+ * Run one CLI invocation.
88
+ *
89
+ * @param {string[]} argv - Arguments after the bin name
90
+ * @param {object} [options] - `{ bundledDir, overlayDir, out, err }` (tests inject all four)
91
+ * @returns {Promise<number>} Process exit code
92
+ */
93
+ async function run(argv, options = {}) {
94
+ const layers = registry.layers(options);
95
+ const out = options.out || ((line) => console.log(line));
96
+ const err = options.err || ((line) => console.error(line));
97
+
98
+ // `--force` is a flag on the command, never part of an added upstream's own
99
+ // command line, so it is stripped for the command/name positions only, and
100
+ // `add` keeps reading the RAW argv for the command it is being handed.
101
+ const force = argv.includes('--force');
102
+ const positional = argv.filter((arg) => arg !== '--force');
103
+ const command = positional[0];
104
+ const name = positional[1];
105
+
106
+ /**
107
+ * Best-effort cache refresh — report failure but never abort the caller.
108
+ *
109
+ * @param {string} target - Upstream name
110
+ * @returns {Promise<void>} Always resolves
111
+ */
112
+ const tryRefresh = async (target) => {
113
+ try {
114
+ const count = await fetchAndCacheSchema(target, layers);
115
+ out(`Cached ${count} tool(s) for "${target}"`);
116
+ } catch (error) {
117
+ err(`Warning: could not cache schema for "${target}": ${error.message}`);
118
+ err(`Run \`omega-mcp refresh ${target}\` later to retry.`);
119
+ }
120
+ };
121
+
122
+ if ((command === 'enable' || command === 'on' || command === 'disable' || command === 'off') && name) {
123
+ const upstream = registry.loadUpstream(name, layers);
124
+ if (!upstream) {
125
+ err(`Server "${name}" not found`);
126
+ return 1;
127
+ }
128
+ const enabled = command === 'enable' || command === 'on';
129
+ // A lock guards WAKING an upstream only: disable stays open.
130
+ if (enabled && upstream.locked && !force) {
131
+ err(registry.lockedRefusal(name));
132
+ return 1;
133
+ }
134
+ registry.patchOverlayEntry(name, { enabled }, layers);
135
+ out(`${enabled ? 'Enabled' : 'Disabled'} ${name}`);
136
+ if (enabled) await tryRefresh(name);
137
+ return 0;
138
+ }
139
+
140
+ if (command === 'add') {
141
+ const commandArgs = argv.slice(2);
142
+ if (!name || commandArgs.length === 0) {
143
+ err('Usage: omega-mcp add <name> <command> [args...]');
144
+ err('Example: omega-mcp add firebase npx -y firebase-tools@latest mcp');
145
+ return 1;
146
+ }
147
+ if (registry.loadUpstream(name, layers)) {
148
+ err(`Server "${name}" already exists. Remove it first or edit its config.json directly.`);
149
+ return 1;
150
+ }
151
+ registry.patchOverlayEntry(name, { enabled: true, command: commandArgs[0], args: commandArgs.slice(1) }, layers);
152
+ out(`Added ${name}`);
153
+ await tryRefresh(name);
154
+ return 0;
155
+ }
156
+
157
+ if ((command === 'remove' || command === 'rm') && name) {
158
+ if (!registry.removeOverlayEntry(name, layers)) {
159
+ if (registry.isBundled(name, layers)) {
160
+ err(`Server "${name}" is a bundled default and cannot be removed. Run \`omega-mcp disable ${name}\` instead.`);
161
+ } else {
162
+ err(`Server "${name}" not found`);
163
+ }
164
+ return 1;
165
+ }
166
+ out(registry.isBundled(name, layers)
167
+ ? `Removed your overrides for ${name} — the bundled default is back in effect`
168
+ : `Removed ${name}`);
169
+ return 0;
170
+ }
171
+
172
+ if (command === 'refresh' && name) {
173
+ try {
174
+ const count = await fetchAndCacheSchema(name, layers);
175
+ out(`Cached ${count} tool(s) for "${name}"`);
176
+ return 0;
177
+ } catch (error) {
178
+ err(`Refresh failed for "${name}": ${error.message}`);
179
+ return 1;
180
+ }
181
+ }
182
+
183
+ if (command === 'list' || command === 'ls') {
184
+ out('\nMCP Servers (proxied through mcp-router):\n');
185
+ for (const upstream of Object.values(registry.loadUpstreams(layers))) {
186
+ const status = upstream.enabled_on_disk ? '\x1b[32m●\x1b[0m' : '\x1b[90m○\x1b[0m';
187
+ const mode = upstream.default === 'on-demand' ? ' [on-demand]' : '';
188
+ const locked = upstream.locked ? ' [locked]' : '';
189
+ const source = upstream.bundled ? (upstream.overlaid ? ' (bundled, overridden)' : ' (bundled)') : ' (yours)';
190
+ const tools = upstream.tools.length ? ` (${upstream.tools.length} tools cached)` : ' (no cache)';
191
+ out(` ${status} ${upstream.name}${mode}${locked}${source}${tools}`);
192
+ }
193
+ out('');
194
+ return 0;
195
+ }
196
+
197
+ if (command === 'help' || !command) {
198
+ out(HELP);
199
+ return 0;
200
+ }
201
+
202
+ err(`Unknown command: ${command}`);
203
+ err('Run "omega-mcp help" for usage information.');
204
+ return 1;
205
+ }
206
+
207
+ /**
208
+ * The bin entry — run argv and set the process exit code.
209
+ *
210
+ * @returns {void}
211
+ */
212
+ function main() {
213
+ run(process.argv.slice(2))
214
+ .then((code) => {
215
+ process.exitCode = code;
216
+ })
217
+ .catch((err) => {
218
+ console.error(err && err.stack ? err.stack : String(err));
219
+ process.exitCode = 1;
220
+ });
221
+ }
222
+
223
+ module.exports = { run, main, fetchAndCacheSchema };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Dependency self-bootstrap for launches from a bare checkout.
3
+ *
4
+ * The plugin's .mcp.json runs the router straight out of the marketplace
5
+ * clone, and a fresh clone has no node_modules. Before any SDK import, this
6
+ * installs the package's own dependencies once — a no-op whenever they
7
+ * already resolve (dev checkouts with the monorepo installed, every launch
8
+ * after the first).
9
+ *
10
+ * This file runs BEFORE node_modules exists, so everything it requires — and
11
+ * everything those files require — must stay builtin-only.
12
+ */
13
+
14
+ const path = require('node:path');
15
+
16
+ const { resolveBin } = require('./lib/env.js');
17
+ const { log } = require('./lib/log.js');
18
+
19
+ const PKG_ROOT = path.join(__dirname, '..');
20
+
21
+ // The exact specifier the router imports — the SDK's exports map has no
22
+ // ./package.json entry, so probing a real export is the honest check.
23
+ const PROBE = '@modelcontextprotocol/sdk/server/index.js';
24
+
25
+ /**
26
+ * Make the package's dependencies resolvable, installing them if needed.
27
+ *
28
+ * @param {object} [options] - `{ resolve, spawnSync, pkgRoot }` seams for tests
29
+ * @returns {boolean} True when dependencies resolve (or were just installed)
30
+ */
31
+ function ensureDeps(options = {}) {
32
+ const resolve = options.resolve || require.resolve;
33
+ const spawnSync = options.spawnSync || require('node:child_process').spawnSync;
34
+ const pkgRoot = options.pkgRoot || PKG_ROOT;
35
+
36
+ try {
37
+ resolve(PROBE, { paths: [pkgRoot] });
38
+ return true;
39
+ } catch {
40
+ log('info', 'first launch from a bare checkout — installing dependencies…');
41
+ }
42
+
43
+ // The bare name would need a PATH the router may not have (same reason the
44
+ // spawn shapes resolve theirs); win32 still needs the shell to run a .cmd,
45
+ // and quoting keeps the default `C:\Program Files\nodejs` install working.
46
+ const shell = process.platform === 'win32';
47
+ const npm = resolveBin('npm');
48
+
49
+ // stdout stays 'ignore' — this process's stdout is the MCP wire.
50
+ const result = spawnSync(shell ? `"${npm}"` : npm, ['install', '--omit=dev', '--no-fund', '--no-audit'], {
51
+ cwd: pkgRoot,
52
+ stdio: ['ignore', 'ignore', 'inherit'],
53
+ shell,
54
+ });
55
+
56
+ if (result.status !== 0) {
57
+ log('fatal', `npm install failed — run it in ${pkgRoot} and relaunch`);
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ module.exports = { ensureDeps, PKG_ROOT };
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `chrome-devtools-extension` upstream's launcher.
4
+ *
5
+ * chrome-devtools-mcp needs an explicit executable to drive an unpacked
6
+ * extension, and the one every machine already has is Chrome for Testing in
7
+ * puppeteer's download cache. This finds the newest CFT there — platform-aware,
8
+ * because the cache lays each platform out differently — and execs the MCP
9
+ * server against it, forwarding stdio so the router talks to it as usual.
10
+ *
11
+ * Set OMEGA_EXTENSION_PATH to the unpacked extension directory before the
12
+ * session starts and it is loaded into that Chrome.
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+ const { spawn } = require('node:child_process');
19
+
20
+ const { resolveBin } = require('./lib/env.js');
21
+ const { log } = require('./lib/log.js');
22
+
23
+ const MCP_PACKAGE = 'chrome-devtools-mcp@1.4.0';
24
+
25
+ /**
26
+ * Where puppeteer downloads Chrome for Testing.
27
+ *
28
+ * @returns {string} Absolute cache path
29
+ */
30
+ function defaultCacheDir() {
31
+ return path.join(os.homedir(), '.cache', 'puppeteer', 'chrome');
32
+ }
33
+
34
+ /**
35
+ * The executable a CFT version directory holds on one platform. macOS hides
36
+ * it inside an .app bundle whose name changes with the channel, so that leg
37
+ * globs; linux and windows are fixed paths.
38
+ *
39
+ * @param {string} versionDir - Absolute `<cache>/<version>` directory
40
+ * @param {string} platform - A process.platform value
41
+ * @param {string} arch - A process.arch value
42
+ * @returns {string|null} Absolute executable path, or null when this dir has none
43
+ */
44
+ function executableIn(versionDir, platform, arch) {
45
+ if (platform === 'darwin') {
46
+ const root = path.join(versionDir, arch === 'arm64' ? 'chrome-mac-arm64' : 'chrome-mac-x64');
47
+ let bundles;
48
+ try {
49
+ bundles = fs.readdirSync(root).filter((entry) => entry.endsWith('.app'));
50
+ } catch {
51
+ return null;
52
+ }
53
+ for (const bundle of bundles) {
54
+ const macos = path.join(root, bundle, 'Contents', 'MacOS');
55
+ let binaries;
56
+ try {
57
+ binaries = fs.readdirSync(macos);
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (binaries.length > 0) return path.join(macos, binaries[0]);
62
+ }
63
+ return null;
64
+ }
65
+
66
+ const relative = platform === 'win32'
67
+ ? path.join('chrome-win64', 'chrome.exe')
68
+ : path.join('chrome-linux64', 'chrome');
69
+ const executable = path.join(versionDir, relative);
70
+ return fs.existsSync(executable) ? executable : null;
71
+ }
72
+
73
+ /**
74
+ * Compare two cache directory names (`mac_arm-150.0.7871.24`) by version,
75
+ * newest first — a plain string sort puts 99 above 150.
76
+ *
77
+ * @param {string} a - A directory name
78
+ * @param {string} b - Another directory name
79
+ * @returns {number} Sort order
80
+ */
81
+ function byVersionDesc(a, b) {
82
+ const parts = (name) => (name.split('-').pop() || '').split('.').map((piece) => Number.parseInt(piece, 10) || 0);
83
+ const left = parts(a);
84
+ const right = parts(b);
85
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
86
+ const diff = (right[index] || 0) - (left[index] || 0);
87
+ if (diff !== 0) return diff;
88
+ }
89
+ return b.localeCompare(a);
90
+ }
91
+
92
+ /**
93
+ * The newest Chrome for Testing executable in the cache.
94
+ *
95
+ * @param {object} [options] - `{ cacheDir, platform, arch }` — all injectable for tests
96
+ * @returns {string|null} Absolute executable path, or null when the cache holds none
97
+ */
98
+ function findChromeForTesting(options = {}) {
99
+ const cacheDir = options.cacheDir || defaultCacheDir();
100
+ const platform = options.platform || process.platform;
101
+ const arch = options.arch || process.arch;
102
+
103
+ let versions;
104
+ try {
105
+ versions = fs.readdirSync(cacheDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
106
+ } catch {
107
+ return null;
108
+ }
109
+
110
+ for (const version of versions.sort(byVersionDesc)) {
111
+ const executable = executableIn(path.join(cacheDir, version), platform, arch);
112
+ if (executable) return executable;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * The chrome-devtools-mcp argv for a found executable.
119
+ *
120
+ * @param {string} executable - Absolute Chrome for Testing path
121
+ * @param {object} env - Environment to read OMEGA_EXTENSION_PATH from
122
+ * @returns {string[]} Arguments for `npx`
123
+ */
124
+ function buildArgs(executable, env) {
125
+ const args = [
126
+ '-y',
127
+ MCP_PACKAGE,
128
+ '--isolated',
129
+ '--acceptInsecureCerts',
130
+ '--usage-statistics=false',
131
+ `--executablePath=${executable}`,
132
+ ];
133
+ if (env.OMEGA_EXTENSION_PATH) {
134
+ args.push(`--chromeArg=--load-extension=${env.OMEGA_EXTENSION_PATH}`);
135
+ args.push('--ignoreDefaultChromeArg=--disable-extensions');
136
+ }
137
+ args.push('--categoryExtensions');
138
+ return args;
139
+ }
140
+
141
+ /**
142
+ * Find CFT and exec the MCP server against it.
143
+ *
144
+ * @param {object} [options] - `{ find, spawn, env, exit }` seams for tests
145
+ * @returns {void}
146
+ */
147
+ function main(options = {}) {
148
+ const find = options.find || findChromeForTesting;
149
+ const spawnFn = options.spawn || spawn;
150
+ const env = options.env || process.env;
151
+ const exit = options.exit || ((code) => process.exit(code));
152
+
153
+ const executable = find();
154
+ if (!executable) {
155
+ // No silent fallback to the user's own Chrome: an extension test in the
156
+ // personal browser is exactly what this upstream exists to avoid.
157
+ log('fatal', `No Chrome for Testing found in ${defaultCacheDir()}. Install one with \`npx puppeteer browsers install chrome\`.`);
158
+ return exit(1);
159
+ }
160
+
161
+ // win32 has no directly spawnable `npx` — it is npx.cmd, which Node only
162
+ // runs through a shell; quoting keeps the resolved absolute path working
163
+ // from the default `C:\Program Files\nodejs` install.
164
+ const shell = process.platform === 'win32';
165
+ const npx = resolveBin('npx');
166
+ const child = spawnFn(shell ? `"${npx}"` : npx, buildArgs(executable, env), { stdio: 'inherit', env, shell });
167
+ child.on('exit', (code, signal) => exit(signal ? 1 : code ?? 0));
168
+ return undefined;
169
+ }
170
+
171
+ module.exports = { findChromeForTesting, executableIn, buildArgs, byVersionDesc, defaultCacheDir, main };
172
+
173
+ if (require.main === module) main();
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `omega-extension` upstream's launcher.
4
+ *
5
+ * The extension MCP server lives inside @omega.js/manager, at
6
+ * `extension/mcp-server/index.js`. This resolves the manager's package root
7
+ * from its main export — the exports map deliberately has no `./package.json`
8
+ * entry, so the root is the main file's directory's parent — and runs the
9
+ * server with stdio forwarded to the router.
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { spawn } = require('node:child_process');
15
+
16
+ const { log } = require('./lib/log.js');
17
+
18
+ const MANAGER_PACKAGE = '@omega.js/manager';
19
+ const SERVER_RELATIVE = path.join('extension', 'mcp-server', 'index.js');
20
+
21
+ /**
22
+ * The installed @omega.js/manager package root.
23
+ *
24
+ * @param {object} [options] - `{ resolve }` — the resolver seam (defaults to require.resolve)
25
+ * @returns {string|null} Absolute package root, or null when the manager is not installed
26
+ */
27
+ function resolveManagerRoot(options = {}) {
28
+ const resolve = options.resolve || require.resolve;
29
+ const exists = options.exists || fs.existsSync;
30
+ try {
31
+ // main is dist/index.js — two dirnames up from it is the package root.
32
+ return path.dirname(path.dirname(resolve(MANAGER_PACKAGE)));
33
+ } catch {
34
+ // A bare checkout has no workspace links, but the manager package sits
35
+ // beside this one in the monorepo.
36
+ const sibling = path.join(__dirname, '..', '..', 'manager');
37
+ return exists(path.join(sibling, 'package.json')) ? sibling : null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The extension MCP server entry inside an installed manager.
43
+ *
44
+ * @param {object} [options] - `{ resolve, exists }` seams for tests
45
+ * @returns {{server: string}|{error: string}} The entry path, or why there is none
46
+ */
47
+ function resolveServerPath(options = {}) {
48
+ const exists = options.exists || fs.existsSync;
49
+ const root = resolveManagerRoot(options);
50
+ if (!root) return { error: `${MANAGER_PACKAGE} is not installed — the omega-extension upstream needs it on disk.` };
51
+
52
+ const server = path.join(root, SERVER_RELATIVE);
53
+ if (!exists(server)) return { error: `${MANAGER_PACKAGE} is installed at ${root} but ${SERVER_RELATIVE} is missing.` };
54
+ return { server };
55
+ }
56
+
57
+ /**
58
+ * Resolve the server and run it.
59
+ *
60
+ * @param {object} [options] - `{ resolve, exists, spawn, exit, execPath }` seams for tests
61
+ * @returns {void}
62
+ */
63
+ function main(options = {}) {
64
+ const spawnFn = options.spawn || spawn;
65
+ const exit = options.exit || ((code) => process.exit(code));
66
+
67
+ const resolved = resolveServerPath(options);
68
+ if (resolved.error) {
69
+ log('fatal', resolved.error);
70
+ return exit(1);
71
+ }
72
+
73
+ // NODE_PATH carries this package's node_modules to the server: in a bare
74
+ // checkout the manager tree has none of its own, and the server's imports
75
+ // (the MCP SDK, ws) are declared here for exactly this launch. Ancestor
76
+ // node_modules still win wherever the monorepo is installed.
77
+ const nodePath = [path.join(__dirname, '..', 'node_modules'), process.env.NODE_PATH]
78
+ .filter(Boolean)
79
+ .join(path.delimiter);
80
+ const child = spawnFn(options.execPath || process.execPath, [resolved.server], {
81
+ stdio: 'inherit',
82
+ env: { ...process.env, NODE_PATH: nodePath },
83
+ });
84
+ child.on('exit', (code, signal) => exit(signal ? 1 : code ?? 0));
85
+ return undefined;
86
+ }
87
+
88
+ module.exports = { resolveManagerRoot, resolveServerPath, main };
89
+
90
+ if (require.main === module) main();
package/src/lib/env.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Secrets and placeholders.
3
+ *
4
+ * `${NAME}` placeholders in an upstream's command/args/env resolve from the
5
+ * overlay `.env` first, then process.env — so tokens never live in a tracked
6
+ * config.json. `${NAME:-default}` takes the same lookups and falls back to the
7
+ * literal default when they all miss, which is what lets a bundled upstream
8
+ * carry an optional port without an `sh -c` wrapper around its command. Other
9
+ * shell forms (`${VAR:+...}`) are not ours and pass through untouched. A
10
+ * session env override is checked ahead of both lookups, so a per-session port
11
+ * reaches the placeholder that names it.
12
+ *
13
+ * One reserved name: `${MCP_ROUTER_ROOT}` always resolves to this package's
14
+ * root directory (checked BEFORE .env and process.env), which is how a
15
+ * bundled upstream points at a launcher script it ships with.
16
+ *
17
+ * Spawn shapes also get their bare `node`/`npm`/`npx` command resolved to an
18
+ * absolute path here, so a child comes up on machines where those names are
19
+ * not on the router's own PATH.
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+ const { parseEnv } = require('node:util');
25
+
26
+ const { log } = require('./log.js');
27
+ const { PACKAGE_ROOT, envFile } = require('./paths.js');
28
+
29
+ const RESERVED = { MCP_ROUTER_ROOT: PACKAGE_ROOT };
30
+ const RESOLVABLE_BINS = new Set(['node', 'npm', 'npx']);
31
+
32
+ /**
33
+ * Read the overlay .env into a plain object. Node's own `util.parseEnv` does
34
+ * the parsing — quoting, escapes inside double quotes, `export` prefixes and
35
+ * trailing comments are all its business, never ours (Ian 2026-08-21).
36
+ *
37
+ * @param {string} [file] - Path to read; defaults to the resolved env file
38
+ * @returns {object} NAME → value (missing file is not an error — process.env may still answer)
39
+ */
40
+ function loadEnvFile(file) {
41
+ let raw;
42
+ try {
43
+ raw = fs.readFileSync(file || envFile(), 'utf8');
44
+ } catch {
45
+ return {};
46
+ }
47
+ return parseEnv(raw);
48
+ }
49
+
50
+ /**
51
+ * Substitute `${NAME}` and `${NAME:-default}` placeholders in one string.
52
+ *
53
+ * @param {string} str - The raw command/arg/env value
54
+ * @param {object} secrets - Values from the overlay .env
55
+ * @param {object} [overrides] - Session env override; beats both ambient sources
56
+ * @returns {string} The resolved string; a placeholder with no value and no default stays literal
57
+ */
58
+ function interpolate(str, secrets, overrides = {}) {
59
+ return String(str).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (whole, name, fallback) => {
60
+ if (RESERVED[name] !== undefined) return RESERVED[name];
61
+ if (overrides[name] !== undefined) return overrides[name];
62
+ if (secrets[name] !== undefined) return secrets[name];
63
+ if (process.env[name] !== undefined) return process.env[name];
64
+ // A default IS a value — nothing is missing, so nothing is warned about.
65
+ if (fallback !== undefined) return fallback;
66
+ log('warn', `No value for placeholder ${whole} (expected in ${envFile()} or the environment); leaving literal`);
67
+ return whole;
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Resolve a bare `node`/`npm`/`npx` to the binary beside this process's own
73
+ * node. process.execPath is the real, already-resolved node even when the
74
+ * router was launched through a shell shim or from an nvm-only PATH, so its
75
+ * siblings are reachable where the bare name is not — which is the difference
76
+ * between a child that starts and a `Request timed out`.
77
+ *
78
+ * @param {string} cmd - The interpolated command from a registry entry
79
+ * @returns {string} The absolute sibling when it exists; otherwise `cmd` untouched, to fall back to PATH
80
+ */
81
+ function resolveBin(cmd) {
82
+ if (cmd.includes('/') || cmd.includes('\\')) return cmd;
83
+ if (!RESOLVABLE_BINS.has(cmd)) return cmd;
84
+ if (cmd === 'node') return process.execPath;
85
+ // win32 names these npx.cmd/npm.cmd on disk; probe rather than assume.
86
+ const names = process.platform === 'win32' ? [`${cmd}.cmd`, cmd] : [cmd];
87
+ for (const name of names) {
88
+ const candidate = path.join(path.dirname(process.execPath), name);
89
+ if (fs.existsSync(candidate)) return candidate;
90
+ }
91
+ return cmd;
92
+ }
93
+
94
+ /**
95
+ * Resolve an upstream's spawn shape. Re-reads .env on every call so a rotated
96
+ * token is picked up without a router restart (spawns are rare, the read is
97
+ * trivial).
98
+ *
99
+ * A session env override (`router__enable_upstream {env}`) feeds the same
100
+ * placeholder resolution, ahead of .env and process.env: the override is what
101
+ * the caller asked THIS child to run with, so a `${OMEGA_CDP_PORT:-9222}` in
102
+ * the spawn shape has to see it — the values are baked here now, not expanded
103
+ * later by a child shell.
104
+ *
105
+ * @param {object} upstream - A registry entry (`command`, `args`, `env`)
106
+ * @param {object} [overrideEnv] - Session env override for placeholder resolution
107
+ * @returns {{command: string, args: string[], env: object}} Interpolated spawn shape
108
+ */
109
+ function resolveSpawn(upstream, overrideEnv = {}) {
110
+ const secrets = loadEnvFile();
111
+ return {
112
+ command: resolveBin(interpolate(upstream.command, secrets, overrideEnv)),
113
+ args: (upstream.args || []).map((arg) => interpolate(arg, secrets, overrideEnv)),
114
+ env: Object.fromEntries(Object.entries(upstream.env || {}).map(([key, value]) => [key, interpolate(value, secrets, overrideEnv)])),
115
+ };
116
+ }
117
+
118
+ module.exports = { loadEnvFile, interpolate, resolveBin, resolveSpawn, RESERVED };