blyuecode 0.1.17 → 0.3.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/dist/core/mcp.js +333 -0
- package/dist/core/mcpRegistry.js +188 -0
- package/dist/core/models.js +150 -0
- package/dist/core/tools.js +5 -1
- package/dist/index.js +2 -0
- package/dist/ui/Chat.js +13 -5
- package/dist/ui/ModelPicker.js +38 -13
- package/package.json +1 -1
package/dist/core/mcp.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Minimal MCP client — stdio + HTTP/SSE transports, JSON-RPC 2.0.
|
|
2
|
+
// Spec: https://spec.modelcontextprotocol.io/
|
|
3
|
+
//
|
|
4
|
+
// No external dependencies. Uses Node 20's global fetch for SSE.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// const client = new McpClient(name, config);
|
|
8
|
+
// await client.connect();
|
|
9
|
+
// const tools = await client.listTools();
|
|
10
|
+
// const result = await client.callTool('read_file', { path: '...' });
|
|
11
|
+
// await client.close();
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
import { EventEmitter } from 'events';
|
|
14
|
+
// ──────────────────────────────────────────────────────────────
|
|
15
|
+
// Stdio transport — newline-delimited JSON-RPC over child stdin/stdout
|
|
16
|
+
// ──────────────────────────────────────────────────────────────
|
|
17
|
+
class StdioTransport extends EventEmitter {
|
|
18
|
+
proc;
|
|
19
|
+
buf = '';
|
|
20
|
+
lastStderr = '';
|
|
21
|
+
get stderrTail() { return this.lastStderr; }
|
|
22
|
+
constructor(cfg) {
|
|
23
|
+
super();
|
|
24
|
+
this.proc = spawn(cfg.command, cfg.args ?? [], {
|
|
25
|
+
cwd: cfg.cwd,
|
|
26
|
+
env: { ...process.env, ...(cfg.env ?? {}) },
|
|
27
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
+
});
|
|
29
|
+
this.proc.stdout?.on('data', (chunk) => {
|
|
30
|
+
this.buf += chunk.toString('utf8');
|
|
31
|
+
let idx;
|
|
32
|
+
while ((idx = this.buf.indexOf('\n')) >= 0) {
|
|
33
|
+
const line = this.buf.slice(0, idx).trim();
|
|
34
|
+
this.buf = this.buf.slice(idx + 1);
|
|
35
|
+
if (!line)
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const msg = JSON.parse(line);
|
|
39
|
+
this.emit('message', msg);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Not JSON — ignore (some servers log to stdout, which is rude
|
|
43
|
+
// but happens; stderr is the correct channel for logs).
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
this.proc.stderr?.on('data', (chunk) => {
|
|
48
|
+
// Servers log readiness banners and diagnostics to stderr — that's
|
|
49
|
+
// the MCP spec's designated log channel. Never treat stderr as an
|
|
50
|
+
// error: it would kill the in-flight initialize request. Keep the
|
|
51
|
+
// last few KB around for debugging, but do nothing else with it.
|
|
52
|
+
this.lastStderr = (this.lastStderr + chunk.toString('utf8')).slice(-4096);
|
|
53
|
+
});
|
|
54
|
+
this.proc.on('error', (err) => this.emit('error', err));
|
|
55
|
+
this.proc.on('exit', () => this.emit('close'));
|
|
56
|
+
}
|
|
57
|
+
send(msg) {
|
|
58
|
+
if (!this.proc.stdin || this.proc.stdin.destroyed) {
|
|
59
|
+
throw new Error('stdio transport closed');
|
|
60
|
+
}
|
|
61
|
+
this.proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
try {
|
|
65
|
+
this.proc.stdin?.end();
|
|
66
|
+
this.proc.kill('SIGTERM');
|
|
67
|
+
// SIGKILL fallback if it doesn't die within 2s.
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
if (this.proc.exitCode === null)
|
|
70
|
+
this.proc.kill('SIGKILL');
|
|
71
|
+
}, 2000).unref?.();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// already dead
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ──────────────────────────────────────────────────────────────
|
|
79
|
+
// SSE transport — POST for sends, GET text/event-stream for receives
|
|
80
|
+
//
|
|
81
|
+
// MCP's HTTP+SSE transport (2024-11-05 spec):
|
|
82
|
+
// 1. GET url -> text/event-stream, server sends an `endpoint` event
|
|
83
|
+
// with the POST URL to use for requests.
|
|
84
|
+
// 2. POST JSON-RPC requests to that endpoint; responses come back on
|
|
85
|
+
// the SSE stream as `message` events.
|
|
86
|
+
// ──────────────────────────────────────────────────────────────
|
|
87
|
+
class SseTransport extends EventEmitter {
|
|
88
|
+
cfg;
|
|
89
|
+
postUrl = null;
|
|
90
|
+
abort = null;
|
|
91
|
+
closed = false;
|
|
92
|
+
constructor(cfg) {
|
|
93
|
+
super();
|
|
94
|
+
this.cfg = cfg;
|
|
95
|
+
void this.start();
|
|
96
|
+
}
|
|
97
|
+
async start() {
|
|
98
|
+
try {
|
|
99
|
+
this.abort = new AbortController();
|
|
100
|
+
const res = await fetch(this.cfg.url, {
|
|
101
|
+
headers: {
|
|
102
|
+
Accept: 'text/event-stream',
|
|
103
|
+
...(this.cfg.headers ?? {}),
|
|
104
|
+
},
|
|
105
|
+
signal: this.abort.signal,
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok || !res.body) {
|
|
108
|
+
throw new Error(`SSE connect failed: HTTP ${res.status}`);
|
|
109
|
+
}
|
|
110
|
+
const reader = res.body.getReader();
|
|
111
|
+
const decoder = new TextDecoder();
|
|
112
|
+
let buf = '';
|
|
113
|
+
while (!this.closed) {
|
|
114
|
+
const { value, done } = await reader.read();
|
|
115
|
+
if (done)
|
|
116
|
+
break;
|
|
117
|
+
buf += decoder.decode(value, { stream: true });
|
|
118
|
+
let idx;
|
|
119
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
120
|
+
const rawEvent = buf.slice(0, idx);
|
|
121
|
+
buf = buf.slice(idx + 2);
|
|
122
|
+
this.parseEvent(rawEvent);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
this.emit('close');
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (!this.closed)
|
|
129
|
+
this.emit('error', err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
parseEvent(raw) {
|
|
133
|
+
let event = 'message';
|
|
134
|
+
let data = '';
|
|
135
|
+
for (const line of raw.split('\n')) {
|
|
136
|
+
if (line.startsWith('event:'))
|
|
137
|
+
event = line.slice(6).trim();
|
|
138
|
+
else if (line.startsWith('data:'))
|
|
139
|
+
data += line.slice(5).trim();
|
|
140
|
+
}
|
|
141
|
+
if (!data)
|
|
142
|
+
return;
|
|
143
|
+
if (event === 'endpoint') {
|
|
144
|
+
// Server tells us where to POST. Relative URLs are resolved against
|
|
145
|
+
// the SSE endpoint's origin.
|
|
146
|
+
try {
|
|
147
|
+
this.postUrl = new URL(data, this.cfg.url).toString();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
this.postUrl = data;
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (event === 'message') {
|
|
155
|
+
try {
|
|
156
|
+
const msg = JSON.parse(data);
|
|
157
|
+
this.emit('message', msg);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// ignore malformed
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
send(msg) {
|
|
165
|
+
if (!this.postUrl) {
|
|
166
|
+
throw new Error('SSE transport not ready — no endpoint event received yet');
|
|
167
|
+
}
|
|
168
|
+
void fetch(this.postUrl, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: {
|
|
171
|
+
'Content-Type': 'application/json',
|
|
172
|
+
...(this.cfg.headers ?? {}),
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify(msg),
|
|
175
|
+
}).catch((err) => this.emit('error', err));
|
|
176
|
+
}
|
|
177
|
+
close() {
|
|
178
|
+
this.closed = true;
|
|
179
|
+
this.abort?.abort();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// ──────────────────────────────────────────────────────────────
|
|
183
|
+
// Client
|
|
184
|
+
// ──────────────────────────────────────────────────────────────
|
|
185
|
+
export class McpClient {
|
|
186
|
+
name;
|
|
187
|
+
cfg;
|
|
188
|
+
transport = null;
|
|
189
|
+
nextId = 1;
|
|
190
|
+
pending = new Map();
|
|
191
|
+
connected = false;
|
|
192
|
+
serverInfo = null;
|
|
193
|
+
cachedTools = null;
|
|
194
|
+
constructor(name, cfg) {
|
|
195
|
+
this.name = name;
|
|
196
|
+
this.cfg = cfg;
|
|
197
|
+
}
|
|
198
|
+
get autoAllow() {
|
|
199
|
+
return this.cfg.autoAllow === true;
|
|
200
|
+
}
|
|
201
|
+
get info() {
|
|
202
|
+
return this.serverInfo;
|
|
203
|
+
}
|
|
204
|
+
async connect() {
|
|
205
|
+
if (this.connected)
|
|
206
|
+
return;
|
|
207
|
+
this.transport =
|
|
208
|
+
'command' in this.cfg
|
|
209
|
+
? new StdioTransport(this.cfg)
|
|
210
|
+
: new SseTransport(this.cfg);
|
|
211
|
+
this.transport.on('message', (msg) => this.handleMessage(msg));
|
|
212
|
+
this.transport.on('error', (err) => {
|
|
213
|
+
// Route transport errors to any in-flight request so they don't hang.
|
|
214
|
+
for (const [, p] of this.pending) {
|
|
215
|
+
clearTimeout(p.timer);
|
|
216
|
+
p.reject(err);
|
|
217
|
+
}
|
|
218
|
+
this.pending.clear();
|
|
219
|
+
});
|
|
220
|
+
this.transport.on('close', () => {
|
|
221
|
+
this.connected = false;
|
|
222
|
+
});
|
|
223
|
+
// Handshake
|
|
224
|
+
const result = (await this.request('initialize', {
|
|
225
|
+
protocolVersion: '2024-11-05',
|
|
226
|
+
capabilities: { tools: {} },
|
|
227
|
+
clientInfo: { name: 'blyue', version: '0.2.0' },
|
|
228
|
+
}));
|
|
229
|
+
this.serverInfo = result?.serverInfo ?? null;
|
|
230
|
+
// Spec requires this notification after initialize.
|
|
231
|
+
this.notify('notifications/initialized', {});
|
|
232
|
+
this.connected = true;
|
|
233
|
+
}
|
|
234
|
+
async listTools() {
|
|
235
|
+
if (this.cachedTools)
|
|
236
|
+
return this.cachedTools;
|
|
237
|
+
const res = (await this.request('tools/list', {}));
|
|
238
|
+
this.cachedTools = res?.tools ?? [];
|
|
239
|
+
return this.cachedTools;
|
|
240
|
+
}
|
|
241
|
+
async callTool(name, args) {
|
|
242
|
+
const res = (await this.request('tools/call', {
|
|
243
|
+
name,
|
|
244
|
+
arguments: args,
|
|
245
|
+
}));
|
|
246
|
+
return res;
|
|
247
|
+
}
|
|
248
|
+
async close() {
|
|
249
|
+
for (const [, p] of this.pending) {
|
|
250
|
+
clearTimeout(p.timer);
|
|
251
|
+
p.reject(new Error('client closed'));
|
|
252
|
+
}
|
|
253
|
+
this.pending.clear();
|
|
254
|
+
this.transport?.close();
|
|
255
|
+
this.transport = null;
|
|
256
|
+
this.connected = false;
|
|
257
|
+
this.cachedTools = null;
|
|
258
|
+
}
|
|
259
|
+
// ────────────────────────────────────────────
|
|
260
|
+
handleMessage(msg) {
|
|
261
|
+
if ('id' in msg && msg.id !== undefined) {
|
|
262
|
+
const p = this.pending.get(msg.id);
|
|
263
|
+
if (!p)
|
|
264
|
+
return;
|
|
265
|
+
this.pending.delete(msg.id);
|
|
266
|
+
clearTimeout(p.timer);
|
|
267
|
+
if (msg.error) {
|
|
268
|
+
p.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
p.resolve(msg.result);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Notifications from server (e.g. tools/list_changed) — currently ignored.
|
|
276
|
+
}
|
|
277
|
+
request(method, params) {
|
|
278
|
+
if (!this.transport)
|
|
279
|
+
throw new Error('not connected');
|
|
280
|
+
const id = this.nextId++;
|
|
281
|
+
const timeoutMs = ('timeoutMs' in this.cfg ? this.cfg.timeoutMs : undefined) ?? 60_000;
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
const timer = setTimeout(() => {
|
|
284
|
+
this.pending.delete(id);
|
|
285
|
+
reject(new Error(`MCP request "${method}" timed out after ${timeoutMs}ms`));
|
|
286
|
+
}, timeoutMs);
|
|
287
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
288
|
+
try {
|
|
289
|
+
this.transport.send({ jsonrpc: '2.0', id, method, params });
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
this.pending.delete(id);
|
|
294
|
+
reject(err);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
notify(method, params) {
|
|
299
|
+
if (!this.transport)
|
|
300
|
+
return;
|
|
301
|
+
this.transport.send({ jsonrpc: '2.0', method, params });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// ──────────────────────────────────────────────────────────────
|
|
305
|
+
// Config load / save
|
|
306
|
+
// ──────────────────────────────────────────────────────────────
|
|
307
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
308
|
+
import { homedir } from 'os';
|
|
309
|
+
import { join, dirname } from 'path';
|
|
310
|
+
export function mcpConfigPath() {
|
|
311
|
+
return join(homedir(), '.blyue', 'mcp.json');
|
|
312
|
+
}
|
|
313
|
+
export function loadMcpConfig() {
|
|
314
|
+
const p = mcpConfigPath();
|
|
315
|
+
if (!existsSync(p))
|
|
316
|
+
return { servers: {} };
|
|
317
|
+
try {
|
|
318
|
+
const raw = readFileSync(p, 'utf8');
|
|
319
|
+
const parsed = JSON.parse(raw);
|
|
320
|
+
if (!parsed.servers || typeof parsed.servers !== 'object') {
|
|
321
|
+
return { servers: {} };
|
|
322
|
+
}
|
|
323
|
+
return parsed;
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
throw new Error(`Failed to parse ${p}: ${err.message}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
export function saveMcpConfig(cfg) {
|
|
330
|
+
const p = mcpConfigPath();
|
|
331
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
332
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
333
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// MCP registry — holds all connected MCP clients as a module-level
|
|
2
|
+
// singleton so the tools layer can merge their tool defs into the LLM's
|
|
3
|
+
// tool list without threading state through the agent.
|
|
4
|
+
//
|
|
5
|
+
// Lifecycle:
|
|
6
|
+
// 1. index.tsx calls initMcpRegistry() once after config load
|
|
7
|
+
// 2. tools.ts calls getMcpToolDefsSync() every agent-loop iteration
|
|
8
|
+
// 3. agent calls executeTool() → falls through to executeMcpTool()
|
|
9
|
+
// for names prefixed mcp__<server>__<tool>
|
|
10
|
+
import { McpClient, loadMcpConfig, } from './mcp.js';
|
|
11
|
+
import { permissions } from './permission.js';
|
|
12
|
+
const servers = new Map();
|
|
13
|
+
let cachedDefs = [];
|
|
14
|
+
let initPromise = null;
|
|
15
|
+
let initialized = false;
|
|
16
|
+
/**
|
|
17
|
+
* Called once on app startup, after config is loaded.
|
|
18
|
+
* Never throws — a bad MCP server is logged and skipped so the TUI
|
|
19
|
+
* still boots. (Failing closed here would make a typo in mcp.json
|
|
20
|
+
* brick the whole app.)
|
|
21
|
+
*/
|
|
22
|
+
export async function initMcpRegistry() {
|
|
23
|
+
if (initPromise)
|
|
24
|
+
return initPromise;
|
|
25
|
+
initPromise = (async () => {
|
|
26
|
+
let cfg;
|
|
27
|
+
try {
|
|
28
|
+
cfg = loadMcpConfig();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
initialized = true;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const entries = Object.entries(cfg.servers || {});
|
|
35
|
+
if (entries.length === 0) {
|
|
36
|
+
initialized = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await Promise.all(entries.map(async ([name, serverCfg]) => {
|
|
40
|
+
const client = new McpClient(name, serverCfg);
|
|
41
|
+
try {
|
|
42
|
+
await client.connect();
|
|
43
|
+
const tools = await client.listTools();
|
|
44
|
+
servers.set(name, { client, tools });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
servers.set(name, {
|
|
48
|
+
client,
|
|
49
|
+
tools: [],
|
|
50
|
+
error: err.message,
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
await client.close();
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
rebuildDefs();
|
|
59
|
+
initialized = true;
|
|
60
|
+
})();
|
|
61
|
+
return initPromise;
|
|
62
|
+
}
|
|
63
|
+
function rebuildDefs() {
|
|
64
|
+
const defs = [];
|
|
65
|
+
for (const [serverName, state] of servers) {
|
|
66
|
+
if (state.error)
|
|
67
|
+
continue;
|
|
68
|
+
for (const t of state.tools) {
|
|
69
|
+
defs.push({
|
|
70
|
+
type: 'function',
|
|
71
|
+
function: {
|
|
72
|
+
name: `mcp__${serverName}__${t.name}`,
|
|
73
|
+
description: t.description ||
|
|
74
|
+
`MCP tool "${t.name}" from server "${serverName}".`,
|
|
75
|
+
parameters: t.schema || { type: 'object', properties: {} },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
cachedDefs = defs;
|
|
81
|
+
}
|
|
82
|
+
/** Synchronous — safe to call every iteration of the agent loop. */
|
|
83
|
+
export function getMcpToolDefsSync() {
|
|
84
|
+
return cachedDefs;
|
|
85
|
+
}
|
|
86
|
+
export function isMcpTool(name) {
|
|
87
|
+
return typeof name === 'string' && name.startsWith('mcp__');
|
|
88
|
+
}
|
|
89
|
+
export function parseMcpToolName(fullName) {
|
|
90
|
+
if (!isMcpTool(fullName))
|
|
91
|
+
return null;
|
|
92
|
+
const rest = fullName.slice('mcp__'.length);
|
|
93
|
+
const idx = rest.indexOf('__');
|
|
94
|
+
if (idx < 0)
|
|
95
|
+
return null;
|
|
96
|
+
return { server: rest.slice(0, idx), tool: rest.slice(idx + 2) };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Executes an MCP tool call. Routes through the permission broker
|
|
100
|
+
* unless the server is marked autoAllow:true in mcp.json.
|
|
101
|
+
*/
|
|
102
|
+
export async function executeMcpTool(fullName, args) {
|
|
103
|
+
const parsed = parseMcpToolName(fullName);
|
|
104
|
+
if (!parsed)
|
|
105
|
+
return `Error: not an MCP tool: ${fullName}`;
|
|
106
|
+
const state = servers.get(parsed.server);
|
|
107
|
+
if (!state)
|
|
108
|
+
return `Error: MCP server "${parsed.server}" not registered`;
|
|
109
|
+
if (state.error) {
|
|
110
|
+
return `Error: MCP server "${parsed.server}" failed to connect: ${state.error}`;
|
|
111
|
+
}
|
|
112
|
+
const { client } = state;
|
|
113
|
+
const alreadyAllowed = permissions.isAlwaysAllowed(fullName);
|
|
114
|
+
if (!client.autoAllow && !alreadyAllowed) {
|
|
115
|
+
const argSummary = safeJson(args).slice(0, 400);
|
|
116
|
+
const answer = await permissions.ask({
|
|
117
|
+
tool: fullName,
|
|
118
|
+
title: `MCP · ${parsed.server}`,
|
|
119
|
+
body: [
|
|
120
|
+
`Tool: ${parsed.tool}`,
|
|
121
|
+
`Args: ${argSummary || '(none)'}`,
|
|
122
|
+
],
|
|
123
|
+
danger: 'medium',
|
|
124
|
+
});
|
|
125
|
+
if (answer === 'no')
|
|
126
|
+
return `Denied by user: ${fullName}`;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const result = await client.callTool(parsed.tool, args || {});
|
|
130
|
+
return formatResult(result);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return `MCP error: ${err.message}`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function formatResult(result) {
|
|
137
|
+
if (!result || !Array.isArray(result.content)) {
|
|
138
|
+
return safeJson(result);
|
|
139
|
+
}
|
|
140
|
+
const parts = [];
|
|
141
|
+
for (const c of result.content) {
|
|
142
|
+
if (c.type === 'text')
|
|
143
|
+
parts.push(c.text);
|
|
144
|
+
else if (c.type === 'image')
|
|
145
|
+
parts.push(`[image ${c.mimeType}, ${c.data.length} base64 chars]`);
|
|
146
|
+
else if (c.type === 'resource')
|
|
147
|
+
parts.push(c.resource.text ??
|
|
148
|
+
`[resource ${c.resource.uri}${c.resource.blob ? ', blob' : ''}]`);
|
|
149
|
+
}
|
|
150
|
+
const text = parts.join('\n');
|
|
151
|
+
return result.isError ? `ERROR: ${text}` : text;
|
|
152
|
+
}
|
|
153
|
+
function safeJson(v) {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.stringify(v);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return String(v);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function listMcpServers() {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const [name, state] of servers) {
|
|
164
|
+
out.push({
|
|
165
|
+
name,
|
|
166
|
+
autoAllow: state.client.autoAllow,
|
|
167
|
+
toolCount: state.tools.length,
|
|
168
|
+
toolNames: state.tools.map((t) => t.name),
|
|
169
|
+
error: state.error,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
export function isMcpReady() {
|
|
175
|
+
return initialized;
|
|
176
|
+
}
|
|
177
|
+
export async function closeAllMcp() {
|
|
178
|
+
for (const [, state] of servers) {
|
|
179
|
+
try {
|
|
180
|
+
await state.client.close();
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
}
|
|
184
|
+
servers.clear();
|
|
185
|
+
cachedDefs = [];
|
|
186
|
+
initPromise = null;
|
|
187
|
+
initialized = false;
|
|
188
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Universal model fetcher — works with any OpenAI-compatible /v1/models
|
|
2
|
+
// endpoint: OpenAI, OpenRouter, Groq, xAI (Grok), Ollama, LM Studio,
|
|
3
|
+
// llama.cpp, vLLM, together, deepseek, etc.
|
|
4
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
5
|
+
let cache = null;
|
|
6
|
+
function currentBaseUrl() {
|
|
7
|
+
const raw = process.env.OPENAI_BASE_URL ||
|
|
8
|
+
process.env.OPENAI_API_BASE ||
|
|
9
|
+
'https://api.openai.com/v1';
|
|
10
|
+
// Strip trailing slash for consistent concat.
|
|
11
|
+
return raw.replace(/\/+$/, '');
|
|
12
|
+
}
|
|
13
|
+
function currentApiKey() {
|
|
14
|
+
return process.env.OPENAI_API_KEY || process.env.API_KEY || '';
|
|
15
|
+
}
|
|
16
|
+
function providerLabel(baseUrl) {
|
|
17
|
+
const u = baseUrl.toLowerCase();
|
|
18
|
+
if (u.includes('openrouter'))
|
|
19
|
+
return 'OpenRouter';
|
|
20
|
+
if (u.includes('api.x.ai'))
|
|
21
|
+
return 'xAI';
|
|
22
|
+
if (u.includes('groq.com'))
|
|
23
|
+
return 'Groq';
|
|
24
|
+
if (u.includes('api.openai.com'))
|
|
25
|
+
return 'OpenAI';
|
|
26
|
+
if (u.includes('api.deepseek.com'))
|
|
27
|
+
return 'DeepSeek';
|
|
28
|
+
if (u.includes('api.together'))
|
|
29
|
+
return 'Together';
|
|
30
|
+
if (u.includes('localhost') || u.includes('127.0.0.1'))
|
|
31
|
+
return 'local';
|
|
32
|
+
// Fall back to the hostname.
|
|
33
|
+
try {
|
|
34
|
+
return new URL(baseUrl).host;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'provider';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function currentProviderLabel() {
|
|
41
|
+
return providerLabel(currentBaseUrl());
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Non-chat models we should hide from the picker. Conservative — better
|
|
45
|
+
* to show a couple of extras than to hide something the user wants.
|
|
46
|
+
*/
|
|
47
|
+
function isChatModel(id) {
|
|
48
|
+
const l = id.toLowerCase();
|
|
49
|
+
const block = [
|
|
50
|
+
'embed',
|
|
51
|
+
'embedding',
|
|
52
|
+
'whisper',
|
|
53
|
+
'-tts',
|
|
54
|
+
'tts-',
|
|
55
|
+
'dall-e',
|
|
56
|
+
'dalle',
|
|
57
|
+
'moderation',
|
|
58
|
+
'realtime',
|
|
59
|
+
'transcribe',
|
|
60
|
+
'-audio',
|
|
61
|
+
'audio-',
|
|
62
|
+
'image-',
|
|
63
|
+
'-vision-preview',
|
|
64
|
+
'davinci',
|
|
65
|
+
'babbage',
|
|
66
|
+
'curie',
|
|
67
|
+
'ada-',
|
|
68
|
+
];
|
|
69
|
+
return !block.some((b) => l.includes(b));
|
|
70
|
+
}
|
|
71
|
+
function parseNumber(v) {
|
|
72
|
+
if (v === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const n = typeof v === 'string' ? parseFloat(v) : v;
|
|
75
|
+
return Number.isFinite(n) ? n : undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Fetches the model list from the current provider. Cached for 5 minutes
|
|
79
|
+
* per base URL, so reopening /model is instant but "new models" surface
|
|
80
|
+
* within one cache window.
|
|
81
|
+
*/
|
|
82
|
+
export async function fetchModels(opts) {
|
|
83
|
+
const baseUrl = currentBaseUrl();
|
|
84
|
+
const url = baseUrl + '/models';
|
|
85
|
+
if (!opts?.force &&
|
|
86
|
+
cache &&
|
|
87
|
+
cache.url === url &&
|
|
88
|
+
Date.now() - cache.at < CACHE_TTL_MS) {
|
|
89
|
+
return cache.models;
|
|
90
|
+
}
|
|
91
|
+
const headers = { Accept: 'application/json' };
|
|
92
|
+
const key = currentApiKey();
|
|
93
|
+
if (key)
|
|
94
|
+
headers.Authorization = `Bearer ${key}`;
|
|
95
|
+
let res;
|
|
96
|
+
try {
|
|
97
|
+
res = await fetch(url, { headers });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new Error(`Could not reach ${url}: ${err.message}. ` +
|
|
101
|
+
`Is the provider reachable and the base URL correct?`);
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
throw new Error(`${url} returned HTTP ${res.status} ${res.statusText}. ` +
|
|
105
|
+
`Check your API key and base URL in /settings.`);
|
|
106
|
+
}
|
|
107
|
+
const body = (await res.json());
|
|
108
|
+
const raw = Array.isArray(body) ? body : body.data ?? [];
|
|
109
|
+
const models = raw
|
|
110
|
+
.map((m) => {
|
|
111
|
+
const id = m.id || m.name;
|
|
112
|
+
if (!id)
|
|
113
|
+
return null;
|
|
114
|
+
const prompt = parseNumber(m.pricing?.prompt);
|
|
115
|
+
const completion = parseNumber(m.pricing?.completion);
|
|
116
|
+
const isFree = prompt !== undefined &&
|
|
117
|
+
completion !== undefined &&
|
|
118
|
+
prompt === 0 &&
|
|
119
|
+
completion === 0;
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
contextLength: m.context_length ?? m.context_window,
|
|
123
|
+
ownedBy: m.owned_by,
|
|
124
|
+
pricing: prompt !== undefined && completion !== undefined
|
|
125
|
+
? { prompt, completion }
|
|
126
|
+
: undefined,
|
|
127
|
+
isFree,
|
|
128
|
+
};
|
|
129
|
+
})
|
|
130
|
+
.filter((m) => m !== null)
|
|
131
|
+
.filter((m) => isChatModel(m.id));
|
|
132
|
+
// Sort: free first, then alphabetical. (OpenRouter returns them in
|
|
133
|
+
// arbitrary order; this makes the useful ones surface at the top.)
|
|
134
|
+
models.sort((a, b) => {
|
|
135
|
+
if (!!a.isFree !== !!b.isFree)
|
|
136
|
+
return a.isFree ? -1 : 1;
|
|
137
|
+
return a.id.localeCompare(b.id);
|
|
138
|
+
});
|
|
139
|
+
cache = { at: Date.now(), url, models };
|
|
140
|
+
return models;
|
|
141
|
+
}
|
|
142
|
+
export function clearModelCache() {
|
|
143
|
+
cache = null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Returns the current model ID — from config/env.
|
|
147
|
+
*/
|
|
148
|
+
export function currentModelId() {
|
|
149
|
+
return process.env.MODEL || '';
|
|
150
|
+
}
|
package/dist/core/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ import { promisify } from 'util';
|
|
|
5
5
|
import { tmpdir } from 'os';
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
import { hasN8n } from './config.js';
|
|
8
|
+
import { getMcpToolDefsSync, executeMcpTool, isMcpTool } from './mcpRegistry.js';
|
|
8
9
|
import { permissions } from './permission.js';
|
|
9
10
|
import { lineDiff, compactDiff } from './diff.js';
|
|
10
11
|
import { isBlocked } from './sandbox.js';
|
|
@@ -86,7 +87,8 @@ const N8N_TOOLS = [
|
|
|
86
87
|
{ type: 'function', function: { name: 'n8n_deactivate_workflow', description: 'Deactivate n8n workflow', parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
|
|
87
88
|
];
|
|
88
89
|
export function getToolDefs() {
|
|
89
|
-
|
|
90
|
+
const base = hasN8n() ? [...BASE_TOOLS, ...N8N_TOOLS] : [...BASE_TOOLS];
|
|
91
|
+
return [...base, ...getMcpToolDefsSync()];
|
|
90
92
|
}
|
|
91
93
|
export const toolDefs = BASE_TOOLS;
|
|
92
94
|
const DANGER_PATTERNS = [
|
|
@@ -242,6 +244,8 @@ export async function executeTool(name, args) {
|
|
|
242
244
|
case 'n8n_deactivate_workflow':
|
|
243
245
|
return await n8nDeactivateWorkflow(args.id);
|
|
244
246
|
default:
|
|
247
|
+
if (isMcpTool(name))
|
|
248
|
+
return executeMcpTool(name, args);
|
|
245
249
|
return 'Unknown tool: ' + name;
|
|
246
250
|
}
|
|
247
251
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Banner } from './ui/Banner.js';
|
|
|
8
8
|
import { Consent, hasConsented } from './ui/Consent.js';
|
|
9
9
|
import { loadConfig, applyConfig } from './core/config.js';
|
|
10
10
|
import { loadSession } from './core/session.js';
|
|
11
|
+
import { initMcpRegistry } from './core/mcpRegistry.js';
|
|
11
12
|
const App = () => {
|
|
12
13
|
const [ready, setReady] = useState(false);
|
|
13
14
|
const [resumedCount, setResumedCount] = useState(0);
|
|
@@ -18,6 +19,7 @@ const App = () => {
|
|
|
18
19
|
const existing = loadConfig();
|
|
19
20
|
if (existing) {
|
|
20
21
|
applyConfig(existing);
|
|
22
|
+
void initMcpRegistry();
|
|
21
23
|
const hist = loadSession();
|
|
22
24
|
setResumedCount(hist ? hist.length : 0);
|
|
23
25
|
setReady(true);
|
package/dist/ui/Chat.js
CHANGED
|
@@ -16,6 +16,7 @@ import { useSlowColor } from './useSlowColor.js';
|
|
|
16
16
|
import { pastelAt } from './pastel.js';
|
|
17
17
|
import { bumpActivity } from './activity.js';
|
|
18
18
|
import { theme } from './theme.js';
|
|
19
|
+
import { getConfig, saveConfig, applyConfig } from '../core/config.js';
|
|
19
20
|
let nextId = 0;
|
|
20
21
|
const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
|
|
21
22
|
const HistoryLine = ({ item }) => {
|
|
@@ -100,9 +101,9 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
100
101
|
bumpActivity();
|
|
101
102
|
if (permReq)
|
|
102
103
|
return;
|
|
103
|
-
if (key.ctrl && inputChar === '
|
|
104
|
+
if (key.ctrl && inputChar === 'l')
|
|
104
105
|
setOverlay('model');
|
|
105
|
-
if (key.ctrl && inputChar === '
|
|
106
|
+
if (key.ctrl && inputChar === 'o')
|
|
106
107
|
setOverlay('settings');
|
|
107
108
|
});
|
|
108
109
|
const flush = (kind, text) => {
|
|
@@ -169,8 +170,8 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
169
170
|
setHistory((h) => [
|
|
170
171
|
...h,
|
|
171
172
|
mkItem('tool', 'Commands:'),
|
|
172
|
-
mkItem('tool', ' /model switch model (Ctrl+
|
|
173
|
-
mkItem('tool', ' /settings open settings (Ctrl+
|
|
173
|
+
mkItem('tool', ' /model switch model (Ctrl+L)'),
|
|
174
|
+
mkItem('tool', ' /settings open settings (Ctrl+O)'),
|
|
174
175
|
mkItem('tool', ' /compact summarize history to free context'),
|
|
175
176
|
mkItem('tool', ' /debug show last request to provider'),
|
|
176
177
|
mkItem('tool', ' /clear clear session and permissions'),
|
|
@@ -227,10 +228,17 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
227
228
|
};
|
|
228
229
|
const handleModelSelect = (id) => {
|
|
229
230
|
process.env.MODEL = id;
|
|
231
|
+
try {
|
|
232
|
+
const cfg = getConfig();
|
|
233
|
+
const next = { ...cfg, model: id };
|
|
234
|
+
saveConfig(next);
|
|
235
|
+
applyConfig(next);
|
|
236
|
+
}
|
|
237
|
+
catch { }
|
|
230
238
|
setCurrentModel(id);
|
|
231
239
|
setOverlay('none');
|
|
232
240
|
setAgent(new Agent());
|
|
233
|
-
setHistory((h) => [...h, mkItem('tool',
|
|
241
|
+
setHistory((h) => [...h, mkItem('tool', 'model → ' + id)]);
|
|
234
242
|
};
|
|
235
243
|
if (permReq) {
|
|
236
244
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsx(Box, { marginTop: 1, children: _jsx(PermissionPrompt, { req: permReq }) }), _jsx(Box, { marginTop: 1, children: _jsx(InputBar, { value: "", onChange: () => { }, onSubmit: () => { }, loading: false, mascotState: "permission", awaitingPermission: true }) })] }));
|
package/dist/ui/ModelPicker.js
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
-
import {
|
|
4
|
+
import { fetchModels, currentProviderLabel, } from '../core/models.js';
|
|
5
5
|
import { theme } from './theme.js';
|
|
6
6
|
export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
7
7
|
const { stdout } = useStdout();
|
|
8
8
|
const width = Math.max(40, stdout?.columns ?? 60);
|
|
9
|
+
const provider = currentProviderLabel();
|
|
9
10
|
const [models, setModels] = useState([]);
|
|
10
11
|
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
11
13
|
const [idx, setIdx] = useState(0);
|
|
12
14
|
const [filter, setFilter] = useState('');
|
|
13
15
|
useEffect(() => {
|
|
14
|
-
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
setLoading(true);
|
|
18
|
+
setError(null);
|
|
19
|
+
fetchModels()
|
|
20
|
+
.then((m) => {
|
|
21
|
+
if (cancelled)
|
|
22
|
+
return;
|
|
15
23
|
setModels(m);
|
|
16
24
|
const found = m.findIndex((x) => x.id === current);
|
|
17
25
|
setIdx(found >= 0 ? found : 0);
|
|
18
26
|
setLoading(false);
|
|
27
|
+
})
|
|
28
|
+
.catch((err) => {
|
|
29
|
+
if (cancelled)
|
|
30
|
+
return;
|
|
31
|
+
setError(err.message);
|
|
32
|
+
setLoading(false);
|
|
19
33
|
});
|
|
34
|
+
return () => {
|
|
35
|
+
cancelled = true;
|
|
36
|
+
};
|
|
20
37
|
}, [current]);
|
|
21
38
|
const filtered = filter
|
|
22
39
|
? models.filter((m) => m.id.toLowerCase().includes(filter.toLowerCase()))
|
|
@@ -24,9 +41,9 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
24
41
|
useInput((input, key) => {
|
|
25
42
|
if (key.escape)
|
|
26
43
|
return onCancel();
|
|
27
|
-
if (key.upArrow)
|
|
44
|
+
if (key.upArrow && filtered.length)
|
|
28
45
|
setIdx((i) => (i - 1 + filtered.length) % filtered.length);
|
|
29
|
-
if (key.downArrow)
|
|
46
|
+
if (key.downArrow && filtered.length)
|
|
30
47
|
setIdx((i) => (i + 1) % filtered.length);
|
|
31
48
|
if (key.return && filtered[idx])
|
|
32
49
|
onSelect(filtered[idx].id);
|
|
@@ -40,18 +57,26 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
40
57
|
});
|
|
41
58
|
const inner = width - 4;
|
|
42
59
|
const maxVisible = 12;
|
|
43
|
-
const start = Math.max(0, Math.min(idx - 6, filtered.length - maxVisible));
|
|
60
|
+
const start = Math.max(0, Math.min(idx - 6, Math.max(0, filtered.length - maxVisible)));
|
|
44
61
|
const visible = filtered.slice(start, start + maxVisible);
|
|
45
|
-
const
|
|
46
|
-
const topPad = Math.max(1, inner -
|
|
47
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children:
|
|
62
|
+
const label = filter ? `filter: ${filter}` : `${provider} models`;
|
|
63
|
+
const topPad = Math.max(1, inner - label.length - 4);
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), loading ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["fetching ", provider, " catalog\u2026"] })] })) : error ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.error, children: ["\u2717 ", error.slice(0, inner - 6)] })] })) : visible.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.error, children: "no matches" })] })) : (visible.map((m, i) => {
|
|
48
65
|
const realIdx = start + i;
|
|
49
66
|
const active = realIdx === idx;
|
|
50
67
|
const isCurrent = m.id === current;
|
|
51
|
-
const ctx = m.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
const ctx = m.contextLength
|
|
69
|
+
? m.contextLength >= 1000
|
|
70
|
+
? `${Math.round(m.contextLength / 1000)}k`
|
|
71
|
+
: `${m.contextLength}`
|
|
72
|
+
: '';
|
|
73
|
+
const freeTag = m.isFree ? 'FREE' : '';
|
|
74
|
+
// Right side: ctx + free badge
|
|
75
|
+
const right = [ctx, freeTag].filter(Boolean).join(' · ');
|
|
76
|
+
const label0 = m.id.length > inner - 10
|
|
77
|
+
? m.id.slice(0, inner - 13) + '…'
|
|
78
|
+
: m.id;
|
|
79
|
+
const pad = Math.max(1, inner - label0.length - right.length - 4);
|
|
80
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: label0 }), _jsx(Text, { color: theme.muted, children: ' '.repeat(pad) }), _jsx(Text, { color: theme.muted, children: right }), isCurrent && _jsx(Text, { color: theme.accent, children: " \u2713" })] }, m.id));
|
|
56
81
|
})), _jsx(Text, { children: _jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }) }), _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 type to filter \u00B7 \u21B5 select \u00B7 esc cancel" })] }));
|
|
57
82
|
};
|