blyuecode 0.1.16 → 0.2.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
+ }
@@ -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);
@@ -92,7 +92,9 @@ export const Settings = ({ onClose, onModelChange }) => {
92
92
  const inner = width - 4;
93
93
  const top = (label) => (_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(Math.max(1, inner - label.length - 3)) + '╮' })] }));
94
94
  const bot = (_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }));
95
- const row = (content) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
95
+ // ⚠️ Changed from <Text> to <Box flexDirection="row"> — callers pass a
96
+ // <Box> containing a prompt + <KeyInput>, and Ink forbids Box-in-Text.
97
+ const row = (content) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
96
98
  // ---- Main screen ----
97
99
  if (screen === 'main') {
98
100
  const baseUrlIsLocal = (cfg.baseUrl || '').includes('localhost') ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.1.16",
3
+ "version": "0.2.0",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",