@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/router.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The router — ONE stdio MCP server that proxies many upstream MCP servers.
|
|
4
|
+
*
|
|
5
|
+
* A session pays for one always-loaded endpoint; every upstream's tools come
|
|
6
|
+
* from a cached schema on disk, and its child process is spawned LAZILY, on
|
|
7
|
+
* the first tool call that needs it. Tools surface to the client as
|
|
8
|
+
* `<upstream>__<tool>` (a Claude session sees `mcp__mcp-router__<upstream>__<tool>`).
|
|
9
|
+
*
|
|
10
|
+
* Per-session control lives in the `router__*` meta-tools: activating or
|
|
11
|
+
* deactivating an upstream changes THIS session's visible tool list and never
|
|
12
|
+
* touches disk. Disk state is the layered registry (bundled defaults +
|
|
13
|
+
* `~/.omega/mcp-router/servers/`), managed by the `omega-mcp` CLI.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
17
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
18
|
+
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
|
19
|
+
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
|
|
20
|
+
const {
|
|
21
|
+
ListToolsRequestSchema,
|
|
22
|
+
CallToolRequestSchema,
|
|
23
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
24
|
+
|
|
25
|
+
const { log } = require('./lib/log.js');
|
|
26
|
+
const { resolveSpawn } = require('./lib/env.js');
|
|
27
|
+
const { connectWithDeadline, listToolsOnce } = require('./lib/oneshot.js');
|
|
28
|
+
const { descendantPids, killTree } = require('./lib/kill-tree.js');
|
|
29
|
+
const registry = require('./lib/registry.js');
|
|
30
|
+
|
|
31
|
+
// ---------- Upstream registry (from disk) ----------
|
|
32
|
+
|
|
33
|
+
const { bundledDir, overlayDir } = registry.layers();
|
|
34
|
+
const upstreams = registry.loadUpstreams();
|
|
35
|
+
log('info', `Loaded ${Object.keys(upstreams).length} upstream configs (bundled ${bundledDir} + overlay ${overlayDir})`);
|
|
36
|
+
|
|
37
|
+
// ---------- Per-session in-memory state ----------
|
|
38
|
+
|
|
39
|
+
const session = {};
|
|
40
|
+
for (const [name, upstream] of Object.entries(upstreams)) {
|
|
41
|
+
session[name] = {
|
|
42
|
+
active: upstream.enabled_on_disk && upstream.default === 'auto',
|
|
43
|
+
client: null,
|
|
44
|
+
transport: null,
|
|
45
|
+
spawning: null,
|
|
46
|
+
lastError: null,
|
|
47
|
+
envOverride: null,
|
|
48
|
+
inflight: 0,
|
|
49
|
+
lastActivity: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------- Lazy spawn ----------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Spawn one upstream's child process and connect a proxy client to it.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} name - Upstream name
|
|
59
|
+
* @returns {Promise<void>} Resolves once the client is connected
|
|
60
|
+
* @throws {Error} When the handshake does not finish within the spawn deadline
|
|
61
|
+
*/
|
|
62
|
+
const spawnUpstream = async (name) => {
|
|
63
|
+
const upstream = upstreams[name];
|
|
64
|
+
if (!upstream) throw new Error(`Unknown upstream: ${name}`);
|
|
65
|
+
|
|
66
|
+
const spawn = resolveSpawn(upstream, session[name].envOverride || {});
|
|
67
|
+
const transport = new StdioClientTransport({
|
|
68
|
+
command: spawn.command,
|
|
69
|
+
args: spawn.args,
|
|
70
|
+
// Session env override (router__enable_upstream {env}) wins over config env.
|
|
71
|
+
env: { ...process.env, ...spawn.env, ...(session[name].envOverride || {}) },
|
|
72
|
+
stderr: 'inherit',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const client = new Client({ name: 'mcp-router-proxy', version: '1.0.0' }, { capabilities: {} });
|
|
76
|
+
|
|
77
|
+
transport.onclose = () => {
|
|
78
|
+
log('warn', `Upstream "${name}" transport closed (pid was ${transport.pid})`);
|
|
79
|
+
if (session[name].transport === transport) {
|
|
80
|
+
session[name].client = null;
|
|
81
|
+
session[name].transport = null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await connectWithDeadline(client, transport);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// The helper terminated the child; record the failure for
|
|
89
|
+
// router__list_upstreams. Rejecting is what clears `spawning` (the
|
|
90
|
+
// caller's .finally), so the next call spawns fresh.
|
|
91
|
+
session[name].lastError = err.message;
|
|
92
|
+
log('error', `Spawn of upstream "${name}" failed: ${err.message}`);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
session[name].client = client;
|
|
97
|
+
session[name].transport = transport;
|
|
98
|
+
// A fresh child is busy by definition: the call that spawned it is next.
|
|
99
|
+
session[name].lastActivity = Date.now();
|
|
100
|
+
log('info', `Spawned upstream "${name}" (pid=${transport.pid})`);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Close an upstream's child, force-killing what outlives the grace period.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} name - Upstream name
|
|
107
|
+
* @param {{wait?: boolean}} [options] - `wait` holds the caller until the
|
|
108
|
+
* grace has run and every survivor is killed. Shutdown needs it: the timer
|
|
109
|
+
* is unref'd everywhere else, so the exit would beat it and orphan the tree.
|
|
110
|
+
* @returns {Promise<void>} Resolves once close() has been attempted — and once
|
|
111
|
+
* the grace has run too, when `wait` is set
|
|
112
|
+
*/
|
|
113
|
+
const killUpstream = async (name, { wait = false } = {}) => {
|
|
114
|
+
const state = session[name];
|
|
115
|
+
if (!state || !state.transport) return;
|
|
116
|
+
// The pid and the WHOLE tree under it are read BEFORE the close, while both
|
|
117
|
+
// still exist: close() clears the transport's own process handle, so nothing
|
|
118
|
+
// read afterwards can even name the child. And close() only ever reaches
|
|
119
|
+
// that ROOT pid — everything below is reparented to init the moment it dies,
|
|
120
|
+
// out of reach of any later walk, one level at a time as each level goes.
|
|
121
|
+
// For an `npx`-wrapped upstream the root is only the npm wrapper, so this
|
|
122
|
+
// capture is the last handle on the real server and the browser it started.
|
|
123
|
+
const pid = state.transport.pid;
|
|
124
|
+
const descendants = pid ? descendantPids(pid) : [];
|
|
125
|
+
|
|
126
|
+
// The slot is emptied when the close STARTS, not when it finishes: a close
|
|
127
|
+
// runs for seconds against a child that will not go politely, and a call
|
|
128
|
+
// landing in that window used to be handed the closing client and fail. With
|
|
129
|
+
// the slot already empty it takes the lazy-spawn path and gets a fresh
|
|
130
|
+
// child, and the sweep's next tick sees nothing left to close here. The
|
|
131
|
+
// transport's own onclose guard stays right — it finds the slot moved on and
|
|
132
|
+
// leaves it alone.
|
|
133
|
+
const client = state.client;
|
|
134
|
+
state.client = null;
|
|
135
|
+
state.transport = null;
|
|
136
|
+
try {
|
|
137
|
+
await client.close();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
log('warn', `client.close() for "${name}" threw: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// close() should terminate the child; force-kill after grace period if needed.
|
|
143
|
+
const reaped = new Promise((resolve) => {
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
if (pid) {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(pid, 0);
|
|
148
|
+
log('warn', `Upstream "${name}" still alive after close(); SIGKILL`);
|
|
149
|
+
killTree(pid, 'SIGKILL');
|
|
150
|
+
} catch {
|
|
151
|
+
// process is gone — fine
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// What the child started outlives it either way. One that already exited
|
|
155
|
+
// alongside its parent is a no-op (ESRCH); anything else — a pid that
|
|
156
|
+
// has become somebody else's since the capture — is reported and stepped
|
|
157
|
+
// over, because the graces after it still have trees to kill.
|
|
158
|
+
for (const child of descendants) {
|
|
159
|
+
try {
|
|
160
|
+
killTree(child, 'SIGKILL');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
log('warn', `Killing "${name}" descendant ${child} threw: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
resolve();
|
|
166
|
+
}, 2000);
|
|
167
|
+
|
|
168
|
+
// Housekeeping never keeps a router alive that is otherwise done — the one
|
|
169
|
+
// exception is the caller that asked to wait, which is on its way out and
|
|
170
|
+
// has to see this through first.
|
|
171
|
+
if (!wait) timer.unref();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (wait) await reaped;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ---------- Idle close ----------
|
|
178
|
+
|
|
179
|
+
// How long an upstream's child may sit unused before the router closes it. A
|
|
180
|
+
// session is open for hours and calls an upstream for minutes: the child used
|
|
181
|
+
// to live for the whole session, which is 2.5 cores of browser for a day of
|
|
182
|
+
// chat. Closing costs one cold spawn on the next call and nothing else.
|
|
183
|
+
// MCP_ROUTER_IDLE_MS is the test seam, alongside the one in oneshot.js.
|
|
184
|
+
const IDLE_MS = Number(process.env.MCP_ROUTER_IDLE_MS) || 15 * 60 * 1000;
|
|
185
|
+
|
|
186
|
+
// Derived from the limit (never faster than a second) so the seam shrinks the
|
|
187
|
+
// sweep with it: a test does not wait out a cadence tuned for 15 minutes.
|
|
188
|
+
const IDLE_SWEEP_MS = Math.max(1000, Math.floor(IDLE_MS / 4));
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Close every spawned upstream that has been idle past the limit.
|
|
192
|
+
*
|
|
193
|
+
* A call IN FLIGHT is never closed under: a long tool call is idle by the
|
|
194
|
+
* clock the whole time it runs, so the counter is what protects it. The
|
|
195
|
+
* upstream stays active for the session — the next call spawns a fresh child
|
|
196
|
+
* through the unchanged lazy path.
|
|
197
|
+
*
|
|
198
|
+
* @returns {Promise<void>} Resolves once the idle children are closed
|
|
199
|
+
*/
|
|
200
|
+
const sweepIdleUpstreams = async () => {
|
|
201
|
+
for (const [name, state] of Object.entries(session)) {
|
|
202
|
+
if (!state.client || state.inflight > 0) continue;
|
|
203
|
+
|
|
204
|
+
const idle = Date.now() - state.lastActivity;
|
|
205
|
+
if (idle <= IDLE_MS) continue;
|
|
206
|
+
|
|
207
|
+
log('info', `Closing idle upstream "${name}" (${Math.round(idle / 1000)}s since its last call)`);
|
|
208
|
+
await killUpstream(name);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Unref'd: housekeeping never keeps a router alive that is otherwise done.
|
|
213
|
+
setInterval(() => {
|
|
214
|
+
// A rejection here would be an unhandled one, and the sweep must never take
|
|
215
|
+
// a live session's router down with it.
|
|
216
|
+
sweepIdleUpstreams().catch((err) => log('error', `Idle sweep failed: ${err.message}`));
|
|
217
|
+
}, IDLE_SWEEP_MS).unref();
|
|
218
|
+
|
|
219
|
+
// ---------- MCP Server ----------
|
|
220
|
+
|
|
221
|
+
const server = new Server(
|
|
222
|
+
{ name: 'mcp-router', version: '1.0.0' },
|
|
223
|
+
{ capabilities: { tools: { listChanged: true } } },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// ---------- Meta-tools ----------
|
|
227
|
+
|
|
228
|
+
const META_TOOLS = [
|
|
229
|
+
{
|
|
230
|
+
name: 'router__list_upstreams',
|
|
231
|
+
description:
|
|
232
|
+
'List all upstream MCP servers registered with the router, including their on-disk enabled state, locked state, per-session active state, and cached tool count.',
|
|
233
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: 'router__enable_upstream',
|
|
237
|
+
description:
|
|
238
|
+
'Activate an upstream for THIS chat session only. The upstream must be enabled on disk (via `omega-mcp enable <name>`) and not locked (an upstream with "locked": true in its overlay config refuses to activate, and there is no override from here). Adds its tools to the visible tool list. Optional env vars apply to the child process for this session (e.g. a debug port); passing env restarts a running child so the values take effect.',
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {
|
|
242
|
+
name: { type: 'string', description: 'Upstream name (e.g. "chrome-devtools")' },
|
|
243
|
+
env: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
additionalProperties: { type: 'string' },
|
|
246
|
+
description: 'Per-session env vars for the child process (e.g. {"OMEGA_CDP_PORT": "9222"}). Cleared by router__disable_upstream.',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
required: ['name'],
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: 'router__disable_upstream',
|
|
255
|
+
description:
|
|
256
|
+
'Deactivate an upstream for THIS chat session only. Removes its tools and kills any running child process. Does NOT modify disk config.',
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
properties: { name: { type: 'string' } },
|
|
260
|
+
required: ['name'],
|
|
261
|
+
additionalProperties: false,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'router__refresh_upstream',
|
|
266
|
+
description:
|
|
267
|
+
'Force-spawn an upstream, re-read its tool list, and persist it to the overlay cache at ~/.omega/mcp-router/servers/<name>/config.json. Use when the cached schema is stale.',
|
|
268
|
+
inputSchema: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties: { name: { type: 'string' } },
|
|
271
|
+
required: ['name'],
|
|
272
|
+
additionalProperties: false,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
const textResult = (text) => ({ content: [{ type: 'text', text }] });
|
|
278
|
+
const errorResult = (text) => ({ isError: true, content: [{ type: 'text', text }] });
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Run one `router__*` meta-tool.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} name - Meta-tool name
|
|
284
|
+
* @param {object} args - Tool arguments
|
|
285
|
+
* @returns {Promise<object>} An MCP tool result
|
|
286
|
+
*/
|
|
287
|
+
const callMetaTool = async (name, args) => {
|
|
288
|
+
if (name === 'router__list_upstreams') {
|
|
289
|
+
const rows = Object.values(upstreams).map((upstream) => ({
|
|
290
|
+
name: upstream.name,
|
|
291
|
+
enabled_on_disk: upstream.enabled_on_disk,
|
|
292
|
+
default: upstream.default,
|
|
293
|
+
locked: upstream.locked,
|
|
294
|
+
active_this_session: session[upstream.name].active,
|
|
295
|
+
spawned: session[upstream.name].client != null,
|
|
296
|
+
pid: session[upstream.name].transport ? session[upstream.name].transport.pid : null,
|
|
297
|
+
idle_ms: session[upstream.name].client ? Date.now() - session[upstream.name].lastActivity : null,
|
|
298
|
+
tool_count: upstream.tools.length,
|
|
299
|
+
last_error: session[upstream.name].lastError,
|
|
300
|
+
}));
|
|
301
|
+
return textResult(JSON.stringify(rows, null, 2));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (name === 'router__enable_upstream') {
|
|
305
|
+
const target = args?.name;
|
|
306
|
+
const upstream = upstreams[target];
|
|
307
|
+
if (!upstream) return errorResult(`Unknown upstream: ${target}`);
|
|
308
|
+
// The overlay may have changed since router startup (an enable flip, a
|
|
309
|
+
// lock written mid-session) — re-read the LAYERED state so the "then call
|
|
310
|
+
// this tool again" retry succeeds and a fresh lock is honored even on an
|
|
311
|
+
// upstream that was already enabled on disk.
|
|
312
|
+
const fresh = registry.loadUpstream(target);
|
|
313
|
+
if (fresh) Object.assign(upstream, fresh);
|
|
314
|
+
// A lock outranks the disabled hint: there is no force path from a chat.
|
|
315
|
+
if (upstream.locked) return errorResult(registry.lockedRefusal(target));
|
|
316
|
+
if (!upstream.enabled_on_disk) {
|
|
317
|
+
return errorResult(
|
|
318
|
+
`Upstream "${target}" is disabled on disk. Run \`omega-mcp enable ${target}\` from the shell to make it available, then call this tool again.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
// Optional per-session env override for the child (e.g. a debug port).
|
|
322
|
+
// A running child was spawned WITHOUT it — restart so the values apply.
|
|
323
|
+
let envNote = '';
|
|
324
|
+
if (args?.env && typeof args.env === 'object' && !Array.isArray(args.env)) {
|
|
325
|
+
const bad = Object.entries(args.env).find(([, value]) => typeof value !== 'string');
|
|
326
|
+
if (bad) return errorResult(`env values must be strings (got ${typeof bad[1]} for "${bad[0]}")`);
|
|
327
|
+
session[target].envOverride = { ...args.env };
|
|
328
|
+
// An in-flight cold spawn would complete with the OLD env and pin a live
|
|
329
|
+
// client — settle it first so the kill below always lands.
|
|
330
|
+
if (session[target].spawning) await session[target].spawning.catch(() => {});
|
|
331
|
+
const hadChild = session[target].client != null;
|
|
332
|
+
if (hadChild) await killUpstream(target);
|
|
333
|
+
envNote = ` Env override set (${Object.keys(args.env).join(', ')})${hadChild ? '; child restarted' : ''}.`;
|
|
334
|
+
}
|
|
335
|
+
session[target].active = true;
|
|
336
|
+
await server.sendToolListChanged();
|
|
337
|
+
return textResult(`Enabled "${target}" for this session (${upstream.tools.length} tools).${envNote}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (name === 'router__disable_upstream') {
|
|
341
|
+
const target = args?.name;
|
|
342
|
+
if (!upstreams[target]) return errorResult(`Unknown upstream: ${target}`);
|
|
343
|
+
session[target].active = false;
|
|
344
|
+
session[target].envOverride = null;
|
|
345
|
+
await killUpstream(target);
|
|
346
|
+
await server.sendToolListChanged();
|
|
347
|
+
return textResult(`Disabled "${target}" for this session and stopped child process.`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (name === 'router__refresh_upstream') {
|
|
351
|
+
const target = args?.name;
|
|
352
|
+
const upstream = upstreams[target];
|
|
353
|
+
if (!upstream) return errorResult(`Unknown upstream: ${target}`);
|
|
354
|
+
// The config may have changed since router startup (a re-pointed command,
|
|
355
|
+
// new args), so re-read the LAYERED state: the spawn below must use what
|
|
356
|
+
// is on disk NOW, which is the whole point of refreshing without a restart.
|
|
357
|
+
const fresh = registry.loadUpstream(target);
|
|
358
|
+
if (fresh) Object.assign(upstream, fresh);
|
|
359
|
+
try {
|
|
360
|
+
// Spawn a one-shot client to fetch tools (don't disturb running session
|
|
361
|
+
// client). The helper is the same one `omega-mcp refresh` runs, so both
|
|
362
|
+
// surfaces carry the same deadline, read budget, and failure cleanup; the
|
|
363
|
+
// outer catch answers the caller.
|
|
364
|
+
const spawn = resolveSpawn(upstream, session[target].envOverride || {});
|
|
365
|
+
const tools = await listToolsOnce({
|
|
366
|
+
command: spawn.command,
|
|
367
|
+
args: spawn.args,
|
|
368
|
+
// The session override applies here too — without it a refresh of an
|
|
369
|
+
// upstream that only starts with the override (Electron port) fails.
|
|
370
|
+
env: { ...process.env, ...spawn.env, ...(session[target].envOverride || {}) },
|
|
371
|
+
});
|
|
372
|
+
session[target].lastError = null;
|
|
373
|
+
|
|
374
|
+
// The cache lands in the OVERLAY — the bundled dir is read-only.
|
|
375
|
+
registry.patchOverlayEntry(target, { tools });
|
|
376
|
+
upstream.tools = tools;
|
|
377
|
+
await server.sendToolListChanged();
|
|
378
|
+
return textResult(`Refreshed "${target}" — ${tools.length} tools cached to ${overlayDir}/${target}/config.json.`);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
// Same record as a failed spawn: without it router__list_upstreams shows
|
|
381
|
+
// a clean upstream after a refresh that failed.
|
|
382
|
+
session[target].lastError = err.message;
|
|
383
|
+
log('error', `Refresh of upstream "${target}" failed: ${err.message}`);
|
|
384
|
+
return errorResult(`Refresh failed for "${target}": ${err.message}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return errorResult(`Unknown meta-tool: ${name}`);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// ---------- Request handlers ----------
|
|
392
|
+
|
|
393
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
394
|
+
const tools = [...META_TOOLS];
|
|
395
|
+
for (const [name, upstream] of Object.entries(upstreams)) {
|
|
396
|
+
if (!session[name].active || !upstream.enabled_on_disk) continue;
|
|
397
|
+
if (upstream.tools.length === 0) {
|
|
398
|
+
log('warn', `Upstream "${name}" active but has no cached tools; skipping. Run \`omega-mcp refresh ${name}\`.`);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
for (const tool of upstream.tools) {
|
|
402
|
+
tools.push({
|
|
403
|
+
name: `${name}__${tool.name}`,
|
|
404
|
+
description: tool.description,
|
|
405
|
+
inputSchema: tool.inputSchema,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return { tools };
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
413
|
+
const fullName = request.params.name;
|
|
414
|
+
const args = request.params.arguments || {};
|
|
415
|
+
|
|
416
|
+
if (fullName.startsWith('router__')) {
|
|
417
|
+
return callMetaTool(fullName, args);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const separator = fullName.indexOf('__');
|
|
421
|
+
if (separator === -1) {
|
|
422
|
+
return errorResult(`Tool name "${fullName}" is not in <upstream>__<tool> format`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const upstreamName = fullName.slice(0, separator);
|
|
426
|
+
const toolName = fullName.slice(separator + 2);
|
|
427
|
+
const upstream = upstreams[upstreamName];
|
|
428
|
+
|
|
429
|
+
if (!upstream) return errorResult(`Unknown upstream: ${upstreamName}`);
|
|
430
|
+
if (!upstream.enabled_on_disk) {
|
|
431
|
+
return errorResult(
|
|
432
|
+
`Upstream "${upstreamName}" is disabled on disk. Run \`omega-mcp enable ${upstreamName}\` to enable it.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (!session[upstreamName].active) {
|
|
436
|
+
return errorResult(
|
|
437
|
+
`Upstream "${upstreamName}" is inactive in this session. Call router__enable_upstream first.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
if (!session[upstreamName].client) {
|
|
443
|
+
// Concurrent cold calls share ONE in-flight spawn — otherwise both would
|
|
444
|
+
// spawn a child and the loser's process would leak.
|
|
445
|
+
if (!session[upstreamName].spawning) {
|
|
446
|
+
session[upstreamName].spawning = spawnUpstream(upstreamName).finally(() => {
|
|
447
|
+
session[upstreamName].spawning = null;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
await session[upstreamName].spawning;
|
|
451
|
+
}
|
|
452
|
+
// Both halves feed the idle sweep: the counter keeps a call in flight from
|
|
453
|
+
// being closed under, and the stamps at each end are what "idle" measures.
|
|
454
|
+
session[upstreamName].inflight += 1;
|
|
455
|
+
session[upstreamName].lastActivity = Date.now();
|
|
456
|
+
try {
|
|
457
|
+
const result = await session[upstreamName].client.callTool({ name: toolName, arguments: args });
|
|
458
|
+
session[upstreamName].lastError = null;
|
|
459
|
+
return result;
|
|
460
|
+
} finally {
|
|
461
|
+
session[upstreamName].inflight -= 1;
|
|
462
|
+
session[upstreamName].lastActivity = Date.now();
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
const message = err && err.message ? err.message : String(err);
|
|
466
|
+
session[upstreamName].lastError = message;
|
|
467
|
+
log('error', `callTool "${fullName}" failed: ${message}`);
|
|
468
|
+
return errorResult(`Upstream "${upstreamName}" failed: ${message}`);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ---------- Startup ----------
|
|
473
|
+
|
|
474
|
+
const main = async () => {
|
|
475
|
+
const transport = new StdioServerTransport();
|
|
476
|
+
await server.connect(transport);
|
|
477
|
+
server.onclose = shutdown;
|
|
478
|
+
// The SDK's stdio server transport listens for data and errors only, so an
|
|
479
|
+
// EOF on stdin leaves it open — and a spawned child's handle then holds this
|
|
480
|
+
// process open for good. EOF IS the host being gone; close through the
|
|
481
|
+
// transport so there stays ONE way out.
|
|
482
|
+
process.stdin.on('end', () => transport.close());
|
|
483
|
+
log('info', 'Router ready on stdio');
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
main().catch((err) => {
|
|
487
|
+
log('fatal', err && err.stack ? err.stack : String(err));
|
|
488
|
+
process.exit(1);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Graceful shutdown — kill any spawned upstreams.
|
|
492
|
+
//
|
|
493
|
+
// ONE run, whatever asks for it: a host tears down by ending stdin, then
|
|
494
|
+
// SIGTERM a moment later, then SIGKILL. That signal lands while the first run
|
|
495
|
+
// is still waiting out its grace, and a second run would find every slot
|
|
496
|
+
// already emptied by the first, await nothing, and exit(0) on top of the timers
|
|
497
|
+
// that had yet to kill the trees. So every trigger gets the SAME promise.
|
|
498
|
+
let shuttingDown = null;
|
|
499
|
+
|
|
500
|
+
const shutdown = () => (shuttingDown ??= (async () => {
|
|
501
|
+
log('info', 'Shutting down; closing upstreams');
|
|
502
|
+
// The ceiling: a close that never settles must not hold the router past the
|
|
503
|
+
// host's own SIGKILL, where the exit stops being ours to make.
|
|
504
|
+
setTimeout(() => process.exit(0), 6000).unref();
|
|
505
|
+
// Session end is the EVERYDAY close, so the grace is waited out here rather
|
|
506
|
+
// than skipped: exiting the moment close() returns leaves whatever a
|
|
507
|
+
// hard-killed wrapper started — a browser, most of the time — running with
|
|
508
|
+
// nothing left that knows its pid. The graces run concurrently, so this
|
|
509
|
+
// costs the one grace period, once.
|
|
510
|
+
await Promise.all(Object.keys(session).map((name) => killUpstream(name, { wait: true })));
|
|
511
|
+
process.exit(0);
|
|
512
|
+
})());
|
|
513
|
+
|
|
514
|
+
process.on('SIGINT', shutdown);
|
|
515
|
+
process.on('SIGTERM', shutdown);
|