wispy-cli 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * wispy-tui — Ink-based TUI entry point
5
+ * Delegates to lib/wispy-tui.mjs
6
+ */
7
+
8
+ import { fileURLToPath } from "node:url";
9
+ import path from "node:path";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
13
+
14
+ await import(tuiScript);
package/bin/wispy.mjs CHANGED
@@ -1,11 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Wispy CLI entry point — delegates to the main REPL
3
+ /**
4
+ * Wispy CLI entry point
5
+ *
6
+ * Flags:
7
+ * --tui Launch Ink-based TUI mode
8
+ * (default) Launch interactive REPL
9
+ */
10
+
4
11
  import { fileURLToPath } from "node:url";
5
12
  import path from "node:path";
6
13
 
7
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
- const mainScript = path.join(__dirname, "..", "lib", "wispy-repl.mjs");
9
15
 
10
- // Dynamic import of the main module
11
- await import(mainScript);
16
+ // Check for --tui flag
17
+ const args = process.argv.slice(2);
18
+ const tuiMode = args.includes("--tui");
19
+
20
+ if (tuiMode) {
21
+ // Remove --tui from args so wispy-tui doesn't see it
22
+ const newArgs = args.filter(a => a !== "--tui");
23
+ process.argv = [process.argv[0], process.argv[1], ...newArgs];
24
+ const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
25
+ await import(tuiScript);
26
+ } else {
27
+ const mainScript = path.join(__dirname, "..", "lib", "wispy-repl.mjs");
28
+ await import(mainScript);
29
+ }
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-client.mjs — MCP (Model Context Protocol) client for Wispy
4
+ *
5
+ * Implements:
6
+ * - stdio transport (spawn server process, communicate over stdin/stdout)
7
+ * - JSON-RPC 2.0 framing (newline-delimited)
8
+ * - initialize / tools/list / tools/call
9
+ * - MCPManager: manages multiple named server connections
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { EventEmitter } from "node:events";
14
+ import readline from "node:readline";
15
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // MCPClient — single server connection
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export class MCPClient extends EventEmitter {
24
+ constructor(name, config) {
25
+ super();
26
+ this.name = name;
27
+ this.config = config; // { command, args?, env?, cwd? }
28
+ this.process = null;
29
+ this.rl = null;
30
+ this._requestId = 0;
31
+ this._pending = new Map(); // id → { resolve, reject, timer }
32
+ this.tools = [];
33
+ this.connected = false;
34
+ this.initialized = false;
35
+ this.serverInfo = null;
36
+ }
37
+
38
+ async connect() {
39
+ const { command, args = [], env = {}, cwd } = this.config;
40
+
41
+ this.process = spawn(command, args, {
42
+ stdio: ["pipe", "pipe", "pipe"],
43
+ env: { ...process.env, ...env },
44
+ cwd: cwd ?? undefined,
45
+ });
46
+
47
+ this.process.on("error", (err) => {
48
+ this.connected = false;
49
+ this.emit("error", err);
50
+ this._rejectAll(err);
51
+ });
52
+
53
+ this.process.on("exit", (code) => {
54
+ this.connected = false;
55
+ this.initialized = false;
56
+ this.emit("disconnect", code);
57
+ this._rejectAll(new Error(`MCP server "${this.name}" exited with code ${code}`));
58
+ });
59
+
60
+ // Read JSON-RPC messages from stdout (newline-delimited)
61
+ this.rl = readline.createInterface({
62
+ input: this.process.stdout,
63
+ crlfDelay: Infinity,
64
+ });
65
+ this.rl.on("line", (line) => this._handleLine(line.trim()));
66
+
67
+ // Collect stderr for debugging
68
+ this._stderrBuf = "";
69
+ this.process.stderr.on("data", (data) => {
70
+ this._stderrBuf += data.toString();
71
+ this.emit("stderr", data.toString());
72
+ });
73
+
74
+ this.connected = true;
75
+
76
+ // Run MCP handshake
77
+ await this._initialize();
78
+ return this;
79
+ }
80
+
81
+ async _initialize() {
82
+ const result = await this._request("initialize", {
83
+ protocolVersion: "2024-11-05",
84
+ capabilities: {
85
+ roots: { listChanged: false },
86
+ sampling: {},
87
+ },
88
+ clientInfo: {
89
+ name: "wispy",
90
+ version: "0.5.0",
91
+ },
92
+ });
93
+
94
+ this.serverInfo = result?.serverInfo ?? null;
95
+
96
+ // Send initialized notification (required by spec)
97
+ this._notify("notifications/initialized", {});
98
+ this.initialized = true;
99
+ this.emit("ready", result);
100
+ return result;
101
+ }
102
+
103
+ async listTools() {
104
+ const result = await this._request("tools/list", {});
105
+ this.tools = result?.tools ?? [];
106
+ return this.tools;
107
+ }
108
+
109
+ async callTool(name, args = {}) {
110
+ const result = await this._request("tools/call", {
111
+ name,
112
+ arguments: args,
113
+ });
114
+ return result;
115
+ }
116
+
117
+ disconnect() {
118
+ if (this.rl) {
119
+ this.rl.close();
120
+ this.rl = null;
121
+ }
122
+ if (this.process) {
123
+ try { this.process.stdin.end(); } catch {}
124
+ try { this.process.kill("SIGTERM"); } catch {}
125
+ this.process = null;
126
+ }
127
+ this.connected = false;
128
+ this.initialized = false;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Internal JSON-RPC helpers
133
+ // ---------------------------------------------------------------------------
134
+
135
+ _handleLine(line) {
136
+ if (!line) return;
137
+ try {
138
+ const msg = JSON.parse(line);
139
+ // Response to a pending request
140
+ if (msg.id !== undefined && this._pending.has(msg.id)) {
141
+ const { resolve, reject } = this._pending.get(msg.id);
142
+ this._pending.delete(msg.id);
143
+ if (msg.error) {
144
+ reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
145
+ } else {
146
+ resolve(msg.result);
147
+ }
148
+ } else if (msg.method) {
149
+ // Server-initiated notification or request
150
+ this.emit("notification", msg);
151
+ }
152
+ } catch {
153
+ // Non-JSON line (server logs etc.) — ignore
154
+ }
155
+ }
156
+
157
+ _request(method, params, timeoutMs = 30_000) {
158
+ return new Promise((resolve, reject) => {
159
+ if (!this.connected || !this.process) {
160
+ return reject(new Error(`MCP server "${this.name}" is not connected`));
161
+ }
162
+
163
+ const id = ++this._requestId;
164
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
165
+
166
+ const timer = setTimeout(() => {
167
+ this._pending.delete(id);
168
+ reject(new Error(`MCP request "${method}" timed out after ${timeoutMs}ms`));
169
+ }, timeoutMs);
170
+
171
+ this._pending.set(id, {
172
+ resolve: (val) => { clearTimeout(timer); resolve(val); },
173
+ reject: (err) => { clearTimeout(timer); reject(err); },
174
+ });
175
+
176
+ try {
177
+ this.process.stdin.write(msg + "\n");
178
+ } catch (err) {
179
+ this._pending.delete(id);
180
+ clearTimeout(timer);
181
+ reject(err);
182
+ }
183
+ });
184
+ }
185
+
186
+ _notify(method, params) {
187
+ if (!this.connected || !this.process) return;
188
+ const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
189
+ try {
190
+ this.process.stdin.write(msg + "\n");
191
+ } catch { /* ignore */ }
192
+ }
193
+
194
+ _rejectAll(err) {
195
+ for (const { reject } of this._pending.values()) {
196
+ reject(err);
197
+ }
198
+ this._pending.clear();
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // MCPManager — manages multiple named server connections
204
+ // ---------------------------------------------------------------------------
205
+
206
+ export class MCPManager {
207
+ constructor(configPath) {
208
+ this.configPath = configPath;
209
+ this.clients = new Map(); // name → MCPClient
210
+ this._toolIndex = new Map(); // wispyName → { serverName, mcpName, inputSchema, description }
211
+ }
212
+
213
+ // Load ~/.wispy/mcp.json (creates default if missing)
214
+ async loadConfig() {
215
+ try {
216
+ const raw = await readFile(this.configPath, "utf8");
217
+ return JSON.parse(raw);
218
+ } catch {
219
+ return { mcpServers: {} };
220
+ }
221
+ }
222
+
223
+ // Save updated config
224
+ async saveConfig(config) {
225
+ await mkdir(path.dirname(this.configPath), { recursive: true });
226
+ await writeFile(this.configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
227
+ }
228
+
229
+ // Auto-connect all non-disabled servers from config
230
+ async autoConnect() {
231
+ const config = await this.loadConfig();
232
+ const servers = config.mcpServers ?? {};
233
+ const results = [];
234
+
235
+ for (const [name, serverConfig] of Object.entries(servers)) {
236
+ if (serverConfig.disabled) {
237
+ results.push({ name, status: "disabled" });
238
+ continue;
239
+ }
240
+ try {
241
+ const client = await this.connect(name, serverConfig);
242
+ results.push({ name, status: "connected", tools: client.tools.length });
243
+ } catch (err) {
244
+ results.push({ name, status: "failed", error: err.message.slice(0, 200) });
245
+ }
246
+ }
247
+
248
+ return results;
249
+ }
250
+
251
+ // Connect a single server by name + config
252
+ async connect(name, serverConfig) {
253
+ // Disconnect existing connection if any
254
+ if (this.clients.has(name)) {
255
+ this.clients.get(name).disconnect();
256
+ this.clients.delete(name);
257
+ }
258
+
259
+ const client = new MCPClient(name, serverConfig);
260
+ await client.connect();
261
+ await client.listTools();
262
+
263
+ this.clients.set(name, client);
264
+ this._rebuildToolIndex();
265
+
266
+ return client;
267
+ }
268
+
269
+ // Disconnect a single server by name
270
+ disconnect(name) {
271
+ const client = this.clients.get(name);
272
+ if (client) {
273
+ client.disconnect();
274
+ this.clients.delete(name);
275
+ this._rebuildToolIndex();
276
+ return true;
277
+ }
278
+ return false;
279
+ }
280
+
281
+ // Disconnect all servers
282
+ disconnectAll() {
283
+ for (const client of this.clients.values()) {
284
+ try { client.disconnect(); } catch {}
285
+ }
286
+ this.clients.clear();
287
+ this._toolIndex.clear();
288
+ }
289
+
290
+ // Rebuild the flat tool index after connections change
291
+ _rebuildToolIndex() {
292
+ this._toolIndex.clear();
293
+ for (const [serverName, client] of this.clients.entries()) {
294
+ for (const tool of client.tools) {
295
+ // Prefix to avoid name collisions: mcp_<server>_<tool>
296
+ const wispyName = `mcp_${serverName}_${tool.name}`.replace(/[^a-zA-Z0-9_]/g, "_");
297
+ this._toolIndex.set(wispyName, {
298
+ wispyName,
299
+ serverName,
300
+ mcpName: tool.name,
301
+ description: `[MCP:${serverName}] ${tool.description ?? tool.name}`,
302
+ inputSchema: tool.inputSchema ?? { type: "object", properties: {}, required: [] },
303
+ });
304
+ }
305
+ }
306
+ }
307
+
308
+ // Execute an MCP tool call by its wispy name
309
+ async callTool(wispyName, args) {
310
+ const toolInfo = this._toolIndex.get(wispyName);
311
+ if (!toolInfo) throw new Error(`MCP tool not found: ${wispyName}`);
312
+
313
+ const client = this.clients.get(toolInfo.serverName);
314
+ if (!client?.connected) throw new Error(`MCP server not connected: ${toolInfo.serverName}`);
315
+
316
+ return client.callTool(toolInfo.mcpName, args);
317
+ }
318
+
319
+ // Returns tool definitions in Wispy TOOL_DEFINITIONS format
320
+ getToolDefinitions() {
321
+ return Array.from(this._toolIndex.values()).map(t => ({
322
+ name: t.wispyName,
323
+ description: t.description,
324
+ parameters: t.inputSchema,
325
+ }));
326
+ }
327
+
328
+ // Check if a tool name is an MCP tool
329
+ hasTool(wispyName) {
330
+ return this._toolIndex.has(wispyName);
331
+ }
332
+
333
+ // Get all tools as a flat array
334
+ getAllTools() {
335
+ return Array.from(this._toolIndex.values());
336
+ }
337
+
338
+ // Get connection status for all known servers
339
+ getStatus() {
340
+ return Array.from(this.clients.entries()).map(([name, client]) => ({
341
+ name,
342
+ connected: client.connected,
343
+ initialized: client.initialized,
344
+ toolCount: client.tools.length,
345
+ tools: client.tools.map(t => t.name),
346
+ serverInfo: client.serverInfo,
347
+ }));
348
+ }
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Default MCP config writer (call once at first run)
353
+ // ---------------------------------------------------------------------------
354
+
355
+ export async function ensureDefaultMcpConfig(configPath) {
356
+ try {
357
+ await readFile(configPath, "utf8");
358
+ // Already exists — don't overwrite
359
+ } catch {
360
+ const defaultConfig = {
361
+ mcpServers: {
362
+ // Example: filesystem server (disabled by default)
363
+ filesystem: {
364
+ command: "npx",
365
+ args: ["-y", "@modelcontextprotocol/server-filesystem", os.homedir()],
366
+ disabled: true,
367
+ description: "MCP filesystem server — access files via MCP protocol",
368
+ },
369
+ // Example: fetch server (disabled by default)
370
+ fetch: {
371
+ command: "npx",
372
+ args: ["-y", "@modelcontextprotocol/server-fetch"],
373
+ disabled: true,
374
+ description: "MCP fetch server — HTTP fetch via MCP protocol",
375
+ },
376
+ },
377
+ };
378
+ await mkdir(path.dirname(configPath), { recursive: true });
379
+ await writeFile(configPath, JSON.stringify(defaultConfig, null, 2) + "\n", "utf8");
380
+ }
381
+ }
@@ -15,6 +15,7 @@ import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { createInterface } from "node:readline";
17
17
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { MCPManager, ensureDefaultMcpConfig } from "./mcp-client.mjs";
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // Config
@@ -22,6 +23,10 @@ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
22
23
 
23
24
  const WISPY_DIR = path.join(os.homedir(), ".wispy");
24
25
  const MEMORY_DIR = path.join(WISPY_DIR, "memory");
26
+ const MCP_CONFIG_PATH = path.join(WISPY_DIR, "mcp.json");
27
+
28
+ // Global MCP manager — initialized at startup
29
+ const mcpManager = new MCPManager(MCP_CONFIG_PATH);
25
30
 
26
31
  // Workstream-aware conversation storage
27
32
  // wispy -w "project-name" → separate conversation per workstream
@@ -619,6 +624,11 @@ function optimizeContext(messages, maxTokens = 30_000) {
619
624
  // Tool definitions (Gemini function calling format)
620
625
  // ---------------------------------------------------------------------------
621
626
 
627
+ // Returns merged static + dynamically registered MCP tool definitions
628
+ function getAllToolDefinitions() {
629
+ return [...TOOL_DEFINITIONS, ...mcpManager.getToolDefinitions()];
630
+ }
631
+
622
632
  const TOOL_DEFINITIONS = [
623
633
  {
624
634
  name: "read_file",
@@ -1465,6 +1475,22 @@ Be concise. Your output feeds into the next stage.`;
1465
1475
  }
1466
1476
 
1467
1477
  default: {
1478
+ // Check MCP tools first
1479
+ if (mcpManager.hasTool(name)) {
1480
+ try {
1481
+ const result = await mcpManager.callTool(name, args);
1482
+ // MCP tools/call returns { content: [{type, text}], isError? }
1483
+ if (result?.isError) {
1484
+ const errText = result.content?.map(c => c.text ?? "").join("") ?? "MCP tool error";
1485
+ return { success: false, error: errText };
1486
+ }
1487
+ const output = result?.content?.map(c => c.text ?? c.data ?? JSON.stringify(c)).join("\n") ?? JSON.stringify(result);
1488
+ return { success: true, output };
1489
+ } catch (err) {
1490
+ return { success: false, error: `MCP tool error: ${err.message}` };
1491
+ }
1492
+ }
1493
+
1468
1494
  // Unknown tool — try to execute as a skill via run_command
1469
1495
  // This handles cases where the AI hallucinates tools from skill descriptions
1470
1496
  const skills = await loadSkills();
@@ -1476,7 +1502,7 @@ Be concise. Your output feeds into the next stage.`;
1476
1502
  skill_hint: matchedSkill.body.slice(0, 500),
1477
1503
  };
1478
1504
  }
1479
- return { success: false, error: `Unknown tool: ${name}. Available: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result` };
1505
+ return { success: false, error: `Unknown tool: ${name}. Available: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result, and MCP tools (see /mcp list)` };
1480
1506
  }
1481
1507
  }
1482
1508
  } catch (err) {
@@ -1599,7 +1625,7 @@ async function buildSystemPrompt(messages = []) {
1599
1625
  " - NEVER reply in Korean when the user wrote in English.",
1600
1626
  "",
1601
1627
  "## Tools",
1602
- "You have 18 tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result.",
1628
+ `You have ${18 + mcpManager.getAllTools().length} tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result${mcpManager.getAllTools().length > 0 ? ", and MCP tools: " + mcpManager.getAllTools().map(t => t.wispyName).join(", ") : ""}.`,
1603
1629
  "- file_edit: for targeted text replacement (prefer over write_file for edits)",
1604
1630
  "- file_search: grep across codebase",
1605
1631
  "- git: any git command",
@@ -1711,7 +1737,7 @@ async function chatGeminiWithTools(messages, onChunk) {
1711
1737
  sessionTokens.input += estimateTokens(systemInstruction + inputText);
1712
1738
 
1713
1739
  const geminiTools = [{
1714
- functionDeclarations: TOOL_DEFINITIONS.map(t => ({
1740
+ functionDeclarations: getAllToolDefinitions().map(t => ({
1715
1741
  name: t.name,
1716
1742
  description: t.description,
1717
1743
  parameters: t.parameters,
@@ -1801,7 +1827,7 @@ async function chatOpenAIWithTools(messages, onChunk) {
1801
1827
  return { role: m.role === "assistant" ? "assistant" : m.role, content: m.content };
1802
1828
  });
1803
1829
 
1804
- const openaiTools = TOOL_DEFINITIONS.map(t => ({
1830
+ const openaiTools = getAllToolDefinitions().map(t => ({
1805
1831
  type: "function",
1806
1832
  function: { name: t.name, description: t.description, parameters: t.parameters },
1807
1833
  }));
@@ -1879,7 +1905,7 @@ async function chatAnthropicWithTools(messages, onChunk) {
1879
1905
  const inputText = anthropicMessages.map(m => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join("");
1880
1906
  sessionTokens.input += estimateTokens(systemPrompt + inputText);
1881
1907
 
1882
- const anthropicTools = TOOL_DEFINITIONS.map(t => ({
1908
+ const anthropicTools = getAllToolDefinitions().map(t => ({
1883
1909
  name: t.name,
1884
1910
  description: t.description,
1885
1911
  input_schema: t.parameters,
@@ -2041,6 +2067,7 @@ ${bold("Wispy Commands:")}
2041
2067
  ${cyan("/clear")} Reset conversation
2042
2068
  ${cyan("/history")} Show conversation length
2043
2069
  ${cyan("/model")} [name] Show or change model
2070
+ ${cyan("/mcp")} [list|connect|disconnect|config|reload] MCP server management
2044
2071
  ${cyan("/quit")} or ${cyan("/exit")} Exit
2045
2072
  `);
2046
2073
  return true;
@@ -2321,6 +2348,119 @@ ${bold("Wispy Commands:")}
2321
2348
  process.exit(0);
2322
2349
  }
2323
2350
 
2351
+ // ---------------------------------------------------------------------------
2352
+ // /mcp — MCP server management
2353
+ // ---------------------------------------------------------------------------
2354
+ if (cmd === "/mcp") {
2355
+ const sub = parts[1] ?? "list";
2356
+
2357
+ if (sub === "list") {
2358
+ const status = mcpManager.getStatus();
2359
+ const allTools = mcpManager.getAllTools();
2360
+ if (status.length === 0) {
2361
+ console.log(dim("No MCP servers connected."));
2362
+ console.log(dim(`Config: ${MCP_CONFIG_PATH}`));
2363
+ console.log(dim("Use /mcp connect <name> to connect a server."));
2364
+ } else {
2365
+ console.log(bold(`\n🔌 MCP Servers (${status.length}):\n`));
2366
+ for (const s of status) {
2367
+ const icon = s.connected ? green("●") : red("○");
2368
+ const toolList = s.tools.length > 0
2369
+ ? dim(` [${s.tools.slice(0, 5).join(", ")}${s.tools.length > 5 ? "..." : ""}]`)
2370
+ : "";
2371
+ console.log(` ${icon} ${bold(s.name.padEnd(18))} ${s.toolCount} tools${toolList}`);
2372
+ if (s.serverInfo?.name) console.log(dim(` server: ${s.serverInfo.name} v${s.serverInfo.version ?? "?"}`));
2373
+ }
2374
+ }
2375
+ if (allTools.length > 0) {
2376
+ console.log(bold(`\n🧰 MCP Tools (${allTools.length} total):\n`));
2377
+ for (const t of allTools) {
2378
+ console.log(` ${cyan(t.wispyName.padEnd(30))} ${dim(t.description.slice(0, 60))}`);
2379
+ }
2380
+ }
2381
+ console.log("");
2382
+ return true;
2383
+ }
2384
+
2385
+ if (sub === "connect") {
2386
+ const serverName = parts[2];
2387
+ if (!serverName) {
2388
+ console.log(yellow("Usage: /mcp connect <server-name>"));
2389
+ return true;
2390
+ }
2391
+ const config = await mcpManager.loadConfig();
2392
+ const serverConfig = config.mcpServers?.[serverName];
2393
+ if (!serverConfig) {
2394
+ console.log(red(`Server "${serverName}" not found in ${MCP_CONFIG_PATH}`));
2395
+ console.log(dim(`Available: ${Object.keys(config.mcpServers ?? {}).join(", ") || "none"}`));
2396
+ return true;
2397
+ }
2398
+ process.stdout.write(dim(` Connecting to "${serverName}"...`));
2399
+ try {
2400
+ const client = await mcpManager.connect(serverName, serverConfig);
2401
+ console.log(green(` ✓ connected (${client.tools.length} tools)`));
2402
+ if (client.tools.length > 0) {
2403
+ console.log(dim(` Tools: ${client.tools.map(t => t.name).join(", ")}`));
2404
+ }
2405
+ } catch (err) {
2406
+ console.log(red(` ✗ failed: ${err.message.slice(0, 120)}`));
2407
+ }
2408
+ return true;
2409
+ }
2410
+
2411
+ if (sub === "disconnect") {
2412
+ const serverName = parts[2];
2413
+ if (!serverName) {
2414
+ console.log(yellow("Usage: /mcp disconnect <server-name>"));
2415
+ return true;
2416
+ }
2417
+ const ok = mcpManager.disconnect(serverName);
2418
+ console.log(ok ? green(`✓ Disconnected "${serverName}"`) : yellow(`"${serverName}" was not connected`));
2419
+ return true;
2420
+ }
2421
+
2422
+ if (sub === "config") {
2423
+ console.log(dim(`Config file: ${MCP_CONFIG_PATH}`));
2424
+ try {
2425
+ const cfg = await mcpManager.loadConfig();
2426
+ const servers = cfg.mcpServers ?? {};
2427
+ console.log(bold(`\nDefined servers (${Object.keys(servers).length}):\n`));
2428
+ for (const [name, s] of Object.entries(servers)) {
2429
+ const status = s.disabled ? dim("disabled") : green("enabled");
2430
+ console.log(` ${name.padEnd(20)} ${status}`);
2431
+ console.log(dim(` cmd: ${s.command} ${(s.args ?? []).join(" ")}`));
2432
+ }
2433
+ } catch {
2434
+ console.log(dim("No config found."));
2435
+ }
2436
+ return true;
2437
+ }
2438
+
2439
+ if (sub === "reload") {
2440
+ console.log(dim("Reloading MCP servers..."));
2441
+ mcpManager.disconnectAll();
2442
+ const results = await mcpManager.autoConnect();
2443
+ for (const r of results) {
2444
+ if (r.status === "connected") console.log(green(` ✓ ${r.name} (${r.tools} tools)`));
2445
+ else if (r.status === "disabled") console.log(dim(` ○ ${r.name} (disabled)`));
2446
+ else console.log(red(` ✗ ${r.name}: ${r.error?.slice(0, 80)}`));
2447
+ }
2448
+ return true;
2449
+ }
2450
+
2451
+ // Unknown subcommand
2452
+ console.log(`
2453
+ ${bold("/mcp commands:")}
2454
+ ${cyan("/mcp list")} List connected servers and tools
2455
+ ${cyan("/mcp connect <name>")} Connect a server from config
2456
+ ${cyan("/mcp disconnect <name>")} Disconnect a server
2457
+ ${cyan("/mcp config")} Show mcp.json config
2458
+ ${cyan("/mcp reload")} Reconnect all servers
2459
+ ${dim(`Config: ${MCP_CONFIG_PATH}`)}
2460
+ `);
2461
+ return true;
2462
+ }
2463
+
2324
2464
  return false;
2325
2465
  }
2326
2466
 
@@ -2618,6 +2758,28 @@ if (!API_KEY && PROVIDER !== "ollama") {
2618
2758
  }
2619
2759
  }
2620
2760
 
2761
+ // Auto-connect MCP servers from ~/.wispy/mcp.json
2762
+ await ensureDefaultMcpConfig(MCP_CONFIG_PATH);
2763
+ {
2764
+ const mcpResults = await mcpManager.autoConnect();
2765
+ const connected = mcpResults.filter(r => r.status === "connected");
2766
+ const failed = mcpResults.filter(r => r.status === "failed");
2767
+ if (connected.length > 0) {
2768
+ // Quiet success — only show if verbose
2769
+ // console.log(dim(`🔌 MCP: ${connected.map(r => `${r.name}(${r.tools})`).join(", ")}`));
2770
+ }
2771
+ if (failed.length > 0 && process.env.WISPY_DEBUG) {
2772
+ for (const r of failed) {
2773
+ console.error(dim(`⚠ MCP "${r.name}" failed: ${r.error?.slice(0, 80)}`));
2774
+ }
2775
+ }
2776
+ }
2777
+
2778
+ // Graceful MCP cleanup on exit
2779
+ process.on("exit", () => { try { mcpManager.disconnectAll(); } catch {} });
2780
+ process.on("SIGINT", () => { mcpManager.disconnectAll(); process.exit(0); });
2781
+ process.on("SIGTERM", () => { mcpManager.disconnectAll(); process.exit(0); });
2782
+
2621
2783
  // Auto-start server before entering REPL or one-shot
2622
2784
  const serverStatus = await startServerIfNeeded();
2623
2785
  if (serverStatus.started) {
@@ -2708,6 +2870,7 @@ ${bold("In-session commands:")}
2708
2870
  /delete <name> Delete a session
2709
2871
  /export [md|clipboard] Export conversation
2710
2872
  /provider Show current provider info
2873
+ /mcp [list|connect|disconnect|config|reload] MCP server management
2711
2874
  /quit Exit
2712
2875
 
2713
2876
  ${bold("Providers (auto-detected):")}