@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/LICENSE +98 -0
- package/README.md +98 -0
- package/bin/mcp-router.js +9 -0
- package/bin/omega-mcp.js +8 -0
- package/package.json +42 -0
- package/servers/chrome-devtools/config.json +1013 -0
- package/servers/chrome-devtools-electron/config.json +1012 -0
- package/servers/chrome-devtools-extension/config.json +1126 -0
- package/servers/omega-extension/config.json +506 -0
- package/src/cli.js +223 -0
- package/src/ensure-deps.js +63 -0
- package/src/launch-cft.js +173 -0
- package/src/launch-omega-extension.js +90 -0
- package/src/lib/env.js +118 -0
- package/src/lib/kill-tree.js +74 -0
- package/src/lib/log.js +17 -0
- package/src/lib/oneshot.js +116 -0
- package/src/lib/paths.js +41 -0
- package/src/lib/registry.js +232 -0
- package/src/router.js +515 -0
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 };
|