@learnrudi/cli 1.9.0 → 1.9.1
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/dist/router-mcp.js +604 -0
- package/package.json +2 -2
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* RUDI Router MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Central MCP dispatcher that:
|
|
6
|
+
* - Reads ~/.rudi/rudi.json
|
|
7
|
+
* - Lists all installed stack tools (namespaced)
|
|
8
|
+
* - Proxies tool calls to correct stack subprocess
|
|
9
|
+
* - Maintains connection pool per stack
|
|
10
|
+
*
|
|
11
|
+
* Design decisions:
|
|
12
|
+
* - Cached tool index: Fast tools/list without spawning all stacks
|
|
13
|
+
* - Lazy spawn: Only spawn stack servers when needed
|
|
14
|
+
* - stdout = protocol only: All logging goes to stderr
|
|
15
|
+
* - Handle null IDs: MCP notifications have id: null
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'child_process';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import * as readline from 'readline';
|
|
22
|
+
import * as os from 'os';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// CONSTANTS
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
const RUDI_HOME = process.env.RUDI_HOME || path.join(os.homedir(), '.rudi');
|
|
29
|
+
const RUDI_JSON_PATH = path.join(RUDI_HOME, 'rudi.json');
|
|
30
|
+
const SECRETS_PATH = path.join(RUDI_HOME, 'secrets.json');
|
|
31
|
+
const TOOL_INDEX_PATH = path.join(RUDI_HOME, 'cache', 'tool-index.json');
|
|
32
|
+
|
|
33
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
34
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// STATE
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
/** @type {Map<string, StackServer>} */
|
|
41
|
+
const serverPool = new Map();
|
|
42
|
+
|
|
43
|
+
/** @type {RudiConfig | null} */
|
|
44
|
+
let rudiConfig = null;
|
|
45
|
+
|
|
46
|
+
/** @type {Object | null} */
|
|
47
|
+
let toolIndex = null;
|
|
48
|
+
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// TYPES (JSDoc)
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {Object} StackServer
|
|
55
|
+
* @property {import('child_process').ChildProcess} process
|
|
56
|
+
* @property {readline.Interface} rl
|
|
57
|
+
* @property {Map<string|number, PendingRequest>} pending
|
|
58
|
+
* @property {string} buffer
|
|
59
|
+
* @property {boolean} initialized
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} PendingRequest
|
|
64
|
+
* @property {(value: JsonRpcResponse) => void} resolve
|
|
65
|
+
* @property {(error: Error) => void} reject
|
|
66
|
+
* @property {NodeJS.Timeout} timeout
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {Object} JsonRpcRequest
|
|
71
|
+
* @property {'2.0'} jsonrpc
|
|
72
|
+
* @property {string|number|null} [id]
|
|
73
|
+
* @property {string} method
|
|
74
|
+
* @property {Object} [params]
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {Object} JsonRpcResponse
|
|
79
|
+
* @property {'2.0'} jsonrpc
|
|
80
|
+
* @property {string|number|null} id
|
|
81
|
+
* @property {*} [result]
|
|
82
|
+
* @property {{code: number, message: string, data?: *}} [error]
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// LOGGING (all to stderr to keep stdout clean for MCP protocol)
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
function log(msg) {
|
|
90
|
+
process.stderr.write(`[rudi-router] ${msg}\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function debug(msg) {
|
|
94
|
+
if (process.env.DEBUG) {
|
|
95
|
+
process.stderr.write(`[rudi-router:debug] ${msg}\n`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =============================================================================
|
|
100
|
+
// CONFIG & SECRETS
|
|
101
|
+
// =============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Load rudi.json
|
|
105
|
+
* @returns {Object}
|
|
106
|
+
*/
|
|
107
|
+
function loadRudiConfig() {
|
|
108
|
+
try {
|
|
109
|
+
const content = fs.readFileSync(RUDI_JSON_PATH, 'utf-8');
|
|
110
|
+
return JSON.parse(content);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log(`Failed to load rudi.json: ${err.message}`);
|
|
113
|
+
return { stacks: {}, runtimes: {}, binaries: {}, secrets: {} };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load tool index from cache file
|
|
119
|
+
* @returns {Object | null}
|
|
120
|
+
*/
|
|
121
|
+
function loadToolIndex() {
|
|
122
|
+
try {
|
|
123
|
+
const content = fs.readFileSync(TOOL_INDEX_PATH, 'utf-8');
|
|
124
|
+
return JSON.parse(content);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Load secrets.json
|
|
132
|
+
* @returns {Object<string, string>}
|
|
133
|
+
*/
|
|
134
|
+
function loadSecrets() {
|
|
135
|
+
try {
|
|
136
|
+
const content = fs.readFileSync(SECRETS_PATH, 'utf-8');
|
|
137
|
+
return JSON.parse(content);
|
|
138
|
+
} catch {
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get secrets for a specific stack
|
|
145
|
+
* @param {string} stackId
|
|
146
|
+
* @returns {Object<string, string>}
|
|
147
|
+
*/
|
|
148
|
+
function getStackSecrets(stackId) {
|
|
149
|
+
const allSecrets = loadSecrets();
|
|
150
|
+
const stackConfig = rudiConfig?.stacks?.[stackId];
|
|
151
|
+
if (!stackConfig?.secrets) return {};
|
|
152
|
+
|
|
153
|
+
const result = {};
|
|
154
|
+
for (const secretDef of stackConfig.secrets) {
|
|
155
|
+
const name = typeof secretDef === 'string' ? secretDef : secretDef.name;
|
|
156
|
+
if (allSecrets[name]) {
|
|
157
|
+
result[name] = allSecrets[name];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// STACK SERVER MANAGEMENT
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Spawn a stack MCP server as subprocess
|
|
169
|
+
* @param {string} stackId
|
|
170
|
+
* @param {Object} stackConfig
|
|
171
|
+
* @returns {StackServer}
|
|
172
|
+
*/
|
|
173
|
+
function spawnStackServer(stackId, stackConfig) {
|
|
174
|
+
const launch = stackConfig.launch;
|
|
175
|
+
if (!launch || !launch.bin) {
|
|
176
|
+
throw new Error(`Stack ${stackId} has no launch configuration`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const secrets = getStackSecrets(stackId);
|
|
180
|
+
const env = { ...process.env, ...secrets };
|
|
181
|
+
|
|
182
|
+
// Add bundled runtimes to PATH
|
|
183
|
+
const nodeBin = path.join(RUDI_HOME, 'runtimes', 'node', 'bin');
|
|
184
|
+
const pythonBin = path.join(RUDI_HOME, 'runtimes', 'python', 'bin');
|
|
185
|
+
if (fs.existsSync(nodeBin) || fs.existsSync(pythonBin)) {
|
|
186
|
+
const runtimePaths = [];
|
|
187
|
+
if (fs.existsSync(nodeBin)) runtimePaths.push(nodeBin);
|
|
188
|
+
if (fs.existsSync(pythonBin)) runtimePaths.push(pythonBin);
|
|
189
|
+
env.PATH = runtimePaths.join(path.delimiter) + path.delimiter + (env.PATH || '');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
debug(`Spawning stack ${stackId}: ${launch.bin} ${launch.args?.join(' ')}`);
|
|
193
|
+
|
|
194
|
+
const childProcess = spawn(launch.bin, launch.args || [], {
|
|
195
|
+
cwd: launch.cwd || stackConfig.path,
|
|
196
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
197
|
+
env
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const rl = readline.createInterface({
|
|
201
|
+
input: childProcess.stdout,
|
|
202
|
+
terminal: false
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** @type {StackServer} */
|
|
206
|
+
const server = {
|
|
207
|
+
process: childProcess,
|
|
208
|
+
rl,
|
|
209
|
+
pending: new Map(),
|
|
210
|
+
buffer: '',
|
|
211
|
+
initialized: false
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Handle responses from stack
|
|
215
|
+
rl.on('line', (line) => {
|
|
216
|
+
try {
|
|
217
|
+
const response = JSON.parse(line);
|
|
218
|
+
debug(`<< ${stackId}: ${line.slice(0, 200)}`);
|
|
219
|
+
|
|
220
|
+
// Handle notifications (id is null or missing)
|
|
221
|
+
if (response.id === null || response.id === undefined) {
|
|
222
|
+
debug(`Notification from ${stackId}: ${response.method || 'unknown'}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const pending = server.pending.get(response.id);
|
|
227
|
+
if (pending) {
|
|
228
|
+
clearTimeout(pending.timeout);
|
|
229
|
+
server.pending.delete(response.id);
|
|
230
|
+
pending.resolve(response);
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
debug(`Failed to parse response from ${stackId}: ${err.message}`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Pipe stack stderr to our stderr
|
|
238
|
+
childProcess.stderr?.on('data', (data) => {
|
|
239
|
+
process.stderr.write(`[${stackId}] ${data}`);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
childProcess.on('error', (err) => {
|
|
243
|
+
log(`Stack process error (${stackId}): ${err.message}`);
|
|
244
|
+
serverPool.delete(stackId);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
childProcess.on('exit', (code, signal) => {
|
|
248
|
+
debug(`Stack ${stackId} exited: code=${code}, signal=${signal}`);
|
|
249
|
+
rl.close();
|
|
250
|
+
serverPool.delete(stackId);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return server;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get or spawn a stack server
|
|
258
|
+
* @param {string} stackId
|
|
259
|
+
* @returns {StackServer}
|
|
260
|
+
*/
|
|
261
|
+
function getOrSpawnServer(stackId) {
|
|
262
|
+
const existing = serverPool.get(stackId);
|
|
263
|
+
if (existing && !existing.process.killed) {
|
|
264
|
+
return existing;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const stackConfig = rudiConfig?.stacks?.[stackId];
|
|
268
|
+
if (!stackConfig) {
|
|
269
|
+
throw new Error(`Stack not found: ${stackId}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!stackConfig.installed) {
|
|
273
|
+
throw new Error(`Stack not installed: ${stackId}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const server = spawnStackServer(stackId, stackConfig);
|
|
277
|
+
serverPool.set(stackId, server);
|
|
278
|
+
return server;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Send JSON-RPC request to stack server
|
|
283
|
+
* @param {StackServer} server
|
|
284
|
+
* @param {JsonRpcRequest} request
|
|
285
|
+
* @param {number} [timeoutMs]
|
|
286
|
+
* @returns {Promise<JsonRpcResponse>}
|
|
287
|
+
*/
|
|
288
|
+
async function sendToStack(server, request, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const timeout = setTimeout(() => {
|
|
291
|
+
server.pending.delete(request.id);
|
|
292
|
+
reject(new Error(`Request timeout: ${request.method}`));
|
|
293
|
+
}, timeoutMs);
|
|
294
|
+
|
|
295
|
+
server.pending.set(request.id, { resolve, reject, timeout });
|
|
296
|
+
|
|
297
|
+
const line = JSON.stringify(request) + '\n';
|
|
298
|
+
debug(`>> ${line.slice(0, 200)}`);
|
|
299
|
+
server.process.stdin?.write(line);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Initialize a stack server (MCP handshake)
|
|
305
|
+
* @param {StackServer} server
|
|
306
|
+
* @param {string} stackId
|
|
307
|
+
*/
|
|
308
|
+
async function initializeStack(server, stackId) {
|
|
309
|
+
if (server.initialized) return;
|
|
310
|
+
|
|
311
|
+
const initRequest = {
|
|
312
|
+
jsonrpc: '2.0',
|
|
313
|
+
id: `init-${stackId}-${Date.now()}`,
|
|
314
|
+
method: 'initialize',
|
|
315
|
+
params: {
|
|
316
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
317
|
+
capabilities: {},
|
|
318
|
+
clientInfo: {
|
|
319
|
+
name: 'rudi-router',
|
|
320
|
+
version: '1.0.0'
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const response = await sendToStack(server, initRequest);
|
|
327
|
+
if (!response.error) {
|
|
328
|
+
server.initialized = true;
|
|
329
|
+
debug(`Stack ${stackId} initialized`);
|
|
330
|
+
|
|
331
|
+
// Send initialized notification
|
|
332
|
+
server.process.stdin?.write(JSON.stringify({
|
|
333
|
+
jsonrpc: '2.0',
|
|
334
|
+
method: 'notifications/initialized'
|
|
335
|
+
}) + '\n');
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
debug(`Failed to initialize ${stackId}: ${err.message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// =============================================================================
|
|
343
|
+
// MCP HANDLERS
|
|
344
|
+
// =============================================================================
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* List all tools from all installed stacks (namespaced)
|
|
348
|
+
* Priority: 1. tool-index.json cache, 2. rudi.json inline tools, 3. live query
|
|
349
|
+
* @returns {Promise<Array<{name: string, description: string, inputSchema: Object}>>}
|
|
350
|
+
*/
|
|
351
|
+
async function listTools() {
|
|
352
|
+
const tools = [];
|
|
353
|
+
|
|
354
|
+
for (const [stackId, stackConfig] of Object.entries(rudiConfig?.stacks || {})) {
|
|
355
|
+
if (!stackConfig.installed) continue;
|
|
356
|
+
|
|
357
|
+
// 1. Check tool-index.json cache (from `rudi index` command)
|
|
358
|
+
const indexEntry = toolIndex?.byStack?.[stackId];
|
|
359
|
+
if (indexEntry?.tools && indexEntry.tools.length > 0 && !indexEntry.error) {
|
|
360
|
+
tools.push(...indexEntry.tools.map(t => ({
|
|
361
|
+
name: `${stackId}.${t.name}`,
|
|
362
|
+
description: `[${stackId}] ${t.description || t.name}`,
|
|
363
|
+
inputSchema: t.inputSchema || { type: 'object', properties: {} }
|
|
364
|
+
})));
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 2. Check inline tools in rudi.json (legacy/fallback)
|
|
369
|
+
if (stackConfig.tools && stackConfig.tools.length > 0) {
|
|
370
|
+
tools.push(...stackConfig.tools.map(t => ({
|
|
371
|
+
name: `${stackId}.${t.name}`,
|
|
372
|
+
description: `[${stackId}] ${t.description || t.name}`,
|
|
373
|
+
inputSchema: t.inputSchema || { type: 'object', properties: {} }
|
|
374
|
+
})));
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 3. Fall back to querying the stack (slow, spawns server)
|
|
379
|
+
try {
|
|
380
|
+
const server = getOrSpawnServer(stackId);
|
|
381
|
+
await initializeStack(server, stackId);
|
|
382
|
+
|
|
383
|
+
const response = await sendToStack(server, {
|
|
384
|
+
jsonrpc: '2.0',
|
|
385
|
+
id: `list-${stackId}-${Date.now()}`,
|
|
386
|
+
method: 'tools/list'
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (response.result?.tools) {
|
|
390
|
+
tools.push(...response.result.tools.map(t => ({
|
|
391
|
+
name: `${stackId}.${t.name}`,
|
|
392
|
+
description: `[${stackId}] ${t.description || t.name}`,
|
|
393
|
+
inputSchema: t.inputSchema || { type: 'object', properties: {} }
|
|
394
|
+
})));
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
log(`Failed to list tools from ${stackId}: ${err.message}`);
|
|
398
|
+
// Continue with other stacks
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return tools;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Call a tool on the appropriate stack
|
|
407
|
+
* @param {string} toolName - Namespaced tool name (e.g., "slack.send_message")
|
|
408
|
+
* @param {Object} arguments_
|
|
409
|
+
* @returns {Promise<*>}
|
|
410
|
+
*/
|
|
411
|
+
async function callTool(toolName, arguments_) {
|
|
412
|
+
// Parse namespace: "slack.send_message" → stackId="slack", actualTool="send_message"
|
|
413
|
+
const dotIndex = toolName.indexOf('.');
|
|
414
|
+
if (dotIndex === -1) {
|
|
415
|
+
throw new Error(`Invalid tool name format: ${toolName} (expected: stack.tool_name)`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const stackId = toolName.slice(0, dotIndex);
|
|
419
|
+
const actualToolName = toolName.slice(dotIndex + 1);
|
|
420
|
+
|
|
421
|
+
if (!rudiConfig?.stacks?.[stackId]) {
|
|
422
|
+
throw new Error(`Stack not found: ${stackId}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const server = getOrSpawnServer(stackId);
|
|
426
|
+
await initializeStack(server, stackId);
|
|
427
|
+
|
|
428
|
+
const response = await sendToStack(server, {
|
|
429
|
+
jsonrpc: '2.0',
|
|
430
|
+
id: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
431
|
+
method: 'tools/call',
|
|
432
|
+
params: {
|
|
433
|
+
name: actualToolName,
|
|
434
|
+
arguments: arguments_
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (response.error) {
|
|
439
|
+
throw new Error(`Tool error: ${response.error.message}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return response.result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// =============================================================================
|
|
446
|
+
// MAIN MCP PROTOCOL LOOP
|
|
447
|
+
// =============================================================================
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Handle incoming JSON-RPC request
|
|
451
|
+
* @param {JsonRpcRequest} request
|
|
452
|
+
* @returns {Promise<JsonRpcResponse>}
|
|
453
|
+
*/
|
|
454
|
+
async function handleRequest(request) {
|
|
455
|
+
/** @type {JsonRpcResponse} */
|
|
456
|
+
const response = {
|
|
457
|
+
jsonrpc: '2.0',
|
|
458
|
+
id: request.id ?? null
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
switch (request.method) {
|
|
463
|
+
case 'initialize':
|
|
464
|
+
response.result = {
|
|
465
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
466
|
+
capabilities: {
|
|
467
|
+
tools: {}
|
|
468
|
+
},
|
|
469
|
+
serverInfo: {
|
|
470
|
+
name: 'rudi-router',
|
|
471
|
+
version: '1.0.0'
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
break;
|
|
475
|
+
|
|
476
|
+
case 'notifications/initialized':
|
|
477
|
+
// Client acknowledges initialization - no response needed for notifications
|
|
478
|
+
return null;
|
|
479
|
+
|
|
480
|
+
case 'tools/list': {
|
|
481
|
+
const tools = await listTools();
|
|
482
|
+
response.result = { tools };
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
case 'tools/call': {
|
|
487
|
+
const params = request.params;
|
|
488
|
+
const result = await callTool(params.name, params.arguments || {});
|
|
489
|
+
response.result = result;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
case 'ping':
|
|
494
|
+
response.result = {};
|
|
495
|
+
break;
|
|
496
|
+
|
|
497
|
+
default:
|
|
498
|
+
// Unknown method
|
|
499
|
+
if (request.id !== null && request.id !== undefined) {
|
|
500
|
+
response.error = {
|
|
501
|
+
code: -32601,
|
|
502
|
+
message: `Method not found: ${request.method}`
|
|
503
|
+
};
|
|
504
|
+
} else {
|
|
505
|
+
// It's a notification, don't respond
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} catch (err) {
|
|
510
|
+
response.error = {
|
|
511
|
+
code: -32603,
|
|
512
|
+
message: err.message || 'Internal error'
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return response;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Main entry point
|
|
521
|
+
*/
|
|
522
|
+
async function main() {
|
|
523
|
+
log('Starting RUDI Router MCP Server');
|
|
524
|
+
|
|
525
|
+
// Load config
|
|
526
|
+
rudiConfig = loadRudiConfig();
|
|
527
|
+
const stackCount = Object.keys(rudiConfig.stacks || {}).length;
|
|
528
|
+
log(`Loaded ${stackCount} stacks from rudi.json`);
|
|
529
|
+
|
|
530
|
+
// Load tool index cache
|
|
531
|
+
toolIndex = loadToolIndex();
|
|
532
|
+
if (toolIndex) {
|
|
533
|
+
const cachedStacks = Object.keys(toolIndex.byStack || {}).length;
|
|
534
|
+
log(`Loaded tool index (${cachedStacks} stacks cached)`);
|
|
535
|
+
} else {
|
|
536
|
+
log('No tool index cache found (run: rudi index)');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Set up stdin/stdout for MCP protocol
|
|
540
|
+
const rl = readline.createInterface({
|
|
541
|
+
input: process.stdin,
|
|
542
|
+
terminal: false
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
rl.on('line', async (line) => {
|
|
546
|
+
try {
|
|
547
|
+
const request = JSON.parse(line);
|
|
548
|
+
debug(`Received: ${line.slice(0, 200)}`);
|
|
549
|
+
|
|
550
|
+
const response = await handleRequest(request);
|
|
551
|
+
|
|
552
|
+
// Don't respond to notifications
|
|
553
|
+
if (response !== null) {
|
|
554
|
+
const responseStr = JSON.stringify(response);
|
|
555
|
+
debug(`Sending: ${responseStr.slice(0, 200)}`);
|
|
556
|
+
process.stdout.write(responseStr + '\n');
|
|
557
|
+
}
|
|
558
|
+
} catch (err) {
|
|
559
|
+
// Parse error
|
|
560
|
+
const errorResponse = {
|
|
561
|
+
jsonrpc: '2.0',
|
|
562
|
+
id: null,
|
|
563
|
+
error: {
|
|
564
|
+
code: -32700,
|
|
565
|
+
message: `Parse error: ${err.message}`
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
rl.on('close', () => {
|
|
573
|
+
log('stdin closed, shutting down');
|
|
574
|
+
// Clean up all spawned servers
|
|
575
|
+
for (const [stackId, server] of serverPool) {
|
|
576
|
+
debug(`Killing stack ${stackId}`);
|
|
577
|
+
server.process.kill();
|
|
578
|
+
}
|
|
579
|
+
process.exit(0);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Handle process termination
|
|
583
|
+
process.on('SIGTERM', () => {
|
|
584
|
+
log('SIGTERM received, shutting down');
|
|
585
|
+
for (const [stackId, server] of serverPool) {
|
|
586
|
+
server.process.kill();
|
|
587
|
+
}
|
|
588
|
+
process.exit(0);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
process.on('SIGINT', () => {
|
|
592
|
+
log('SIGINT received, shutting down');
|
|
593
|
+
for (const [stackId, server] of serverPool) {
|
|
594
|
+
server.process.kill();
|
|
595
|
+
}
|
|
596
|
+
process.exit(0);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Run
|
|
601
|
+
main().catch(err => {
|
|
602
|
+
log(`Fatal error: ${err.message}`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learnrudi/cli",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "RUDI CLI - Install and manage MCP stacks, runtimes, and AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"start": "node src/index.js",
|
|
17
|
-
"build": "esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:better-sqlite3 --external:@learnrudi/core --external:@learnrudi/db --external:@learnrudi/env --external:@learnrudi/manifest --external:@learnrudi/mcp --external:@learnrudi/registry-client --external:@learnrudi/runner --external:@learnrudi/secrets --external:@learnrudi/utils",
|
|
17
|
+
"build": "esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:better-sqlite3 --external:@learnrudi/core --external:@learnrudi/db --external:@learnrudi/env --external:@learnrudi/manifest --external:@learnrudi/mcp --external:@learnrudi/registry-client --external:@learnrudi/runner --external:@learnrudi/secrets --external:@learnrudi/utils && cp src/router-mcp.js dist/router-mcp.js",
|
|
18
18
|
"postinstall": "node scripts/postinstall.js",
|
|
19
19
|
"prepublishOnly": "npm run build",
|
|
20
20
|
"test": "node --test src/__tests__/"
|