@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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Killing a child and everything it started.
3
+ *
4
+ * The pid the router holds is often only the ROOT of a tree: `npx` starts npm,
5
+ * which starts the real server, which starts a browser. Signalling that one
6
+ * pid ends the wrapper and REPARENTS the rest to init, out of reach of anything
7
+ * the router knows — which is how a stopped upstream leaves a browser burning
8
+ * cores for the rest of the day. So the tree is walked and signalled deepest
9
+ * first, while the links between the processes still exist to be read.
10
+ */
11
+
12
+ const { execFileSync } = require('node:child_process');
13
+
14
+ /**
15
+ * The direct children of one process.
16
+ *
17
+ * @param {number} pid - Parent pid
18
+ * @returns {number[]} Child pids, empty when there are none to find
19
+ */
20
+ const childPids = (pid) => {
21
+ try {
22
+ return execFileSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' })
23
+ .split('\n')
24
+ .map((line) => Number(line.trim()))
25
+ .filter((child) => Number.isInteger(child) && child > 0);
26
+ } catch {
27
+ // pgrep exits 1 when nothing matches, which execFileSync raises; a platform
28
+ // with no pgrep at all answers the same way. Either is "no children we can
29
+ // see", and the pid itself is still signalled by the caller.
30
+ return [];
31
+ }
32
+ };
33
+
34
+ /**
35
+ * Every descendant of one process, deepest first.
36
+ *
37
+ * One level is never enough for a capture: the MIDDLE of a tree exits with the
38
+ * root above it, and the browser it started is reparented and invisible to any
39
+ * walk by the time a grace period runs. The tree has to be read whole while it
40
+ * is still a tree.
41
+ *
42
+ * @param {number} pid - Root pid, itself not included
43
+ * @returns {number[]} Descendant pids, deepest first, empty when there are none
44
+ */
45
+ const descendantPids = (pid) => {
46
+ const found = [];
47
+ for (const child of childPids(pid)) found.push(...descendantPids(child), child);
48
+ return found;
49
+ };
50
+
51
+ /**
52
+ * Signal a process and every descendant, deepest first.
53
+ *
54
+ * Deepest first is what makes it a tree kill rather than a race: a parent
55
+ * signalled first would take its links with it and leave the rest unreachable.
56
+ *
57
+ * @param {number} pid - Root pid of the tree
58
+ * @param {string|number} signal - Signal to send (e.g. 'SIGKILL')
59
+ * @returns {void}
60
+ * @throws {Error} Whatever process.kill rejected with, except ESRCH
61
+ */
62
+ const killTree = (pid, signal) => {
63
+ for (const child of childPids(pid)) killTree(child, signal);
64
+
65
+ try {
66
+ process.kill(pid, signal);
67
+ } catch (err) {
68
+ // ESRCH is the process having exited on its own between the walk and the
69
+ // signal — the outcome we wanted. Anything else (EPERM) is a real problem.
70
+ if (err.code !== 'ESRCH') throw err;
71
+ }
72
+ };
73
+
74
+ module.exports = { descendantPids, killTree };
package/src/lib/log.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The router's log sink. Everything a router process says goes to STDERR —
3
+ * stdout is the MCP wire, and one stray byte on it corrupts the protocol.
4
+ */
5
+
6
+ /**
7
+ * Write one line to stderr.
8
+ *
9
+ * @param {string} level - `info` | `warn` | `error` | `fatal`
10
+ * @param {...*} args - Message parts, joined with a space
11
+ * @returns {void}
12
+ */
13
+ function log(level, ...args) {
14
+ process.stderr.write(`[mcp-router ${level}] ${args.join(' ')}\n`);
15
+ }
16
+
17
+ module.exports = { log };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Connecting to an upstream child, under the router's spawn budget.
3
+ *
4
+ * Two surfaces refresh a schema (the router's `router__refresh_upstream` and
5
+ * `omega-mcp refresh`), and both do the same thing: spawn the upstream once,
6
+ * read its tool list, close it. They ran as two copies of that sequence and the
7
+ * copies drifted (the cleanup, then the read budget, landed on the router side
8
+ * only), so the sequence lives here ONCE and both callers import it.
9
+ *
10
+ * The connect deadline is shared wider still: the router's lazy cold spawn
11
+ * connects through the same helper, on the same budget.
12
+ */
13
+
14
+ // How long a connect gets to finish the MCP handshake. A child that corrupts
15
+ // the stdio wire never answers initialize, and an unbounded connect would leave
16
+ // the router's `spawning` promise pending: every later call would await that
17
+ // dead promise and die at the caller's own tool timeout, for the rest of the
18
+ // session. The deadline bounds it so the NEXT call spawns fresh. It bounds the
19
+ // one-shot refresh connect AND its tools/list read too, on the same budget.
20
+ // MCP_ROUTER_SPAWN_TIMEOUT_MS is the test seam, alongside the two in paths.js.
21
+ const SPAWN_TIMEOUT_MS = Number(process.env.MCP_ROUTER_SPAWN_TIMEOUT_MS) || 30000;
22
+
23
+ /**
24
+ * Connect a client over its transport, bounded by SPAWN_TIMEOUT_MS.
25
+ *
26
+ * The deadline abandons a connect the SDK has not settled (the SDK's own
27
+ * request timeout answers much later); Promise.race already attaches the
28
+ * rejection handler, so the explicit catch on `connected` only documents the
29
+ * abandonment. On ANY rejection the transport is closed — a stuck handshake,
30
+ * or one that lands after the deadline, would otherwise leave its child
31
+ * running for the rest of the session — then the rejection is rethrown.
32
+ *
33
+ * @param {object} client - Proxy client to connect
34
+ * @param {object} transport - Transport whose child is terminated on failure
35
+ * @returns {Promise<void>} Resolves once the handshake lands
36
+ * @throws {Error} When the handshake does not finish within SPAWN_TIMEOUT_MS
37
+ */
38
+ const connectWithDeadline = async (client, transport) => {
39
+ const connected = client.connect(transport);
40
+ connected.catch(() => {});
41
+
42
+ let deadline;
43
+ try {
44
+ await Promise.race([
45
+ connected,
46
+ new Promise((_, reject) => {
47
+ deadline = setTimeout(
48
+ () => reject(new Error(`handshake did not finish within ${SPAWN_TIMEOUT_MS}ms`)),
49
+ SPAWN_TIMEOUT_MS,
50
+ );
51
+ }),
52
+ ]);
53
+ } catch (err) {
54
+ await transport.close().catch(() => {});
55
+ throw err;
56
+ } finally {
57
+ clearTimeout(deadline);
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Connect an already-built pair, read the tool list once, and close.
63
+ *
64
+ * A child that finishes the handshake can still fail the tools/list read (an
65
+ * error answer, or a stall). The read carries the router's own budget because
66
+ * the SDK would otherwise hold the caller for its 60s default.
67
+ * connectWithDeadline is done with the transport by then, so nothing else would
68
+ * close it and the one-shot child would run for the rest of the session. The
69
+ * success path closes through the client instead, so the transport is never
70
+ * closed twice.
71
+ *
72
+ * @param {object} client - Client to connect and read through
73
+ * @param {object} transport - Transport whose child is terminated on any failure
74
+ * @returns {Promise<object[]>} The upstream's tool descriptors
75
+ * @throws {Error} Whatever the connect or the read rejected with
76
+ */
77
+ const readToolsOnce = async (client, transport) => {
78
+ await connectWithDeadline(client, transport);
79
+
80
+ let result;
81
+ try {
82
+ result = await client.listTools(undefined, { timeout: SPAWN_TIMEOUT_MS });
83
+ } catch (err) {
84
+ await transport.close().catch(() => {});
85
+ throw err;
86
+ }
87
+ await client.close();
88
+ return result.tools;
89
+ };
90
+
91
+ /**
92
+ * Spawn an upstream once, read its tool list, and close it.
93
+ *
94
+ * The SDK is required HERE rather than at module load so the CLI commands that
95
+ * never reach this path (list, help) still work when node_modules is missing.
96
+ *
97
+ * @param {{command: string, args: string[], env: object}} spawn - Resolved spawn shape for the child
98
+ * @returns {Promise<object[]>} The upstream's tool descriptors
99
+ * @throws {Error} Whatever the connect or the read rejected with
100
+ */
101
+ const listToolsOnce = async (spawn) => {
102
+ const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
103
+ const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
104
+
105
+ const transport = new StdioClientTransport({
106
+ command: spawn.command,
107
+ args: spawn.args,
108
+ env: spawn.env,
109
+ stderr: 'inherit',
110
+ });
111
+ const client = new Client({ name: 'mcp-router-refresh', version: '1.0.0' }, { capabilities: {} });
112
+
113
+ return readToolsOnce(client, transport);
114
+ };
115
+
116
+ module.exports = { SPAWN_TIMEOUT_MS, connectWithDeadline, readToolsOnce, listToolsOnce };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Where the router reads and writes.
3
+ *
4
+ * Two layers, one direction: the BUNDLED defaults ship inside the package
5
+ * (read-only — for a consumer they live in node_modules), and the OVERLAY
6
+ * under `~/.omega/mcp-router/` is the only thing anything ever writes.
7
+ *
8
+ * Both env seams exist for tests and power users:
9
+ * MCP_ROUTER_SERVERS_DIR — overrides the overlay servers dir
10
+ * MCP_ROUTER_ENV_FILE — overrides the overlay .env path
11
+ */
12
+
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+
16
+ // src/lib/paths.js → the package root is two levels up.
17
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
18
+
19
+ const BUNDLED_SERVERS_DIR = path.join(PACKAGE_ROOT, 'servers');
20
+
21
+ const OVERLAY_ROOT = path.join(os.homedir(), '.omega', 'mcp-router');
22
+
23
+ /**
24
+ * The overlay servers dir for this process.
25
+ *
26
+ * @returns {string} Absolute path
27
+ */
28
+ function overlayServersDir() {
29
+ return process.env.MCP_ROUTER_SERVERS_DIR || path.join(OVERLAY_ROOT, 'servers');
30
+ }
31
+
32
+ /**
33
+ * The .env file secrets interpolate from.
34
+ *
35
+ * @returns {string} Absolute path (the file need not exist)
36
+ */
37
+ function envFile() {
38
+ return process.env.MCP_ROUTER_ENV_FILE || path.join(OVERLAY_ROOT, '.env');
39
+ }
40
+
41
+ module.exports = { PACKAGE_ROOT, BUNDLED_SERVERS_DIR, OVERLAY_ROOT, overlayServersDir, envFile };
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The layered upstream registry — the one view of "what upstreams exist",
3
+ * shared by the router and the CLI.
4
+ *
5
+ * Two layers: the BUNDLED defaults that ship in `servers/` inside this
6
+ * package, then the user's OVERLAY at `~/.omega/mcp-router/servers/`. The
7
+ * merge is SHALLOW and field-level — an overlay entry's top-level keys win
8
+ * over the bundled entry's, so:
9
+ *
10
+ * {"enabled": false} turns a bundled default off
11
+ * {"args": [...]} re-points one field of a default
12
+ * a full entry under a new name adds a private upstream
13
+ *
14
+ * Every WRITE lands in the overlay, never in the bundled dir — for a consumer
15
+ * the bundled dir lives inside node_modules, which nothing may edit.
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const { log } = require('./log.js');
22
+ const { BUNDLED_SERVERS_DIR, overlayServersDir } = require('./paths.js');
23
+
24
+ /**
25
+ * Resolve the two layer directories for a call.
26
+ *
27
+ * @param {object} [options] - `{ bundledDir, overlayDir }` overrides (tests pass fixtures)
28
+ * @returns {{bundledDir: string, overlayDir: string}} The layers, weakest first
29
+ */
30
+ function layers(options = {}) {
31
+ return {
32
+ bundledDir: options.bundledDir || BUNDLED_SERVERS_DIR,
33
+ overlayDir: options.overlayDir || overlayServersDir(),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Read one layer's `<name>/config.json`.
39
+ *
40
+ * @param {string} dir - A layer directory
41
+ * @param {string} name - Upstream name
42
+ * @returns {object|null} The parsed config, or null when absent/unreadable (unreadable logs loudly)
43
+ */
44
+ function readLayer(dir, name) {
45
+ const file = path.join(dir, name, 'config.json');
46
+ if (!fs.existsSync(file)) return null;
47
+ try {
48
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
49
+ } catch (err) {
50
+ // One malformed config must never take down the whole registry.
51
+ log('error', `Skipping ${file}: unreadable config.json (${err.message})`);
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Every upstream name either layer knows about.
58
+ *
59
+ * @param {object} [options] - `{ bundledDir, overlayDir }`
60
+ * @returns {string[]} Sorted names
61
+ */
62
+ function names(options) {
63
+ const { bundledDir, overlayDir } = layers(options);
64
+ const found = new Set();
65
+ for (const dir of [bundledDir, overlayDir]) {
66
+ let entries;
67
+ try {
68
+ entries = fs.readdirSync(dir, { withFileTypes: true });
69
+ } catch {
70
+ continue; // a missing layer is normal: no overlay yet, or a bare checkout
71
+ }
72
+ for (const entry of entries) {
73
+ if (entry.isDirectory() && fs.existsSync(path.join(dir, entry.name, 'config.json'))) found.add(entry.name);
74
+ }
75
+ }
76
+ return [...found].sort();
77
+ }
78
+
79
+ /**
80
+ * The merged RAW config for one upstream — bundled fields with the overlay's
81
+ * top-level keys layered over them.
82
+ *
83
+ * @param {string} name - Upstream name
84
+ * @param {object} [options] - `{ bundledDir, overlayDir }`
85
+ * @returns {object|null} The merged config, or null when neither layer has it
86
+ */
87
+ function mergedConfig(name, options) {
88
+ const { bundledDir, overlayDir } = layers(options);
89
+ const bundled = readLayer(bundledDir, name);
90
+ const overlay = readLayer(overlayDir, name);
91
+ if (!bundled && !overlay) return null;
92
+ return { ...(bundled || {}), ...(overlay || {}) };
93
+ }
94
+
95
+ /**
96
+ * Normalize a raw config into the shape the router and CLI consume.
97
+ *
98
+ * @param {string} name - Upstream name
99
+ * @param {object} config - A merged raw config
100
+ * @param {object} sources - `{ bundled: boolean, overlay: boolean }`
101
+ * @returns {object} The registry entry
102
+ */
103
+ function normalize(name, config, sources) {
104
+ return {
105
+ name,
106
+ enabled_on_disk: config.enabled === true,
107
+ default: config.default === 'on-demand' ? 'on-demand' : 'auto',
108
+ locked: config.locked === true,
109
+ command: config.command,
110
+ args: config.args || [],
111
+ env: config.env || {},
112
+ tools: Array.isArray(config.tools) ? config.tools : [],
113
+ bundled: sources.bundled,
114
+ overlaid: sources.overlay,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * One upstream's layered entry.
120
+ *
121
+ * @param {string} name - Upstream name
122
+ * @param {object} [options] - `{ bundledDir, overlayDir }`
123
+ * @returns {object|null} The registry entry, or null when unknown
124
+ */
125
+ function loadUpstream(name, options) {
126
+ const { bundledDir, overlayDir } = layers(options);
127
+ const config = mergedConfig(name, { bundledDir, overlayDir });
128
+ if (!config) return null;
129
+ return normalize(name, config, {
130
+ bundled: readLayer(bundledDir, name) !== null,
131
+ overlay: readLayer(overlayDir, name) !== null,
132
+ });
133
+ }
134
+
135
+ /**
136
+ * The whole layered registry.
137
+ *
138
+ * @param {object} [options] - `{ bundledDir, overlayDir }`
139
+ * @returns {object} name → registry entry
140
+ */
141
+ function loadUpstreams(options) {
142
+ const { bundledDir, overlayDir } = layers(options);
143
+ const upstreams = {};
144
+ for (const name of names({ bundledDir, overlayDir })) {
145
+ const entry = loadUpstream(name, { bundledDir, overlayDir });
146
+ // Both layers unreadable — readLayer already said so.
147
+ if (entry) upstreams[name] = entry;
148
+ }
149
+ return upstreams;
150
+ }
151
+
152
+ /**
153
+ * Is this name a bundled default?
154
+ *
155
+ * @param {string} name - Upstream name
156
+ * @param {object} [options] - `{ bundledDir }`
157
+ * @returns {boolean} True when the package ships it
158
+ */
159
+ function isBundled(name, options) {
160
+ const { bundledDir } = layers(options);
161
+ return fs.existsSync(path.join(bundledDir, name, 'config.json'));
162
+ }
163
+
164
+ /**
165
+ * The user's overlay entry as written on disk, unmerged.
166
+ *
167
+ * @param {string} name - Upstream name
168
+ * @param {object} [options] - `{ overlayDir }`
169
+ * @returns {object|null} The overlay config, or null when there is none
170
+ */
171
+ function readOverlayEntry(name, options) {
172
+ const { overlayDir } = layers(options);
173
+ return readLayer(overlayDir, name);
174
+ }
175
+
176
+ /**
177
+ * Merge fields into the overlay entry and write it. This is the ONLY write
178
+ * path: a bundled default is never touched, it is only shadowed.
179
+ *
180
+ * @param {string} name - Upstream name
181
+ * @param {object} patch - Top-level keys to set
182
+ * @param {object} [options] - `{ overlayDir }`
183
+ * @returns {object} The overlay entry as written
184
+ */
185
+ function patchOverlayEntry(name, patch, options) {
186
+ const { overlayDir } = layers(options);
187
+ const next = { ...(readLayer(overlayDir, name) || {}), ...patch };
188
+ const dir = path.join(overlayDir, name);
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ fs.writeFileSync(path.join(dir, 'config.json'), `${JSON.stringify(next, null, 2)}\n`);
191
+ return next;
192
+ }
193
+
194
+ /**
195
+ * The one refusal message for a locked upstream, shared by every caller that
196
+ * flips `enabled` on: the CLI and the router's meta-tool say the same thing.
197
+ *
198
+ * @param {string} name - Upstream name
199
+ * @returns {string} The refusal, naming the field and the shell escape hatch
200
+ */
201
+ function lockedRefusal(name) {
202
+ return `Upstream "${name}" is locked (locked: true in its overlay config.json) and will not be enabled. `
203
+ + `Remove that field to unlock it, or run \`omega-mcp enable ${name} --force\` from the shell.`;
204
+ }
205
+
206
+ /**
207
+ * Delete an overlay entry — a bundled default under the same name comes back.
208
+ *
209
+ * @param {string} name - Upstream name
210
+ * @param {object} [options] - `{ overlayDir }`
211
+ * @returns {boolean} True when something was removed
212
+ */
213
+ function removeOverlayEntry(name, options) {
214
+ const { overlayDir } = layers(options);
215
+ const dir = path.join(overlayDir, name);
216
+ if (!fs.existsSync(path.join(dir, 'config.json'))) return false;
217
+ fs.rmSync(dir, { recursive: true });
218
+ return true;
219
+ }
220
+
221
+ module.exports = {
222
+ layers,
223
+ names,
224
+ mergedConfig,
225
+ loadUpstream,
226
+ loadUpstreams,
227
+ isBundled,
228
+ readOverlayEntry,
229
+ lockedRefusal,
230
+ patchOverlayEntry,
231
+ removeOverlayEntry,
232
+ };