@learnrudi/cli 1.9.0 → 1.9.2
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/index.cjs +71 -84
- package/dist/router-mcp.js +604 -0
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -3228,34 +3228,15 @@ var import_os2 = __toESM(require("os"), 1);
|
|
|
3228
3228
|
var import_env6 = require("@learnrudi/env");
|
|
3229
3229
|
var import_mcp4 = require("@learnrudi/mcp");
|
|
3230
3230
|
var HOME = import_os2.default.homedir();
|
|
3231
|
-
var
|
|
3232
|
-
function
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
const shimsDir = path9.dirname(SHIM_PATH);
|
|
3239
|
-
if (!fs11.existsSync(shimsDir)) {
|
|
3240
|
-
fs11.mkdirSync(shimsDir, { recursive: true });
|
|
3241
|
-
}
|
|
3242
|
-
const shimContent = `#!/usr/bin/env bash
|
|
3243
|
-
set -euo pipefail
|
|
3244
|
-
# Try rudi in PATH first, fall back to npx
|
|
3245
|
-
if command -v rudi &> /dev/null; then
|
|
3246
|
-
exec rudi mcp "$1"
|
|
3247
|
-
else
|
|
3248
|
-
exec npx --yes @learnrudi/cli mcp "$1"
|
|
3249
|
-
fi
|
|
3250
|
-
`;
|
|
3251
|
-
if (fs11.existsSync(SHIM_PATH)) {
|
|
3252
|
-
const existing = fs11.readFileSync(SHIM_PATH, "utf-8");
|
|
3253
|
-
if (existing === shimContent) {
|
|
3254
|
-
return { created: false, path: SHIM_PATH };
|
|
3255
|
-
}
|
|
3231
|
+
var ROUTER_SHIM_PATH = path9.join(import_env6.PATHS.home, "shims", "rudi-router");
|
|
3232
|
+
function checkRouterShim() {
|
|
3233
|
+
if (!fs11.existsSync(ROUTER_SHIM_PATH)) {
|
|
3234
|
+
throw new Error(
|
|
3235
|
+
`Router shim not found at ${ROUTER_SHIM_PATH}
|
|
3236
|
+
Run: npm install -g @learnrudi/cli@latest`
|
|
3237
|
+
);
|
|
3256
3238
|
}
|
|
3257
|
-
|
|
3258
|
-
return { created: true, path: SHIM_PATH };
|
|
3239
|
+
return ROUTER_SHIM_PATH;
|
|
3259
3240
|
}
|
|
3260
3241
|
function backupConfig(configPath) {
|
|
3261
3242
|
if (!fs11.existsSync(configPath)) return null;
|
|
@@ -3280,24 +3261,23 @@ function writeJsonConfig(configPath, config) {
|
|
|
3280
3261
|
}
|
|
3281
3262
|
fs11.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
3282
3263
|
}
|
|
3283
|
-
function
|
|
3264
|
+
function buildRouterEntry(agentId) {
|
|
3284
3265
|
const base = {
|
|
3285
|
-
command:
|
|
3286
|
-
args: [
|
|
3266
|
+
command: ROUTER_SHIM_PATH,
|
|
3267
|
+
args: []
|
|
3287
3268
|
};
|
|
3288
3269
|
if (agentId === "claude-desktop" || agentId === "claude-code") {
|
|
3289
3270
|
return { type: "stdio", ...base };
|
|
3290
3271
|
}
|
|
3291
3272
|
return base;
|
|
3292
3273
|
}
|
|
3293
|
-
async function integrateAgent(agentId,
|
|
3274
|
+
async function integrateAgent(agentId, flags) {
|
|
3294
3275
|
const agentConfig = import_mcp4.AGENT_CONFIGS.find((a) => a.id === agentId);
|
|
3295
3276
|
if (!agentConfig) {
|
|
3296
3277
|
console.error(`Unknown agent: ${agentId}`);
|
|
3297
3278
|
return { success: false, error: "Unknown agent" };
|
|
3298
3279
|
}
|
|
3299
3280
|
const configPath = (0, import_mcp4.findAgentConfig)(agentConfig);
|
|
3300
|
-
const configDir = configPath ? path9.dirname(configPath) : null;
|
|
3301
3281
|
const targetPath = configPath || path9.join(HOME, agentConfig.paths[process.platform]?.[0] || agentConfig.paths.darwin[0]);
|
|
3302
3282
|
console.log(`
|
|
3303
3283
|
${agentConfig.name}:`);
|
|
@@ -3313,32 +3293,54 @@ ${agentConfig.name}:`);
|
|
|
3313
3293
|
if (!config[key]) {
|
|
3314
3294
|
config[key] = {};
|
|
3315
3295
|
}
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
for (const
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3296
|
+
const rudiMcpShimPath = path9.join(import_env6.PATHS.home, "shims", "rudi-mcp");
|
|
3297
|
+
const removedEntries = [];
|
|
3298
|
+
for (const [serverName, serverConfig] of Object.entries(config[key])) {
|
|
3299
|
+
if (serverConfig.command === rudiMcpShimPath) {
|
|
3300
|
+
delete config[key][serverName];
|
|
3301
|
+
removedEntries.push(serverName);
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
if (removedEntries.length > 0) {
|
|
3305
|
+
console.log(` Removed old entries: ${removedEntries.join(", ")}`);
|
|
3306
|
+
}
|
|
3307
|
+
const routerEntry = buildRouterEntry(agentId);
|
|
3308
|
+
const existing = config[key]["rudi"];
|
|
3309
|
+
let action = "none";
|
|
3310
|
+
if (!existing) {
|
|
3311
|
+
config[key]["rudi"] = routerEntry;
|
|
3312
|
+
action = "added";
|
|
3313
|
+
} else if (existing.command !== routerEntry.command || JSON.stringify(existing.args) !== JSON.stringify(routerEntry.args)) {
|
|
3314
|
+
config[key]["rudi"] = routerEntry;
|
|
3315
|
+
action = "updated";
|
|
3316
|
+
}
|
|
3317
|
+
if (action !== "none" || removedEntries.length > 0) {
|
|
3330
3318
|
writeJsonConfig(targetPath, config);
|
|
3331
|
-
|
|
3319
|
+
if (action !== "none") {
|
|
3320
|
+
console.log(` ${action === "added" ? "\u2713 Added" : "\u2713 Updated"} rudi router`);
|
|
3321
|
+
}
|
|
3332
3322
|
} else {
|
|
3333
|
-
console.log(` Already
|
|
3323
|
+
console.log(` \u2713 Already configured`);
|
|
3334
3324
|
}
|
|
3335
|
-
return { success: true,
|
|
3325
|
+
return { success: true, action, removed: removedEntries };
|
|
3336
3326
|
}
|
|
3337
3327
|
async function cmdIntegrate(args, flags) {
|
|
3338
3328
|
const target = args[0];
|
|
3329
|
+
if (flags.list || target === "list") {
|
|
3330
|
+
const installed = (0, import_mcp4.getInstalledAgents)();
|
|
3331
|
+
console.log("\nDetected agents:");
|
|
3332
|
+
for (const agent of installed) {
|
|
3333
|
+
console.log(` \u2713 ${agent.name}`);
|
|
3334
|
+
console.log(` ${agent.configFile}`);
|
|
3335
|
+
}
|
|
3336
|
+
if (installed.length === 0) {
|
|
3337
|
+
console.log(" (none detected)");
|
|
3338
|
+
}
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3339
3341
|
if (!target) {
|
|
3340
3342
|
console.log(`
|
|
3341
|
-
rudi integrate - Wire RUDI
|
|
3343
|
+
rudi integrate - Wire RUDI router into agent configs
|
|
3342
3344
|
|
|
3343
3345
|
USAGE
|
|
3344
3346
|
rudi integrate <agent> Integrate with specific agent
|
|
@@ -3364,29 +3366,14 @@ EXAMPLES
|
|
|
3364
3366
|
`);
|
|
3365
3367
|
return;
|
|
3366
3368
|
}
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
console.log(` \u2713 ${agent.name}`);
|
|
3372
|
-
console.log(` ${agent.configFile}`);
|
|
3373
|
-
}
|
|
3374
|
-
if (installed.length === 0) {
|
|
3375
|
-
console.log(" (none detected)");
|
|
3376
|
-
}
|
|
3377
|
-
return;
|
|
3378
|
-
}
|
|
3379
|
-
const stacks = getInstalledStacks();
|
|
3380
|
-
if (stacks.length === 0) {
|
|
3381
|
-
console.log("No stacks installed. Install with: rudi install <stack>");
|
|
3369
|
+
try {
|
|
3370
|
+
checkRouterShim();
|
|
3371
|
+
} catch (err) {
|
|
3372
|
+
console.error(err.message);
|
|
3382
3373
|
return;
|
|
3383
3374
|
}
|
|
3384
3375
|
console.log(`
|
|
3385
|
-
|
|
3386
|
-
const shimResult = ensureShim();
|
|
3387
|
-
if (shimResult.created) {
|
|
3388
|
-
console.log(`Created shim: ${shimResult.path}`);
|
|
3389
|
-
}
|
|
3376
|
+
Wiring up RUDI router...`);
|
|
3390
3377
|
let targetAgents = [];
|
|
3391
3378
|
if (target === "all") {
|
|
3392
3379
|
targetAgents = (0, import_mcp4.getInstalledAgents)().map((a) => a.id);
|
|
@@ -3416,25 +3403,25 @@ Integrating ${stacks.length} stack(s)...`);
|
|
|
3416
3403
|
targetAgents = [agentId];
|
|
3417
3404
|
}
|
|
3418
3405
|
if (flags["dry-run"]) {
|
|
3419
|
-
console.log("\nDry run - would
|
|
3406
|
+
console.log("\nDry run - would add RUDI router to:");
|
|
3420
3407
|
for (const agentId of targetAgents) {
|
|
3421
3408
|
const agent = import_mcp4.AGENT_CONFIGS.find((a) => a.id === agentId);
|
|
3422
|
-
console.log(` ${agent?.name || agentId}
|
|
3423
|
-
for (const stack of stacks) {
|
|
3424
|
-
console.log(` - ${stack}`);
|
|
3425
|
-
}
|
|
3409
|
+
console.log(` ${agent?.name || agentId}`);
|
|
3426
3410
|
}
|
|
3427
3411
|
return;
|
|
3428
3412
|
}
|
|
3429
3413
|
const results = [];
|
|
3430
3414
|
for (const agentId of targetAgents) {
|
|
3431
|
-
const result = await integrateAgent(agentId,
|
|
3415
|
+
const result = await integrateAgent(agentId, flags);
|
|
3432
3416
|
results.push({ agent: agentId, ...result });
|
|
3433
3417
|
}
|
|
3434
3418
|
const successful = results.filter((r) => r.success);
|
|
3435
3419
|
console.log(`
|
|
3436
3420
|
\u2713 Integrated with ${successful.length} agent(s)`);
|
|
3437
|
-
console.log("\nRestart your agent(s) to
|
|
3421
|
+
console.log("\nRestart your agent(s) to access all installed stacks.");
|
|
3422
|
+
console.log("\nManage stacks:");
|
|
3423
|
+
console.log(" rudi install <stack> # Install a new stack");
|
|
3424
|
+
console.log(" rudi index # Rebuild tool cache");
|
|
3438
3425
|
}
|
|
3439
3426
|
|
|
3440
3427
|
// src/commands/migrate.js
|
|
@@ -3445,7 +3432,7 @@ var import_env7 = require("@learnrudi/env");
|
|
|
3445
3432
|
var import_mcp5 = require("@learnrudi/mcp");
|
|
3446
3433
|
var HOME2 = import_os3.default.homedir();
|
|
3447
3434
|
var OLD_PROMPT_STACK = path10.join(HOME2, ".prompt-stack");
|
|
3448
|
-
var
|
|
3435
|
+
var SHIM_PATH = path10.join(import_env7.PATHS.home, "shims", "rudi-mcp");
|
|
3449
3436
|
function getOldStacks() {
|
|
3450
3437
|
const stacksDir = path10.join(OLD_PROMPT_STACK, "stacks");
|
|
3451
3438
|
if (!fs12.existsSync(stacksDir)) return [];
|
|
@@ -3481,8 +3468,8 @@ function copyRecursive(src, dest) {
|
|
|
3481
3468
|
fs12.copyFileSync(src, dest);
|
|
3482
3469
|
}
|
|
3483
3470
|
}
|
|
3484
|
-
function
|
|
3485
|
-
const shimsDir = path10.dirname(
|
|
3471
|
+
function ensureShim() {
|
|
3472
|
+
const shimsDir = path10.dirname(SHIM_PATH);
|
|
3486
3473
|
if (!fs12.existsSync(shimsDir)) {
|
|
3487
3474
|
fs12.mkdirSync(shimsDir, { recursive: true });
|
|
3488
3475
|
}
|
|
@@ -3494,11 +3481,11 @@ else
|
|
|
3494
3481
|
exec npx --yes @learnrudi/cli mcp "$1"
|
|
3495
3482
|
fi
|
|
3496
3483
|
`;
|
|
3497
|
-
fs12.writeFileSync(
|
|
3484
|
+
fs12.writeFileSync(SHIM_PATH, shimContent, { mode: 493 });
|
|
3498
3485
|
}
|
|
3499
3486
|
function buildNewEntry(stackName, agentId) {
|
|
3500
3487
|
const base = {
|
|
3501
|
-
command:
|
|
3488
|
+
command: SHIM_PATH,
|
|
3502
3489
|
args: [stackName]
|
|
3503
3490
|
};
|
|
3504
3491
|
if (agentId === "claude-desktop" || agentId === "claude-code") {
|
|
@@ -3676,8 +3663,8 @@ Stacks migrated to: ${import_env7.PATHS.stacks}`);
|
|
|
3676
3663
|
async function migrateConfigs(flags) {
|
|
3677
3664
|
console.log("=== Updating Agent Configs ===\n");
|
|
3678
3665
|
if (!flags.dryRun) {
|
|
3679
|
-
|
|
3680
|
-
console.log(`Shim ready: ${
|
|
3666
|
+
ensureShim();
|
|
3667
|
+
console.log(`Shim ready: ${SHIM_PATH}
|
|
3681
3668
|
`);
|
|
3682
3669
|
}
|
|
3683
3670
|
let installedStacks = [];
|
|
@@ -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.2",
|
|
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__/"
|