wispy-cli 0.3.2 → 0.4.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 +167 -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,8 @@ 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 { fileURLToPath as _fileURLToPath } from "node:url";
|
|
19
|
+
import { MCPManager, ensureDefaultMcpConfig } from "./mcp-client.mjs";
|
|
18
20
|
|
|
19
21
|
// ---------------------------------------------------------------------------
|
|
20
22
|
// Config
|
|
@@ -22,6 +24,10 @@ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
22
24
|
|
|
23
25
|
const WISPY_DIR = path.join(os.homedir(), ".wispy");
|
|
24
26
|
const MEMORY_DIR = path.join(WISPY_DIR, "memory");
|
|
27
|
+
const MCP_CONFIG_PATH = path.join(WISPY_DIR, "mcp.json");
|
|
28
|
+
|
|
29
|
+
// Global MCP manager — initialized at startup
|
|
30
|
+
const mcpManager = new MCPManager(MCP_CONFIG_PATH);
|
|
25
31
|
|
|
26
32
|
// Workstream-aware conversation storage
|
|
27
33
|
// wispy -w "project-name" → separate conversation per workstream
|
|
@@ -619,6 +625,11 @@ function optimizeContext(messages, maxTokens = 30_000) {
|
|
|
619
625
|
// Tool definitions (Gemini function calling format)
|
|
620
626
|
// ---------------------------------------------------------------------------
|
|
621
627
|
|
|
628
|
+
// Returns merged static + dynamically registered MCP tool definitions
|
|
629
|
+
function getAllToolDefinitions() {
|
|
630
|
+
return [...TOOL_DEFINITIONS, ...mcpManager.getToolDefinitions()];
|
|
631
|
+
}
|
|
632
|
+
|
|
622
633
|
const TOOL_DEFINITIONS = [
|
|
623
634
|
{
|
|
624
635
|
name: "read_file",
|
|
@@ -1465,6 +1476,22 @@ Be concise. Your output feeds into the next stage.`;
|
|
|
1465
1476
|
}
|
|
1466
1477
|
|
|
1467
1478
|
default: {
|
|
1479
|
+
// Check MCP tools first
|
|
1480
|
+
if (mcpManager.hasTool(name)) {
|
|
1481
|
+
try {
|
|
1482
|
+
const result = await mcpManager.callTool(name, args);
|
|
1483
|
+
// MCP tools/call returns { content: [{type, text}], isError? }
|
|
1484
|
+
if (result?.isError) {
|
|
1485
|
+
const errText = result.content?.map(c => c.text ?? "").join("") ?? "MCP tool error";
|
|
1486
|
+
return { success: false, error: errText };
|
|
1487
|
+
}
|
|
1488
|
+
const output = result?.content?.map(c => c.text ?? c.data ?? JSON.stringify(c)).join("\n") ?? JSON.stringify(result);
|
|
1489
|
+
return { success: true, output };
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
return { success: false, error: `MCP tool error: ${err.message}` };
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1468
1495
|
// Unknown tool — try to execute as a skill via run_command
|
|
1469
1496
|
// This handles cases where the AI hallucinates tools from skill descriptions
|
|
1470
1497
|
const skills = await loadSkills();
|
|
@@ -1476,7 +1503,7 @@ Be concise. Your output feeds into the next stage.`;
|
|
|
1476
1503
|
skill_hint: matchedSkill.body.slice(0, 500),
|
|
1477
1504
|
};
|
|
1478
1505
|
}
|
|
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` };
|
|
1506
|
+
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
1507
|
}
|
|
1481
1508
|
}
|
|
1482
1509
|
} catch (err) {
|
|
@@ -1599,7 +1626,7 @@ async function buildSystemPrompt(messages = []) {
|
|
|
1599
1626
|
" - NEVER reply in Korean when the user wrote in English.",
|
|
1600
1627
|
"",
|
|
1601
1628
|
"## Tools",
|
|
1602
|
-
|
|
1629
|
+
`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
1630
|
"- file_edit: for targeted text replacement (prefer over write_file for edits)",
|
|
1604
1631
|
"- file_search: grep across codebase",
|
|
1605
1632
|
"- git: any git command",
|
|
@@ -1711,7 +1738,7 @@ async function chatGeminiWithTools(messages, onChunk) {
|
|
|
1711
1738
|
sessionTokens.input += estimateTokens(systemInstruction + inputText);
|
|
1712
1739
|
|
|
1713
1740
|
const geminiTools = [{
|
|
1714
|
-
functionDeclarations:
|
|
1741
|
+
functionDeclarations: getAllToolDefinitions().map(t => ({
|
|
1715
1742
|
name: t.name,
|
|
1716
1743
|
description: t.description,
|
|
1717
1744
|
parameters: t.parameters,
|
|
@@ -1801,7 +1828,7 @@ async function chatOpenAIWithTools(messages, onChunk) {
|
|
|
1801
1828
|
return { role: m.role === "assistant" ? "assistant" : m.role, content: m.content };
|
|
1802
1829
|
});
|
|
1803
1830
|
|
|
1804
|
-
const openaiTools =
|
|
1831
|
+
const openaiTools = getAllToolDefinitions().map(t => ({
|
|
1805
1832
|
type: "function",
|
|
1806
1833
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
1807
1834
|
}));
|
|
@@ -1879,7 +1906,7 @@ async function chatAnthropicWithTools(messages, onChunk) {
|
|
|
1879
1906
|
const inputText = anthropicMessages.map(m => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join("");
|
|
1880
1907
|
sessionTokens.input += estimateTokens(systemPrompt + inputText);
|
|
1881
1908
|
|
|
1882
|
-
const anthropicTools =
|
|
1909
|
+
const anthropicTools = getAllToolDefinitions().map(t => ({
|
|
1883
1910
|
name: t.name,
|
|
1884
1911
|
description: t.description,
|
|
1885
1912
|
input_schema: t.parameters,
|
|
@@ -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) {
|