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.
- package/bin/wispy-tui.mjs +14 -0
- package/bin/wispy.mjs +22 -4
- package/lib/mcp-client.mjs +381 -0
- package/lib/wispy-repl.mjs +168 -5
- package/lib/wispy-tui.mjs +812 -0
- package/package.json +12 -3
|
@@ -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
|
-
|
|
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
|
-
//
|
|
11
|
-
|
|
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
|
+
}
|
package/lib/wispy-repl.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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):")}
|