@bahulam/code 0.1.22 → 0.1.24
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/package.json +1 -1
- package/src/commands/mcp.mjs +296 -0
- package/src/core/headless.mjs +9 -0
- package/src/core/tool-executor.mjs +8 -3
- package/src/local-service/agent-relay.mjs +7 -0
- package/src/mcp/client.mjs +54 -5
- package/src/mcp/loader.mjs +98 -0
- package/src/mcp/transport-shttp.mjs +2 -1
- package/src/permissions/command-classifier.mjs +35 -0
- package/src/terminal/main.mjs +9 -0
- package/src/terminal/repl-model-form.mjs +9 -11
- package/src/terminal/repl.mjs +40 -0
- package/src/tools/bash.mjs +15 -2
- package/src/ui/commands.mjs +35 -10
package/package.json
CHANGED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bahulam mcp` — manage MCP server registrations.
|
|
3
|
+
*
|
|
4
|
+
* bahulam mcp add <name> --command <cmd> [--args ...] [--env KEY=VAL ...]
|
|
5
|
+
* bahulam mcp add <name> --url <url> [--headers ...]
|
|
6
|
+
* bahulam mcp remove <name>
|
|
7
|
+
* bahulam mcp list
|
|
8
|
+
* bahulam mcp test <name>
|
|
9
|
+
*
|
|
10
|
+
* MCP servers are stored in ~/.claude/settings.json under mcpServers,
|
|
11
|
+
* matching the Claude Desktop / Cursor / Cline portable format.
|
|
12
|
+
* The settings loader chain reads this file at startup, and the MCP
|
|
13
|
+
* loader (src/mcp/loader.mjs) spawns and registers tools from it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
|
|
20
|
+
const RESET = '\x1b[0m';
|
|
21
|
+
const BOLD = '\x1b[1m';
|
|
22
|
+
const DIM = '\x1b[2m';
|
|
23
|
+
const CYAN = '\x1b[36m';
|
|
24
|
+
const GREEN = '\x1b[32m';
|
|
25
|
+
const YELLOW = '\x1b[33m';
|
|
26
|
+
const RED = '\x1b[31m';
|
|
27
|
+
|
|
28
|
+
function settingsFilePath() {
|
|
29
|
+
return path.join(os.homedir(), '.claude', 'settings.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadSettingsFile() {
|
|
33
|
+
const file = settingsFilePath();
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveSettingsFile(data) {
|
|
42
|
+
const file = settingsFilePath();
|
|
43
|
+
const dir = path.dirname(file);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const parsed = {
|
|
50
|
+
subcommand: null,
|
|
51
|
+
name: null,
|
|
52
|
+
command: null,
|
|
53
|
+
args: [],
|
|
54
|
+
url: null,
|
|
55
|
+
env: {},
|
|
56
|
+
headers: {},
|
|
57
|
+
transport: null,
|
|
58
|
+
help: false,
|
|
59
|
+
json: false,
|
|
60
|
+
};
|
|
61
|
+
const positional = [];
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const arg = argv[i];
|
|
64
|
+
switch (arg) {
|
|
65
|
+
case '--help': case '-h': parsed.help = true; break;
|
|
66
|
+
case '--json': parsed.json = true; break;
|
|
67
|
+
case '--command': parsed.command = argv[++i]; break;
|
|
68
|
+
case '--url': parsed.url = argv[++i]; break;
|
|
69
|
+
case '--transport': parsed.transport = argv[++i]; break;
|
|
70
|
+
case '--env': {
|
|
71
|
+
const pair = argv[++i] || '';
|
|
72
|
+
const eq = pair.indexOf('=');
|
|
73
|
+
if (eq > 0) parsed.env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case '--header': case '--headers': {
|
|
77
|
+
const pair = argv[++i] || '';
|
|
78
|
+
const eq = pair.indexOf(':');
|
|
79
|
+
if (eq > 0) parsed.headers[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case '--args': {
|
|
83
|
+
// Collect remaining positional args until next --flag
|
|
84
|
+
while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
85
|
+
parsed.args.push(argv[++i]);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
if (!arg.startsWith('-')) positional.push(arg);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
parsed.subcommand = positional.shift() || null;
|
|
95
|
+
parsed.name = positional.shift() || null;
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function handleMcpCommand(argv) {
|
|
100
|
+
const args = parseArgs(argv);
|
|
101
|
+
|
|
102
|
+
if (args.help || !args.subcommand) {
|
|
103
|
+
process.stderr.write(`
|
|
104
|
+
${BOLD}bahulam mcp <subcommand>${RESET}
|
|
105
|
+
|
|
106
|
+
Manage MCP (Model Context Protocol) server registrations.
|
|
107
|
+
|
|
108
|
+
${BOLD}Subcommands:${RESET}
|
|
109
|
+
|
|
110
|
+
${CYAN}add${RESET} <name> ${DIM}--command <cmd> [--args a b c] [--env KEY=VAL ...]${RESET}
|
|
111
|
+
Register a stdio MCP server (spawns a child process).
|
|
112
|
+
|
|
113
|
+
${CYAN}add${RESET} <name> ${DIM}--url <url> [--header "Key: Val" ...]${RESET}
|
|
114
|
+
Register a remote MCP server (SSE, WebSocket, or Streamable HTTP).
|
|
115
|
+
Transport is auto-detected: ws:// → WebSocket, /sse → SSE, else sHTTP.
|
|
116
|
+
|
|
117
|
+
${CYAN}remove${RESET} <name> Unregister an MCP server.
|
|
118
|
+
${CYAN}list${RESET} List all registered MCP servers.
|
|
119
|
+
${CYAN}test${RESET} <name> Connect, list tools, and call one.
|
|
120
|
+
|
|
121
|
+
${BOLD}Examples:${RESET}
|
|
122
|
+
|
|
123
|
+
${DIM}# Supabase MCP (stdio)${RESET}
|
|
124
|
+
${CYAN}bahulam mcp add supabase --command npx --args -y @supabase/mcp-server-supabase --env SUPABASE_ACCESS_TOKEN=sbp_xxx${RESET}
|
|
125
|
+
|
|
126
|
+
${DIM}# Remote SSE server${RESET}
|
|
127
|
+
${CYAN}bahulam mcp add myapi --url https://api.example.com/sse --header "Authorization: Bearer token123"${RESET}
|
|
128
|
+
|
|
129
|
+
${DIM}# List and test${RESET}
|
|
130
|
+
${CYAN}bahulam mcp list${RESET}
|
|
131
|
+
${CYAN}bahulam mcp test supabase${RESET}
|
|
132
|
+
|
|
133
|
+
${BOLD}Config location:${RESET} ${settingsFilePath()}
|
|
134
|
+
|
|
135
|
+
`);
|
|
136
|
+
if (!args.subcommand) process.exit(1);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (args.subcommand) {
|
|
141
|
+
case 'add': return handleAdd(args);
|
|
142
|
+
case 'remove': case 'rm': return handleRemove(args);
|
|
143
|
+
case 'list': case 'ls': return handleList(args);
|
|
144
|
+
case 'test': return handleTest(args);
|
|
145
|
+
default:
|
|
146
|
+
process.stderr.write(`${RED}✗${RESET} Unknown subcommand: ${args.subcommand}\n`);
|
|
147
|
+
process.stderr.write(`Run ${CYAN}bahulam mcp --help${RESET} for usage.\n`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function handleAdd(args) {
|
|
153
|
+
if (!args.name) {
|
|
154
|
+
process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp add <name> --command <cmd> | --url <url>\n`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
if (!args.command && !args.url) {
|
|
158
|
+
process.stderr.write(`${RED}✗${RESET} Either --command or --url is required.\n`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const settings = loadSettingsFile();
|
|
163
|
+
if (!settings.mcpServers) settings.mcpServers = {};
|
|
164
|
+
|
|
165
|
+
const config = {};
|
|
166
|
+
if (args.command) {
|
|
167
|
+
config.command = args.command;
|
|
168
|
+
if (args.args.length > 0) config.args = args.args;
|
|
169
|
+
}
|
|
170
|
+
if (args.url) config.url = args.url;
|
|
171
|
+
if (Object.keys(args.env).length > 0) config.env = args.env;
|
|
172
|
+
if (Object.keys(args.headers).length > 0) config.headers = args.headers;
|
|
173
|
+
if (args.transport) config.transport = args.transport;
|
|
174
|
+
|
|
175
|
+
settings.mcpServers[args.name] = config;
|
|
176
|
+
saveSettingsFile(settings);
|
|
177
|
+
|
|
178
|
+
if (args.json) {
|
|
179
|
+
process.stdout.write(JSON.stringify({ ok: true, name: args.name, config }) + '\n');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const type = config.command ? 'stdio' : 'remote';
|
|
184
|
+
const endpoint = config.command || config.url;
|
|
185
|
+
process.stderr.write(`${GREEN}✓${RESET} Registered MCP server ${BOLD}${args.name}${RESET} (${type}: ${endpoint})\n`);
|
|
186
|
+
process.stderr.write(` ${DIM}config${RESET} ${settingsFilePath()}\n`);
|
|
187
|
+
process.stderr.write(` ${DIM}test${RESET} ${CYAN}bahulam mcp test ${args.name}${RESET}\n\n`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handleRemove(args) {
|
|
191
|
+
if (!args.name) {
|
|
192
|
+
process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp remove <name>\n`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
const settings = loadSettingsFile();
|
|
196
|
+
if (!settings.mcpServers?.[args.name]) {
|
|
197
|
+
process.stderr.write(`${YELLOW}!${RESET} No MCP server named "${args.name}" is registered.\n`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
delete settings.mcpServers[args.name];
|
|
201
|
+
saveSettingsFile(settings);
|
|
202
|
+
|
|
203
|
+
if (args.json) {
|
|
204
|
+
process.stdout.write(JSON.stringify({ ok: true, removed: args.name }) + '\n');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
process.stderr.write(`${GREEN}✓${RESET} Removed MCP server ${BOLD}${args.name}${RESET}\n`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function handleList(args) {
|
|
211
|
+
const settings = loadSettingsFile();
|
|
212
|
+
const servers = settings.mcpServers || {};
|
|
213
|
+
const names = Object.keys(servers);
|
|
214
|
+
|
|
215
|
+
if (names.length === 0) {
|
|
216
|
+
process.stderr.write(`${DIM}No MCP servers registered.${RESET}\n`);
|
|
217
|
+
process.stderr.write(`Add one: ${CYAN}bahulam mcp add <name> --command <cmd>${RESET}\n\n`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (args.json) {
|
|
222
|
+
process.stdout.write(JSON.stringify({ servers }) + '\n');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
process.stderr.write(`${BOLD}MCP Servers${RESET} (${names.length}):\n`);
|
|
227
|
+
for (const name of names) {
|
|
228
|
+
const cfg = servers[name];
|
|
229
|
+
const type = cfg.command ? 'stdio' : 'remote';
|
|
230
|
+
const endpoint = cfg.command || cfg.url || 'unknown';
|
|
231
|
+
const toolCount = cfg._tools ? ` (${cfg._tools} tools)` : '';
|
|
232
|
+
process.stderr.write(` ${CYAN}${name.padEnd(20)}${RESET} ${type.padEnd(7)} ${endpoint}${toolCount}\n`);
|
|
233
|
+
}
|
|
234
|
+
process.stderr.write(`\n${DIM}Config: ${settingsFilePath()}${RESET}\n\n`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function handleTest(args) {
|
|
238
|
+
if (!args.name) {
|
|
239
|
+
process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp test <name>\n`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const settings = loadSettingsFile();
|
|
244
|
+
const config = settings.mcpServers?.[args.name];
|
|
245
|
+
if (!config) {
|
|
246
|
+
process.stderr.write(`${RED}✗${RESET} No MCP server named "${args.name}" is registered.\n`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
process.stderr.write(`${DIM}Connecting to ${args.name}…${RESET}\n`);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const { McpClient } = await import('../mcp/client.mjs');
|
|
254
|
+
const { loadMcpServers } = await import('../mcp/loader.mjs');
|
|
255
|
+
|
|
256
|
+
// Use the loader to handle env expansion
|
|
257
|
+
const fakeExecutor = {
|
|
258
|
+
registerMcpTool: () => true,
|
|
259
|
+
unregisterMcpServer: () => 0,
|
|
260
|
+
};
|
|
261
|
+
const mcp = await loadMcpServers(fakeExecutor, { mcpServers: { [args.name]: config } });
|
|
262
|
+
|
|
263
|
+
if (mcp.clients.length === 0) {
|
|
264
|
+
process.stderr.write(`${RED}✗${RESET} Failed to connect to "${args.name}".\n`);
|
|
265
|
+
process.stderr.write(`${DIM}Check that the command/URL is correct and any required env vars are set.${RESET}\n`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const client = mcp.clients[0].client;
|
|
270
|
+
const tools = client.tools;
|
|
271
|
+
|
|
272
|
+
process.stderr.write(`${GREEN}✓${RESET} Connected! Server: ${BOLD}${client.serverInfo?.serverInfo?.name || args.name}${RESET}\n`);
|
|
273
|
+
process.stderr.write(` ${DIM}tools${RESET} ${tools.length}\n`);
|
|
274
|
+
for (const t of tools) {
|
|
275
|
+
process.stderr.write(` ${CYAN}${t.name}${RESET} — ${(t.description || '').slice(0, 70)}\n`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Try calling the first tool that looks like a list/query
|
|
279
|
+
const listTool = tools.find(t =>
|
|
280
|
+
/list|query|search|get|fetch/i.test(t.name) && !/delete|update|create|insert|drop/i.test(t.name)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (listTool) {
|
|
284
|
+
process.stderr.write(`\n${DIM}Testing tool: ${listTool.name}…${RESET}\n`);
|
|
285
|
+
const result = await client.callTool(listTool.name, {});
|
|
286
|
+
const preview = String(result).slice(0, 500);
|
|
287
|
+
process.stderr.write(`${GREEN}✓${RESET} Result: ${preview}${preview.length >= 500 ? '…' : ''}\n`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await mcp.disconnectAll();
|
|
291
|
+
process.stderr.write(`\n${GREEN}✓${RESET} Test passed.\n\n`);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
process.stderr.write(`${RED}✗${RESET} Test failed: ${err.message}\n`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
package/src/core/headless.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { persistProjectArtifacts } from './project-artifacts.mjs';
|
|
|
18
18
|
import { BahulamAuth } from '../auth/bahulam-auth.mjs';
|
|
19
19
|
import { ApprovalManager } from './approval.mjs';
|
|
20
20
|
import { PluginRegistry } from '../plugins/registry.mjs';
|
|
21
|
+
import { loadSettings } from '../config/settings.mjs';
|
|
22
|
+
import { loadMcpServers } from '../mcp/loader.mjs';
|
|
21
23
|
// daemon wiring — headless (and `bahulam daemonize`) also starts the socket
|
|
22
24
|
// server + relay bridge when eventlog is enabled. Without this the daemon
|
|
23
25
|
// is invisible to attach clients and to paired mobile devices.
|
|
@@ -98,6 +100,9 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
98
100
|
return outcome;
|
|
99
101
|
};
|
|
100
102
|
toolExecutor = createToolExecutor({ pluginRegistry, delegateRunner: runDelegateFromTool });
|
|
103
|
+
// Load MCP servers from settings chain (~/.claude/settings.json etc.)
|
|
104
|
+
const _settings = await loadSettings();
|
|
105
|
+
const _mcpClients = await loadMcpServers(toolExecutor, _settings);
|
|
101
106
|
const timer = setTimeout(() => {
|
|
102
107
|
emit({ type: 'timeout', duration_s: timeout });
|
|
103
108
|
process.exit(2);
|
|
@@ -167,6 +172,10 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
167
172
|
};
|
|
168
173
|
toolExecutor = createToolExecutor({ pluginRegistry, delegateRunner: runDelegateFromTool });
|
|
169
174
|
|
|
175
|
+
// Load MCP servers from settings chain (~/.claude/settings.json etc.)
|
|
176
|
+
const _settings = await loadSettings();
|
|
177
|
+
const _mcpClients = await loadMcpServers(toolExecutor, _settings);
|
|
178
|
+
|
|
170
179
|
// Auto-approve everything — no prompts
|
|
171
180
|
const approval = new ApprovalManager({ autoApprove: true });
|
|
172
181
|
|
|
@@ -152,8 +152,13 @@ export function createToolExecutor({
|
|
|
152
152
|
return project.resource.root;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
async function commandCwd(args = {}) {
|
|
156
|
-
|
|
155
|
+
async function commandCwd(args = {}, { readOnly = false } = {}) {
|
|
156
|
+
try {
|
|
157
|
+
return await resolvePath(args.cwd || null, args);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (readOnly) return args.cwd ? path.resolve(args.cwd) : process.cwd();
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
function shellTargetPath(cwd, target) {
|
|
@@ -1390,7 +1395,7 @@ export function createToolExecutor({
|
|
|
1390
1395
|
args._riskReason = classification.reason || shellCheck.reason;
|
|
1391
1396
|
}
|
|
1392
1397
|
args._classification = classification.classification; // 'safe' or 'contained'
|
|
1393
|
-
const cwd = await commandCwd(args);
|
|
1398
|
+
const cwd = await commandCwd(args, { readOnly: classification.classification === 'safe' });
|
|
1394
1399
|
|
|
1395
1400
|
// Background execution: start via the BackgroundTasks registry
|
|
1396
1401
|
// and return immediately. Safety checks above still apply;
|
|
@@ -755,6 +755,13 @@ export class LocalAgentRelay {
|
|
|
755
755
|
_makeWorkspaceSessionSubstrate(pluginRegistry) {
|
|
756
756
|
return (agent, node, instruction, { scopedExecutor } = {}) => (async function* (relay) {
|
|
757
757
|
const execContext = await relay._buildExecContext(instruction);
|
|
758
|
+
// Per-agent model override from YAML — same pattern as agents.mjs:330.
|
|
759
|
+
// If the agent definition declares a `model` field, it wins over
|
|
760
|
+
// the session default for this sub-agent's turn.
|
|
761
|
+
if (agent.model) execContext.model_override = agent.model;
|
|
762
|
+
if (agent.models && typeof agent.models === 'object' && Object.keys(agent.models).length) {
|
|
763
|
+
execContext.model_overrides = { ...(execContext.model_overrides || {}), ...agent.models };
|
|
764
|
+
}
|
|
758
765
|
const slug = agent.slug || agent.command || agent.name || node?.agent_slug || node?.id || 'agent';
|
|
759
766
|
execContext.sub_agent = {
|
|
760
767
|
slug,
|
package/src/mcp/client.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { spawn } from 'child_process';
|
|
14
14
|
|
|
15
15
|
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
16
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
16
17
|
|
|
17
18
|
export class McpClient {
|
|
18
19
|
/**
|
|
@@ -102,7 +103,9 @@ export class McpClient {
|
|
|
102
103
|
}
|
|
103
104
|
});
|
|
104
105
|
this.connected = true;
|
|
105
|
-
|
|
106
|
+
await this._initRemote();
|
|
107
|
+
this._notifyRemote('notifications/initialized', {});
|
|
108
|
+
return this.serverInfo;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
async _connectWebSocket() {
|
|
@@ -117,6 +120,7 @@ export class McpClient {
|
|
|
117
120
|
capabilities: {},
|
|
118
121
|
clientInfo: { name: 'bahulam-code', version: '2.0.0' },
|
|
119
122
|
});
|
|
123
|
+
this._notifyRemote('notifications/initialized', {});
|
|
120
124
|
return this.serverInfo;
|
|
121
125
|
}
|
|
122
126
|
|
|
@@ -132,6 +136,7 @@ export class McpClient {
|
|
|
132
136
|
capabilities: {},
|
|
133
137
|
clientInfo: { name: 'bahulam-code', version: '2.0.0' },
|
|
134
138
|
});
|
|
139
|
+
this._notifyRemote('notifications/initialized', {});
|
|
135
140
|
return this.serverInfo;
|
|
136
141
|
}
|
|
137
142
|
|
|
@@ -145,11 +150,39 @@ export class McpClient {
|
|
|
145
150
|
return result;
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
_notifyRemote(method, params) {
|
|
154
|
+
if (this.transport?.request) {
|
|
155
|
+
// WebSocket / sHTTP transports have native request() — use send()
|
|
156
|
+
// for notifications (no id, no response expected).
|
|
157
|
+
if (this.transport.send) {
|
|
158
|
+
this.transport.send({ jsonrpc: '2.0', method, params }).catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
} else if (this.transport) {
|
|
161
|
+
// SSE transport — fire-and-forget via send()
|
|
162
|
+
this.transport.send({ jsonrpc: '2.0', method, params }).catch(() => {});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
148
166
|
async _transportRequest(method, params) {
|
|
149
167
|
return new Promise((resolve, reject) => {
|
|
150
168
|
const id = ++this.requestId;
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
const timeout = setTimeout(() => {
|
|
170
|
+
if (this.pending.has(id)) {
|
|
171
|
+
this.pending.delete(id);
|
|
172
|
+
reject(new Error(`MCP request timeout: ${method} (${DEFAULT_REQUEST_TIMEOUT_MS}ms)`));
|
|
173
|
+
}
|
|
174
|
+
}, DEFAULT_REQUEST_TIMEOUT_MS);
|
|
175
|
+
this.pending.set(id, {
|
|
176
|
+
resolve: (val) => { clearTimeout(timeout); resolve(val); },
|
|
177
|
+
reject: (err) => { clearTimeout(timeout); reject(err); },
|
|
178
|
+
});
|
|
179
|
+
this.transport.send({ jsonrpc: '2.0', id, method, params }).catch(err => {
|
|
180
|
+
if (this.pending.has(id)) {
|
|
181
|
+
this.pending.delete(id);
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
reject(err);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
153
186
|
});
|
|
154
187
|
}
|
|
155
188
|
|
|
@@ -177,7 +210,14 @@ export class McpClient {
|
|
|
177
210
|
result = await this._request('tools/call', params);
|
|
178
211
|
}
|
|
179
212
|
if (result?.content && Array.isArray(result.content)) {
|
|
180
|
-
|
|
213
|
+
const textParts = result.content.filter(c => c.type === 'text').map(c => c.text);
|
|
214
|
+
const imageParts = result.content.filter(c => c.type === 'image');
|
|
215
|
+
if (imageParts.length > 0 && textParts.length === 0) {
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
if (textParts.length > 0) {
|
|
219
|
+
return textParts.join('\n');
|
|
220
|
+
}
|
|
181
221
|
}
|
|
182
222
|
return result;
|
|
183
223
|
}
|
|
@@ -222,7 +262,16 @@ export class McpClient {
|
|
|
222
262
|
_request(method, params) {
|
|
223
263
|
return new Promise((resolve, reject) => {
|
|
224
264
|
const id = ++this.requestId;
|
|
225
|
-
|
|
265
|
+
const timeout = setTimeout(() => {
|
|
266
|
+
if (this.pending.has(id)) {
|
|
267
|
+
this.pending.delete(id);
|
|
268
|
+
reject(new Error(`MCP request timeout: ${method} (${DEFAULT_REQUEST_TIMEOUT_MS}ms)`));
|
|
269
|
+
}
|
|
270
|
+
}, DEFAULT_REQUEST_TIMEOUT_MS);
|
|
271
|
+
this.pending.set(id, {
|
|
272
|
+
resolve: (val) => { clearTimeout(timeout); resolve(val); },
|
|
273
|
+
reject: (err) => { clearTimeout(timeout); reject(err); },
|
|
274
|
+
});
|
|
226
275
|
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
227
276
|
this.process.stdin.write(msg + '\n');
|
|
228
277
|
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Settings Loader — bridges settings-based mcpServers to the tool executor.
|
|
3
|
+
*
|
|
4
|
+
* The agent-relay path already spawns MCP servers from plugin manifests.
|
|
5
|
+
* This module provides the same capability for CLI/REPL/headless mode by
|
|
6
|
+
* reading mcpServers from the settings chain (~/.claude/settings.json etc.)
|
|
7
|
+
* and optionally from ~/.bahulam/config.json.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { loadMcpServers } from '../mcp/loader.mjs';
|
|
11
|
+
* const mcpClients = await loadMcpServers(toolExecutor, settings);
|
|
12
|
+
* // ... later ...
|
|
13
|
+
* await mcpClients.disconnectAll();
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { McpClient } from './client.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recursively expand ${VAR} patterns in a config object using process.env.
|
|
20
|
+
* Matches the behavior in agent-relay.mjs _expandEnvInMcpConfig.
|
|
21
|
+
* @param {*} value
|
|
22
|
+
* @returns {*}
|
|
23
|
+
*/
|
|
24
|
+
function expandEnv(value) {
|
|
25
|
+
if (typeof value === 'string') {
|
|
26
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? '');
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(value)) return value.map(expandEnv);
|
|
29
|
+
if (value && typeof value === 'object') {
|
|
30
|
+
return Object.fromEntries(
|
|
31
|
+
Object.entries(value).map(([k, v]) => [k, expandEnv(v)]),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load and connect MCP servers from settings, registering their tools
|
|
39
|
+
* with the tool executor.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} toolExecutor - the createToolExecutor() instance
|
|
42
|
+
* @param {object} settings - loaded settings (from loadSettings())
|
|
43
|
+
* @param {object} [options]
|
|
44
|
+
* @param {string} [options.pluginName='settings'] - namespace for tool registration
|
|
45
|
+
* @returns {Promise<{clients: Array, disconnectAll: Function}>}
|
|
46
|
+
*/
|
|
47
|
+
export async function loadMcpServers(toolExecutor, settings, options = {}) {
|
|
48
|
+
const pluginName = options.pluginName || 'settings';
|
|
49
|
+
const servers = settings?.mcpServers || {};
|
|
50
|
+
const clients = [];
|
|
51
|
+
|
|
52
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
53
|
+
if (!config || typeof config !== 'object') continue;
|
|
54
|
+
if (!config.command && !config.url) continue;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Merge the config's env block into process.env BEFORE expanding
|
|
58
|
+
// ${VAR} patterns in args/env. This matches the Claude Desktop
|
|
59
|
+
// convention: the env block sets vars for the spawned process
|
|
60
|
+
// AND for ${VAR} expansion in the same config.
|
|
61
|
+
if (config.env && typeof config.env === 'object') {
|
|
62
|
+
for (const [k, v] of Object.entries(config.env)) {
|
|
63
|
+
if (typeof v === 'string' && !process.env[k]) {
|
|
64
|
+
process.env[k] = v;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const expanded = expandEnv(config);
|
|
69
|
+
const client = new McpClient(expanded);
|
|
70
|
+
await client.connect();
|
|
71
|
+
const tools = await client.listTools();
|
|
72
|
+
for (const tool of tools) {
|
|
73
|
+
if (toolExecutor.registerMcpTool) {
|
|
74
|
+
toolExecutor.registerMcpTool(pluginName, name, tool.name, client, tool.inputSchema || {});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
clients.push({ name, client });
|
|
78
|
+
if (process.env.MCP_DEBUG) {
|
|
79
|
+
process.stderr.write(`[mcp:settings] ${name}: ${tools.length} tools registered\n`);
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// One server failure must never block the session.
|
|
83
|
+
if (process.env.MCP_DEBUG) {
|
|
84
|
+
process.stderr.write(`[mcp:settings] ${name} failed: ${err.message}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
clients,
|
|
91
|
+
async disconnectAll() {
|
|
92
|
+
for (const { name, client } of clients) {
|
|
93
|
+
try { toolExecutor?.unregisterMcpServer?.(pluginName, name); } catch {}
|
|
94
|
+
try { await client.disconnect(); } catch {}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -16,6 +16,7 @@ export class StreamableHttpTransport {
|
|
|
16
16
|
this.timeout = options.timeout || 30000;
|
|
17
17
|
this.sessionId = options.sessionId || null;
|
|
18
18
|
this.connected = false;
|
|
19
|
+
this.requestId = 0;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
async connect() {
|
|
@@ -42,7 +43,7 @@ export class StreamableHttpTransport {
|
|
|
42
43
|
* Collects all events and returns the final result.
|
|
43
44
|
*/
|
|
44
45
|
async request(method, params) {
|
|
45
|
-
const id =
|
|
46
|
+
const id = ++this.requestId;
|
|
46
47
|
const body = { jsonrpc: '2.0', id, method, params };
|
|
47
48
|
|
|
48
49
|
const headers = {
|
|
@@ -238,6 +238,24 @@ const EXACT_SAFE = new Set([
|
|
|
238
238
|
'python --version', 'python3 --version',
|
|
239
239
|
]);
|
|
240
240
|
|
|
241
|
+
/** Tools commonly invoked with -version/--version to probe the installed version.
|
|
242
|
+
* These are pure reads (no file access, no network) and should never require
|
|
243
|
+
* project-scoping — they probe the local system's SDK/toolchain. */
|
|
244
|
+
const VERSION_PROBE_TOOLS = new Set([
|
|
245
|
+
'java', 'javac', 'python', 'python3', 'node', 'ruby', 'go', 'rustc',
|
|
246
|
+
'deno', 'bun', 'php', 'perl', 'gcc', 'clang', 'make', 'cmake', 'mvn',
|
|
247
|
+
'gradle', 'pip', 'npm', 'yarn', 'pnpm', 'cargo', 'swift', 'kotlin',
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
/** Absolute paths to system probes that are safe to invoke for read-only
|
|
251
|
+
* version/path queries. These live outside any project root and should not
|
|
252
|
+
* trigger project-scope errors. */
|
|
253
|
+
const SAFE_ABS_PROBE_PATHS = new Set([
|
|
254
|
+
'/usr/libexec/java_home',
|
|
255
|
+
'/usr/bin/xcode-select',
|
|
256
|
+
'/usr/bin/which',
|
|
257
|
+
]);
|
|
258
|
+
|
|
241
259
|
/**
|
|
242
260
|
* Commands with allowed flags — allowlist approach.
|
|
243
261
|
* Key: command name (or "git diff" for multi-word).
|
|
@@ -682,6 +700,23 @@ function classifySingleCommand(command) {
|
|
|
682
700
|
return { classification: 'safe', reason: 'Echo without expansion' };
|
|
683
701
|
}
|
|
684
702
|
|
|
703
|
+
// ── Version probe: <tool> -version / --version (pure OS read, no project needed) ──
|
|
704
|
+
if (VERSION_PROBE_TOOLS.has(baseCmd)) {
|
|
705
|
+
const token = tokenize(trimmed);
|
|
706
|
+
const flag = token[1] || '';
|
|
707
|
+
if (/^--?v(ersion)?$/.test(flag)) {
|
|
708
|
+
return { classification: 'safe', reason: `Version probe: ${baseCmd}` };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Known system-probe absolute paths (e.g. /usr/libexec/java_home -V) ──
|
|
713
|
+
if (SAFE_ABS_PROBE_PATHS.has(baseCmd)) {
|
|
714
|
+
const rest = trimmed.slice(baseCmd.length).trim();
|
|
715
|
+
if (!rest || /^--?[a-z]/i.test(rest)) {
|
|
716
|
+
return { classification: 'safe', reason: `System probe: ${baseCmd}` };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
685
720
|
// ── Default: contained (unknown command, not explicitly blocked) ──
|
|
686
721
|
return { classification: 'contained', reason: `Unknown command, defaulting to contained: ${baseCmd}` };
|
|
687
722
|
}
|
package/src/terminal/main.mjs
CHANGED
|
@@ -291,6 +291,12 @@ async function main() {
|
|
|
291
291
|
return;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
if (subcommand === 'mcp') {
|
|
295
|
+
const { handleMcpCommand } = await import('../commands/mcp.mjs');
|
|
296
|
+
await handleMcpCommand(subcommandArgs);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
294
300
|
if (subcommand === 'plugin' || subcommand === 'plugins') {
|
|
295
301
|
// `install`/`pull` moved to top-level. Detect the old form and redirect.
|
|
296
302
|
if (subcommandArgs[0] === 'install' || subcommandArgs[0] === 'pull') {
|
|
@@ -338,6 +344,9 @@ async function main() {
|
|
|
338
344
|
bahulam login Sign in via browser
|
|
339
345
|
bahulam logout Sign out and clear credentials
|
|
340
346
|
bahulam init Scaffold .bahulam config, memory, hooks, tasks
|
|
347
|
+
bahulam mcp add <name> ... Register an MCP server
|
|
348
|
+
bahulam mcp list List registered MCP servers
|
|
349
|
+
bahulam mcp test <name> Test an MCP server connection
|
|
341
350
|
bahulam version Show version
|
|
342
351
|
|
|
343
352
|
\x1b[1mDaemon:\x1b[0m
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interactive per-role model form for /model (PRD-076 W7).
|
|
3
3
|
*
|
|
4
|
-
* ↑↓ picks a role row, ←→ cycles through [backend default] + the
|
|
4
|
+
* ↑↓ picks a role row, ←→ cycles through [backend default] + the available
|
|
5
5
|
* platform catalog for that role, Enter applies to session overrides,
|
|
6
6
|
* c resets every row to default, Esc cancels. Same raw-stdin overlay
|
|
7
7
|
* pattern as the resume picker (repl-resume.mjs): pause readline, raw
|
|
@@ -35,19 +35,19 @@ function formatTokenLimit(value, label) {
|
|
|
35
35
|
return `${Math.round(n)} ${label}`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function optionRowsForRole(catalog, row) {
|
|
39
|
-
const
|
|
38
|
+
export function optionRowsForRole(catalog, row) {
|
|
39
|
+
const available = (catalog || []).filter(m => m?.id);
|
|
40
40
|
const group = String(row?.optionGroup || 'text').toLowerCase();
|
|
41
41
|
if (group === 'image_analysis') {
|
|
42
|
-
return
|
|
42
|
+
return available.filter(m => (
|
|
43
43
|
['image', 'multimodal'].includes(modelCategory(m))
|
|
44
44
|
&& !isImageGenerationModel(m)
|
|
45
45
|
));
|
|
46
46
|
}
|
|
47
47
|
if (group === 'image_generation') {
|
|
48
|
-
return
|
|
48
|
+
return available.filter(m => modelCategory(m) === 'image' && isImageGenerationModel(m));
|
|
49
49
|
}
|
|
50
|
-
return
|
|
50
|
+
return available.filter(m => ['text', 'chat', 'multimodal'].includes(modelCategory(m)));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
@@ -63,11 +63,11 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
|
|
|
63
63
|
if (!process.stdin.isTTY) return null;
|
|
64
64
|
if (rl) rl.pause();
|
|
65
65
|
|
|
66
|
-
const catalogRows = (catalog || []).filter(m => m?.
|
|
66
|
+
const catalogRows = (catalog || []).filter(m => m?.id);
|
|
67
67
|
const usingFallback = catalogRows.length === 0;
|
|
68
68
|
const byId = new Map(catalogRows.map(m => [m.id, m]));
|
|
69
69
|
|
|
70
|
-
// Per-row option list; a current override that isn't in the
|
|
70
|
+
// Per-row option list; a current override that isn't in the catalog list
|
|
71
71
|
// is appended so it stays visible and selectable.
|
|
72
72
|
const rows = roles.map(r => {
|
|
73
73
|
const optionIds = usingFallback
|
|
@@ -99,9 +99,7 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
|
|
|
99
99
|
}
|
|
100
100
|
const meta = byId.get(value);
|
|
101
101
|
const badge = meta ? creditBadge(meta) : '';
|
|
102
|
-
|
|
103
|
-
// in fallback mode every option is a known backend model, not a stray.
|
|
104
|
-
const flag = meta || usingFallback ? '' : c.yellow(' (uncurated)');
|
|
102
|
+
const flag = meta && meta.harness_validated === false ? c.yellow(' (uncurated)') : '';
|
|
105
103
|
return `${c.brand(value)}${badge ? ` ${c.dim(badge)}` : ''}${flag}`;
|
|
106
104
|
};
|
|
107
105
|
|
package/src/terminal/repl.mjs
CHANGED
|
@@ -4398,11 +4398,51 @@ async function handleCommand(input, ctx) {
|
|
|
4398
4398
|
process.stderr.write(`\n ${c.brand('Goodbye!')}\n\n`);
|
|
4399
4399
|
process.exit(0);
|
|
4400
4400
|
|
|
4401
|
+
case '/mcp': {
|
|
4402
|
+
await handleMcpSlashCommand(rest, ctx);
|
|
4403
|
+
return;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4401
4406
|
default:
|
|
4402
4407
|
process.stderr.write(` ${c.gray(`Unknown: ${cmd}. Type /help.`)}\n`);
|
|
4403
4408
|
}
|
|
4404
4409
|
}
|
|
4405
4410
|
|
|
4411
|
+
/**
|
|
4412
|
+
* /mcp slash command — dispatches to the CLI's handleMcpCommand.
|
|
4413
|
+
* Supports: /mcp (status), /mcp add, /mcp remove, /mcp list, /mcp test.
|
|
4414
|
+
*/
|
|
4415
|
+
async function handleMcpSlashCommand(rest, ctx) {
|
|
4416
|
+
const sub = String(rest || '').trim().split(/\s+/)[0]?.toLowerCase();
|
|
4417
|
+
if (!sub || !['add', 'remove', 'rm', 'list', 'ls', 'test'].includes(sub)) {
|
|
4418
|
+
// No subcommand → show connected server status from session state
|
|
4419
|
+
const mcpClients = ctx?.toolExecutor?._mcpClients || [];
|
|
4420
|
+
if (mcpClients.length === 0) {
|
|
4421
|
+
process.stderr.write(` ${c.dim('No MCP servers connected. Use:')} ${c.brand('/mcp add <name> --command <cmd> | --url <url>')}\n`);
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
process.stderr.write(`\n ${c.bold('MCP Servers')} (${mcpClients.length}):\n`);
|
|
4425
|
+
for (let i = 0; i < mcpClients.length; i++) {
|
|
4426
|
+
const cl = mcpClients[i];
|
|
4427
|
+
const name = cl.name || cl.config?.command || 'unknown';
|
|
4428
|
+
const endpoint = cl.config?.url || cl.config?.command || 'unknown';
|
|
4429
|
+
const status = cl.connected ? c.green('connected') : c.yellow('disconnected');
|
|
4430
|
+
process.stderr.write(` ${c.brand(String(i + 1).padStart(2))}. ${c.brand(name.padEnd(20))} ${status} ${c.dim(endpoint)}\n`);
|
|
4431
|
+
}
|
|
4432
|
+
process.stderr.write('\n');
|
|
4433
|
+
return;
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
// Dispatch to the CLI handler — same code path as `bahulam mcp`
|
|
4437
|
+
try {
|
|
4438
|
+
const { handleMcpCommand } = await import('../commands/mcp.mjs');
|
|
4439
|
+
const mcpArgs = String(rest || '').trim().split(/\s+/);
|
|
4440
|
+
await handleMcpCommand(mcpArgs);
|
|
4441
|
+
} catch (err) {
|
|
4442
|
+
process.stderr.write(` ${c.red('✗')} ${c.dim(err.message)}\n`);
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4406
4446
|
// ── Fetch User Profile ──
|
|
4407
4447
|
|
|
4408
4448
|
async function fetchUser(ctx) {
|
package/src/tools/bash.mjs
CHANGED
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { spawn } from 'child_process';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve shell binary and args for the current platform.
|
|
15
|
+
* Windows uses cmd.exe; Linux/macOS use bash.
|
|
16
|
+
*/
|
|
17
|
+
function shellSpawnArgs(command) {
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
return ['cmd.exe', ['/d', '/s', '/c', command]];
|
|
20
|
+
}
|
|
21
|
+
return ['bash', ['-c', command]];
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
// Strip ANSI escape sequences
|
|
14
25
|
function stripAnsi(str) {
|
|
15
26
|
// eslint-disable-next-line no-control-regex
|
|
@@ -73,7 +84,8 @@ export const BashTool = {
|
|
|
73
84
|
let killTimer = null;
|
|
74
85
|
let settled = false;
|
|
75
86
|
|
|
76
|
-
const
|
|
87
|
+
const [shellBin, shellArgs] = shellSpawnArgs(input.command);
|
|
88
|
+
const proc = spawn(shellBin, shellArgs, {
|
|
77
89
|
cwd: input.cwd,
|
|
78
90
|
env: { ...process.env },
|
|
79
91
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -221,7 +233,8 @@ let bgJobId = 0;
|
|
|
221
233
|
|
|
222
234
|
function runBackground(command, cwd) {
|
|
223
235
|
const id = ++bgJobId;
|
|
224
|
-
const
|
|
236
|
+
const [shellBin, shellArgs] = shellSpawnArgs(command);
|
|
237
|
+
const proc = spawn(shellBin, shellArgs, {
|
|
225
238
|
cwd,
|
|
226
239
|
detached: true,
|
|
227
240
|
stdio: ['ignore', 'pipe', 'pipe'],
|
package/src/ui/commands.mjs
CHANGED
|
@@ -345,15 +345,40 @@ export const COMMANDS = {
|
|
|
345
345
|
},
|
|
346
346
|
|
|
347
347
|
'/mcp': {
|
|
348
|
-
description: '
|
|
349
|
-
handler(args, state) {
|
|
350
|
-
|
|
351
|
-
|
|
348
|
+
description: 'Manage MCP servers (add, remove, list, test, status)',
|
|
349
|
+
async handler(args, state) {
|
|
350
|
+
const sub = (args || '').trim().split(/\s+/)[0]?.toLowerCase();
|
|
351
|
+
|
|
352
|
+
// No subcommand → show status (default)
|
|
353
|
+
if (!sub || !['add', 'remove', 'rm', 'list', 'ls', 'test'].includes(sub)) {
|
|
354
|
+
if (!state._mcpClients || state._mcpClients.length === 0) {
|
|
355
|
+
return 'No MCP servers connected. Use: /mcp add <name> --command <cmd> | --url <url>';
|
|
356
|
+
}
|
|
357
|
+
const lines = state._mcpClients.map((c, i) => {
|
|
358
|
+
const name = c.name || c.config?.command || 'unknown';
|
|
359
|
+
const endpoint = c.config?.url || c.config?.command || 'unknown';
|
|
360
|
+
return ` ${i + 1}. ${name} (${endpoint}) — ${c.connected ? 'connected' : 'disconnected'}`;
|
|
361
|
+
});
|
|
362
|
+
return `MCP servers:\n${lines.join('\n')}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Dispatch to the CLI handler — same code path as `bahulam mcp`
|
|
366
|
+
try {
|
|
367
|
+
const { handleMcpCommand } = await import('../commands/mcp.mjs');
|
|
368
|
+
const mcpArgs = (args || '').trim().split(/\s+/);
|
|
369
|
+
// Capture stdout/stderr from the handler
|
|
370
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
371
|
+
const origErrWrite = process.stderr.write.bind(process.stderr);
|
|
372
|
+
let captured = '';
|
|
373
|
+
process.stderr.write = (chunk) => { captured += chunk; return true; };
|
|
374
|
+
process.stdout.write = (chunk) => { captured += chunk; return true; };
|
|
375
|
+
await handleMcpCommand(mcpArgs);
|
|
376
|
+
process.stderr.write = origErrWrite;
|
|
377
|
+
process.stdout.write = origWrite;
|
|
378
|
+
return captured.trim() || 'Done.';
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return `MCP command failed: ${err.message}`;
|
|
352
381
|
}
|
|
353
|
-
const lines = state._mcpClients.map((c, i) =>
|
|
354
|
-
` ${i + 1}. ${c.config?.command || 'unknown'} — ${c.connected ? 'connected' : 'disconnected'}`
|
|
355
|
-
);
|
|
356
|
-
return `MCP servers:\n${lines.join('\n')}`;
|
|
357
382
|
},
|
|
358
383
|
},
|
|
359
384
|
|
|
@@ -516,7 +541,7 @@ export const COMMANDS = {
|
|
|
516
541
|
* @param {object} state - agent loop state
|
|
517
542
|
* @returns {{ response: string, exit: boolean }}
|
|
518
543
|
*/
|
|
519
|
-
export function executeCommand(input, state) {
|
|
544
|
+
export async function executeCommand(input, state) {
|
|
520
545
|
const parts = input.split(/\s+/);
|
|
521
546
|
const cmd = parts[0].toLowerCase();
|
|
522
547
|
const args = parts.slice(1).join(' ');
|
|
@@ -526,7 +551,7 @@ export function executeCommand(input, state) {
|
|
|
526
551
|
return { response: `Unknown command: ${cmd}. Type /help for available commands.`, exit: false };
|
|
527
552
|
}
|
|
528
553
|
|
|
529
|
-
const response = command.handler(args, state);
|
|
554
|
+
const response = await command.handler(args, state);
|
|
530
555
|
return { response, exit: response === 'EXIT' };
|
|
531
556
|
}
|
|
532
557
|
|