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.
@@ -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
+ }
@@ -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
- return hasN8n() ? [...BASE_TOOLS, ...N8N_TOOLS] : [...BASE_TOOLS];
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 === 'p')
104
+ if (key.ctrl && inputChar === 'l')
104
105
  setOverlay('model');
105
- if (key.ctrl && inputChar === 's')
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+P)'),
173
- mkItem('tool', ' /settings open settings (Ctrl+S)'),
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', `model → ${id}`)]);
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 }) })] }));
@@ -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 { fetchFreeModels } from '../core/freeModels.js';
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
- fetchFreeModels(process.env.OPENAI_API_KEY).then((m) => {
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 topLabel = filter ? `filter: ${filter}` : 'free models';
46
- const topPad = Math.max(1, inner - topLabel.length - 4);
47
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: topLabel }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), loading ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.muted, children: "loading catalog\u2026" })] })) : visible.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.error, children: "no matches" })] })) : (visible.map((m, i) => {
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.context >= 1000 ? `${Math.round(m.context / 1000)}k` : `${m.context}`;
52
- const label = m.id.length > inner - 10 ? m.id.slice(0, inner - 13) + '…' : m.id;
53
- const right = `${ctx}`;
54
- const pad = Math.max(1, inner - label.length - right.length - 4);
55
- 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: label }), _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));
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.1.17",
3
+ "version": "0.3.0",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",