@hncode/hncode 0.5.2 → 0.5.5

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.
Files changed (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
package/src/mcp.js CHANGED
@@ -1,384 +1,419 @@
1
- // MCP client — connect to Model Context Protocol servers and expose their tools.
2
- //
3
- // The Model Context Protocol is an open JSON-RPC 2.0 standard. A server speaks it
4
- // over stdio (a child process) or HTTP. This module implements the CLIENT side so
5
- // an hncode session can use tools from servers the user configures.
6
- //
7
- // Config (`~/.hncode/mcp.json`, or a per-project `<workspace>/.hncode/mcp.json`):
8
- //
9
- // {
10
- // "mcpServers": {
11
- // "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
12
- // "remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ..." } }
13
- // }
14
- // }
15
- //
16
- // Lifecycle, per the spec:
17
- // 1. start the transport
18
- // 2. `initialize` (protocolVersion + clientInfo) -> capabilities
19
- // 3. `notifications/initialized`
20
- // 4. `tools/list` -> tool definitions
21
- // 5. `tools/call` -> per invocation
22
- //
23
- // Discovered tools are registered through plugin.registerTool() so they reach the
24
- // model with no changes to the toolbelt, the agent loop, or the LLM adapters.
25
-
26
- import fs from 'node:fs';
27
- import os from 'node:os';
28
- import path from 'node:path';
29
- import cp from 'node:child_process';
30
-
31
- // The protocol revision this client speaks. Servers negotiate down if needed.
32
- export const PROTOCOL_VERSION = '2024-11-05';
33
- export const CLIENT_INFO = { name: 'hncode', version: '0.3.1' };
34
-
35
- // How long a server gets to complete the initialize + tools/list handshake. This
36
- // runs BEFORE the TUI opens, so it is deliberately short: a wedged server should
37
- // cost a few seconds, not the 30s a tool call is allowed.
38
- export const CONNECT_TIMEOUT_MS = 8_000;
39
-
40
- // Live connections made at startup, shared by every consumer of the MCP client
41
- // (the CLI startup and the TUI's /mcp). Kept in this module rather than passed
42
- // around so the status view can always see the real state.
43
- let liveConnections = [];
44
- export function setLiveConnections(conns) { liveConnections = conns || []; }
45
- export function getLiveConnections() { return liveConnections; }
46
-
47
- export function globalMcpFile() {
48
- return process.env.HNCODE_MCP_FILE || path.join(os.homedir(), '.hncode', 'mcp.json');
49
- }
50
- export function projectMcpFile(workspace) {
51
- return path.join(workspace || process.cwd(), '.hncode', 'mcp.json');
52
- }
53
-
54
- // Merge global + project server definitions (project wins on a name clash).
55
- // Returns { servers: {name: def}, errors: [string] }. Never throws.
56
- export function loadMcpConfig(workspace) {
57
- const out = { servers: {}, errors: [] };
58
- const files = workspace ? [globalMcpFile(), projectMcpFile(workspace)] : [globalMcpFile()];
59
- const seen = new Set();
60
- for (const f of files) {
61
- const abs = path.resolve(f);
62
- if (seen.has(abs)) continue;
63
- seen.add(abs);
64
- let text;
65
- try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
66
- let doc;
67
- try { doc = JSON.parse(text); } catch (e) { out.errors.push(`${abs}: invalid JSON (${e.message})`); continue; }
68
- const servers = (doc && doc.mcpServers) || {};
69
- for (const [name, def] of Object.entries(servers)) {
70
- if (!def || typeof def !== 'object') { out.errors.push(`${abs}: server "${name}" must be an object`); continue; }
71
- if (!def.command && !def.url) { out.errors.push(`${abs}: server "${name}" needs "command" or "url"`); continue; }
72
- out.servers[name] = { ...def, _source: abs };
73
- }
74
- }
75
- return out;
76
- }
77
-
78
- // ---- JSON-RPC framing -------------------------------------------------------
79
-
80
- // A stdio connection: newline-delimited JSON-RPC over the child's stdin/stdout.
81
- // MCP's stdio transport is line-delimited (NOT Content-Length framed — that is
82
- // the LSP convention, and mixing them up is the usual way this breaks).
83
- export function createStdioTransport(command, args, opts = {}) {
84
- const child = cp.spawn(command, args || [], {
85
- stdio: ['pipe', 'pipe', 'pipe'],
86
- env: { ...process.env, ...(opts.env || {}) },
87
- cwd: opts.cwd || process.cwd(),
88
- windowsHide: true,
89
- // npm-installed shims are `.cmd` on Windows and need a shell to run.
90
- shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(command),
91
- });
92
- const state = {
93
- child,
94
- buffer: '',
95
- nextId: 1,
96
- pending: new Map(), // id -> { resolve, reject, timer }
97
- closed: false,
98
- stderr: '',
99
- handlers: [],
100
- };
101
- child.stdout.on('data', (d) => {
102
- state.buffer += d.toString('utf8');
103
- let i;
104
- while ((i = state.buffer.indexOf('\n')) >= 0) {
105
- const line = state.buffer.slice(0, i).trim();
106
- state.buffer = state.buffer.slice(i + 1);
107
- if (!line) continue;
108
- let msg;
109
- try { msg = JSON.parse(line); } catch { continue; } // ignore non-JSON noise
110
- handleMessage(state, msg);
111
- }
112
- });
113
- child.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString('utf8')).slice(-4000); });
114
- child.on('error', (e) => failAll(state, `server process error: ${e.message}`));
115
- child.on('close', (code) => { state.closed = true; failAll(state, `server exited (code ${code})`); });
116
- return state;
117
- }
118
-
119
- function handleMessage(state, msg) {
120
- if (msg.id != null && state.pending.has(msg.id)) {
121
- const p = state.pending.get(msg.id);
122
- state.pending.delete(msg.id);
123
- clearTimeout(p.timer);
124
- if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
125
- else p.resolve(msg.result);
126
- return;
127
- }
128
- // A server-initiated REQUEST (e.g. sampling). Answer politely so the server
129
- // does not hang; hncode does not implement sampling, so it is "not supported".
130
- if (msg.method && msg.id != null) {
131
- try {
132
- writeMessage(state, { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `method not supported: ${msg.method}` } });
133
- } catch { /* transport closed */ }
134
- return;
135
- }
136
- for (const h of state.handlers) { try { h(msg); } catch { /* a notification handler must not throw */ } }
137
- }
138
-
139
- function failAll(state, reason) {
140
- for (const [, p] of state.pending) { clearTimeout(p.timer); p.reject(new Error(reason)); }
141
- state.pending.clear();
142
- }
143
-
144
- function writeMessage(state, msg) {
145
- if (state.closed || !state.child.stdin || state.child.stdin.destroyed) throw new Error('transport closed');
146
- state.child.stdin.write(JSON.stringify(msg) + '\n');
147
- }
148
-
149
- // Send a request and await its reply. `timeoutMs` guards a wedged server.
150
- export function request(state, method, params, timeoutMs = 30_000) {
151
- const id = state.nextId++;
152
- return new Promise((resolve, reject) => {
153
- const timer = setTimeout(() => {
154
- state.pending.delete(id);
155
- reject(new Error(`${method} timed out after ${timeoutMs}ms`));
156
- }, timeoutMs);
157
- state.pending.set(id, { resolve, reject, timer });
158
- try { writeMessage(state, { jsonrpc: '2.0', id, method, params: params || {} }); }
159
- catch (e) { state.pending.delete(id); clearTimeout(timer); reject(e); }
160
- });
161
- }
162
-
163
- function notify(state, method, params) {
164
- try { writeMessage(state, { jsonrpc: '2.0', method, params: params || {} }); } catch { /* best-effort */ }
165
- }
166
-
167
- // ---- HTTP transport ---------------------------------------------------------
168
-
169
- // Streamable-HTTP transport: one POST per JSON-RPC message. The server may reply
170
- // with JSON or an SSE stream; both are parsed here.
171
- export function createHttpTransport(url, headers) {
172
- return {
173
- kind: 'http',
174
- url,
175
- headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(headers || {}) },
176
- sessionId: null,
177
- nextId: 1,
178
- };
179
- }
180
-
181
- export async function httpRequest(state, method, params, timeoutMs = 30_000) {
182
- const id = state.nextId++;
183
- const ctrl = new AbortController();
184
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
185
- try {
186
- const res = await fetch(state.url, {
187
- method: 'POST',
188
- headers: state.sessionId ? { ...state.headers, 'mcp-session-id': state.sessionId } : state.headers,
189
- body: JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }),
190
- signal: ctrl.signal,
191
- });
192
- const sid = res.headers.get('mcp-session-id');
193
- if (sid) state.sessionId = sid;
194
- if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
195
- const ct = (res.headers.get('content-type') || '').toLowerCase();
196
- if (ct.includes('text/event-stream')) {
197
- const msg = parseSseForId(await res.text(), id);
198
- if (!msg) throw new Error('no response message in SSE stream');
199
- if (msg.error) throw new Error(msg.error.message || JSON.stringify(msg.error));
200
- return msg.result;
201
- }
202
- const json = await res.json();
203
- if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
204
- return json.result;
205
- } finally {
206
- clearTimeout(timer);
207
- }
208
- }
209
-
210
- // Pull the JSON-RPC reply for `id` out of an SSE body.
211
- export function parseSseForId(body, id) {
212
- for (const line of String(body || '').split('\n')) {
213
- const l = line.trim();
214
- if (!l.startsWith('data:')) continue;
215
- const payload = l.slice(5).trim();
216
- if (!payload || payload === '[DONE]') continue;
217
- try {
218
- const msg = JSON.parse(payload);
219
- if (msg && msg.id === id) return msg;
220
- } catch { /* not JSON: skip */ }
221
- }
222
- return null;
223
- }
224
-
225
- // ---- connection + tool discovery -------------------------------------------
226
-
227
- // Connect to one server, handshake, and list its tools.
228
- // Returns { ok, tools, error, serverInfo, transport, isHttp } — never throws.
229
- export async function connectServer(name, def, opts = {}) {
230
- // CONNECT_TIMEOUT (not the 30s tool-call timeout): this runs during startup, so
231
- // a wedged server must not hold the session hostage. 8s is generous for a
232
- // handshake while keeping a dead server's cost to single-digit seconds.
233
- const timeoutMs = opts.timeoutMs || CONNECT_TIMEOUT_MS;
234
- let transport;
235
- const isHttp = !!def.url;
236
- try {
237
- transport = isHttp
238
- ? createHttpTransport(def.url, def.headers)
239
- : createStdioTransport(def.command, def.args, { env: def.env, cwd: def.cwd });
240
- } catch (e) {
241
- return { ok: false, tools: [], error: `could not start "${name}": ${e.message}` };
242
- }
243
-
244
- const send = (method, params) => (isHttp
245
- ? httpRequest(transport, method, params, timeoutMs)
246
- : request(transport, method, params, timeoutMs));
247
-
248
- try {
249
- const init = await send('initialize', {
250
- protocolVersion: PROTOCOL_VERSION,
251
- capabilities: { tools: {} },
252
- clientInfo: CLIENT_INFO,
253
- });
254
- if (!isHttp) notify(transport, 'notifications/initialized', {});
255
- const list = await send('tools/list', {});
256
- const tools = Array.isArray(list && list.tools) ? list.tools : [];
257
- return { ok: true, tools, error: null, serverInfo: (init && init.serverInfo) || {}, transport, isHttp };
258
- } catch (e) {
259
- try { if (!isHttp) transport.child.kill(); } catch { /* already gone */ }
260
- return { ok: false, tools: [], error: `"${name}": ${e.message}` };
261
- }
262
- }
263
-
264
- // Call one tool on a connected server. Returns { ok, text, error, raw }.
265
- export async function callTool(transport, isHttp, toolName, args, timeoutMs = 60_000) {
266
- try {
267
- const result = isHttp
268
- ? await httpRequest(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs)
269
- : await request(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs);
270
- return { ok: true, text: flattenContent(result), error: null, raw: result };
271
- } catch (e) {
272
- return { ok: false, text: '', error: e.message, raw: null };
273
- }
274
- }
275
-
276
- // MCP tool results carry a `content` array of typed blocks. Flatten to text for
277
- // the model; non-text blocks are described rather than dropped silently.
278
- export function flattenContent(result) {
279
- if (!result) return '';
280
- if (typeof result === 'string') return result;
281
- const blocks = Array.isArray(result.content) ? result.content : [];
282
- const parts = [];
283
- for (const b of blocks) {
284
- if (!b || typeof b !== 'object') continue;
285
- if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
286
- else if (b.type === 'resource' && b.resource) parts.push(`[resource ${b.resource.uri || ''}]`);
287
- else if (b.type === 'image') parts.push(`[image ${b.mimeType || ''}]`);
288
- else parts.push(`[${b.type || 'unknown'} block]`);
289
- }
290
- if (!parts.length && typeof result.text === 'string') parts.push(result.text);
291
- return parts.join('\n');
292
- }
293
-
294
- // A tool name safe for every provider's function-name rules and for matching
295
- // back to its server + original name. Providers want [A-Za-z0-9_-], so
296
- // "server.tool" becomes "mcp__server__tool".
297
- export function mcpToolName(server, tool) {
298
- const clean = (s) => String(s || '').replace(/[^A-Za-z0-9_-]/g, '_');
299
- return `mcp__${clean(server)}__${clean(tool)}`;
300
- }
301
-
302
- // Convert an MCP tool definition into an hncode tool spec whose execute() calls
303
- // back into the server.
304
- export function toHncodeSpec(server, tool, transport, isHttp) {
305
- return {
306
- name: mcpToolName(server, tool.name),
307
- description: (tool.description || `MCP tool ${tool.name} from ${server}.`).slice(0, 4000),
308
- parameters: tool.inputSchema && typeof tool.inputSchema === 'object'
309
- ? tool.inputSchema
310
- : { type: 'object', properties: {} },
311
- _mcp: { server, tool: tool.name },
312
- async execute(args) {
313
- const r = await callTool(transport, isHttp, tool.name, args);
314
- if (!r.ok) return `Error calling ${server}/${tool.name}: ${r.error}`;
315
- // MCP signals tool-level failure with isError, not a JSON-RPC error.
316
- if (r.raw && r.raw.isError) return `Error from ${server}/${tool.name}: ${r.text}`;
317
- return r.text || '(no output)';
318
- },
319
- };
320
- }
321
-
322
- // Connect every configured server and register its tools through the plugin API.
323
- // Returns { servers: [...], toolCount }.
324
- export async function connectAll(config, registerTool, opts = {}) {
325
- const only = opts.only;
326
- const entries = Object.entries((config && config.servers) || {}).filter(([name]) => !only || only.includes(name));
327
- // Connect in PARALLEL. This runs on the startup path, before the TUI opens, so
328
- // a serial loop meant one slow/wedged server delayed every later one — N dead
329
- // servers cost N × timeout before the user saw anything. Runs concurrently, the
330
- // whole set costs the SLOWEST single server instead of their sum.
331
- const results = await Promise.all(entries.map(async ([name, def]) => {
332
- const conn = await connectServer(name, def, opts);
333
- if (!conn.ok) return { name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} };
334
- let registered = 0;
335
- let err = null;
336
- for (const tool of conn.tools) {
337
- if (!tool || !tool.name) continue;
338
- try {
339
- registerTool(toHncodeSpec(name, tool, conn.transport, conn.isHttp));
340
- registered++;
341
- } catch (e) {
342
- // A name clash with a built-in is reported, not fatal: keep the rest.
343
- err = err || `tool "${tool.name}": ${e.message}`;
344
- }
345
- }
346
- return { name, ok: true, toolCount: registered, error: err, serverInfo: conn.serverInfo, transport: conn.transport, isHttp: conn.isHttp };
347
- }));
348
- const toolCount = results.reduce((n, r) => n + (r.toolCount || 0), 0);
349
- return { servers: results, toolCount };
350
- }
351
- // Disconnect every connected server (kills stdio children). HTTP needs nothing.
352
-
353
- // Disconnect every connected server (kills stdio children). HTTP needs nothing.
354
- export function disconnectAll(connections) {
355
- for (const c of connections || []) {
356
- if (c && c.transport && c.transport.child) {
357
- try { c.transport.child.kill(); } catch { /* already gone */ }
358
- }
359
- }
360
- }
361
-
362
- // Human-readable status for /mcp.
363
- export function describeServers(config, connections) {
364
- const names = Object.keys((config && config.servers) || {});
365
- if (!names.length) {
366
- return ['No MCP servers configured.', '', `Config file: ${globalMcpFile()}`,
367
- 'Add one with /mcp-config add <name> <command|url> [args...]'];
368
- }
369
- const byName = new Map((connections || []).map((c) => [c.name, c]));
370
- const lines = [`MCP servers (${names.length}) — config: ${globalMcpFile()}`, ''];
371
- for (const n of names) {
372
- const def = config.servers[n];
373
- const conn = byName.get(n);
374
- const target = def.url ? def.url : `${def.command || ''} ${(def.args || []).join(' ')}`.trim();
375
- let status;
376
- if (!conn) status = 'not connected';
377
- else if (!conn.ok) status = `FAILED: ${conn.error}`;
378
- else status = `connected — ${conn.toolCount} tool(s)${conn.serverInfo && conn.serverInfo.name ? ` (${conn.serverInfo.name})` : ''}`;
379
- lines.push(` ${n}: ${status}`);
380
- lines.push(` ${target}`);
381
- if (conn && conn.error) lines.push(` ! ${conn.error}`);
382
- }
383
- return lines;
384
- }
1
+ // MCP client — connect to Model Context Protocol servers and expose their tools.
2
+ //
3
+ // The Model Context Protocol is an open JSON-RPC 2.0 standard. A server speaks it
4
+ // over stdio (a child process) or HTTP. This module implements the CLIENT side so
5
+ // an hncode session can use tools from servers the user configures.
6
+ //
7
+ // Config (`~/.hncode/mcp.json`, or a per-project `<workspace>/.hncode/mcp.json`):
8
+ //
9
+ // {
10
+ // "mcpServers": {
11
+ // "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
12
+ // "remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ..." } }
13
+ // }
14
+ // }
15
+ //
16
+ // Lifecycle, per the spec:
17
+ // 1. start the transport
18
+ // 2. `initialize` (protocolVersion + clientInfo) -> capabilities
19
+ // 3. `notifications/initialized`
20
+ // 4. `tools/list` -> tool definitions
21
+ // 5. `tools/call` -> per invocation
22
+ //
23
+ // Discovered tools are registered through plugin.registerTool() so they reach the
24
+ // model with no changes to the toolbelt, the agent loop, or the LLM adapters.
25
+
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import cp from 'node:child_process';
30
+
31
+ // The protocol revision this client speaks. Servers negotiate down if needed.
32
+ export const PROTOCOL_VERSION = '2024-11-05';
33
+ export const CLIENT_INFO = { name: 'hncode', version: '0.3.1' };
34
+
35
+ // How long a server gets to complete the initialize + tools/list handshake. This
36
+ // runs BEFORE the TUI opens, so it is deliberately short: a wedged server should
37
+ // cost a few seconds, not the 30s a tool call is allowed.
38
+ export const CONNECT_TIMEOUT_MS = 8_000;
39
+
40
+ // Live connections made at startup, shared by every consumer of the MCP client
41
+ // (the CLI startup and the TUI's /mcp). Kept in this module rather than passed
42
+ // around so the status view can always see the real state.
43
+ let liveConnections = [];
44
+ export function setLiveConnections(conns) { liveConnections = conns || []; }
45
+ export function getLiveConnections() { return liveConnections; }
46
+
47
+ export function globalMcpFile() {
48
+ return process.env.HNCODE_MCP_FILE || path.join(os.homedir(), '.hncode', 'mcp.json');
49
+ }
50
+ export function projectMcpFile(workspace) {
51
+ return path.join(workspace || process.cwd(), '.hncode', 'mcp.json');
52
+ }
53
+
54
+ // Merge global + project server definitions (project wins on a name clash).
55
+ // Returns { servers: {name: def}, errors: [string] }. Never throws.
56
+ //
57
+ // Project-scope defs are tagged `_project: true`. They spawn whatever command the
58
+ // REPO's .hncode/mcp.json names, so a cloned repository would otherwise get
59
+ // arbitrary code execution the moment hncode opens the workspace; connectAll
60
+ // refuses them without an explicit approval (see the gate there).
61
+ export function loadMcpConfig(workspace) {
62
+ const out = { servers: {}, errors: [] };
63
+ const projectFile = workspace ? path.resolve(projectMcpFile(workspace)) : null;
64
+ const files = workspace ? [globalMcpFile(), projectMcpFile(workspace)] : [globalMcpFile()];
65
+ const seen = new Set();
66
+ for (const f of files) {
67
+ const abs = path.resolve(f);
68
+ const isProject = projectFile != null && abs === projectFile;
69
+ if (seen.has(abs)) continue;
70
+ seen.add(abs);
71
+ let text;
72
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
73
+ let doc;
74
+ try { doc = JSON.parse(text); } catch (e) { out.errors.push(`${abs}: invalid JSON (${e.message})`); continue; }
75
+ const servers = (doc && doc.mcpServers) || {};
76
+ for (const [name, def] of Object.entries(servers)) {
77
+ if (!def || typeof def !== 'object') { out.errors.push(`${abs}: server "${name}" must be an object`); continue; }
78
+ if (!def.command && !def.url) { out.errors.push(`${abs}: server "${name}" needs "command" or "url"`); continue; }
79
+ out.servers[name] = { ...def, _source: abs, ...(isProject ? { _project: true } : {}) };
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // ---- JSON-RPC framing -------------------------------------------------------
86
+
87
+ // A stdio connection: newline-delimited JSON-RPC over the child's stdin/stdout.
88
+ // MCP's stdio transport is line-delimited (NOT Content-Length framed — that is
89
+ // the LSP convention, and mixing them up is the usual way this breaks).
90
+ // Quote one argument for cmd.exe. With `shell: true` the words are re-parsed by
91
+ // cmd, so an argument holding `&`/`|`/`<`/`>`/`^` would run a SECOND command of
92
+ // its own. Wrapping in quotes neutralizes the metacharacters; embedded quotes
93
+ // double, which is cmd's own convention.
94
+ function cmdQuoteArg(a) {
95
+ const s = String(a);
96
+ if (s === '') return '""';
97
+ if (!/[\s&|<>()^"%!]/.test(s)) return s;
98
+ return `"${s.replace(/"/g, '""')}"`;
99
+ }
100
+
101
+ export function createStdioTransport(command, args, opts = {}) {
102
+ const needsShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
103
+ const child = cp.spawn(command, needsShell ? (args || []).map(cmdQuoteArg) : (args || []), {
104
+ stdio: ['pipe', 'pipe', 'pipe'],
105
+ env: { ...process.env, ...(opts.env || {}) },
106
+ cwd: opts.cwd || process.cwd(),
107
+ windowsHide: true,
108
+ // npm-installed shims are `.cmd` on Windows and need a shell to run.
109
+ shell: needsShell,
110
+ });
111
+ const state = {
112
+ child,
113
+ buffer: '',
114
+ nextId: 1,
115
+ pending: new Map(), // id -> { resolve, reject, timer }
116
+ closed: false,
117
+ stderr: '',
118
+ handlers: [],
119
+ };
120
+ child.stdout.on('data', (d) => {
121
+ state.buffer += d.toString('utf8');
122
+ let i;
123
+ while ((i = state.buffer.indexOf('\n')) >= 0) {
124
+ const line = state.buffer.slice(0, i).trim();
125
+ state.buffer = state.buffer.slice(i + 1);
126
+ if (!line) continue;
127
+ let msg;
128
+ try { msg = JSON.parse(line); } catch { continue; } // ignore non-JSON noise
129
+ handleMessage(state, msg);
130
+ }
131
+ });
132
+ child.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString('utf8')).slice(-4000); });
133
+ child.on('error', (e) => failAll(state, `server process error: ${e.message}`));
134
+ child.on('close', (code) => { state.closed = true; failAll(state, `server exited (code ${code})`); });
135
+ return state;
136
+ }
137
+
138
+ function handleMessage(state, msg) {
139
+ if (msg.id != null && state.pending.has(msg.id)) {
140
+ const p = state.pending.get(msg.id);
141
+ state.pending.delete(msg.id);
142
+ clearTimeout(p.timer);
143
+ if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
144
+ else p.resolve(msg.result);
145
+ return;
146
+ }
147
+ // A server-initiated REQUEST (e.g. sampling). Answer politely so the server
148
+ // does not hang; hncode does not implement sampling, so it is "not supported".
149
+ if (msg.method && msg.id != null) {
150
+ try {
151
+ writeMessage(state, { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `method not supported: ${msg.method}` } });
152
+ } catch { /* transport closed */ }
153
+ return;
154
+ }
155
+ for (const h of state.handlers) { try { h(msg); } catch { /* a notification handler must not throw */ } }
156
+ }
157
+
158
+ function failAll(state, reason) {
159
+ for (const [, p] of state.pending) { clearTimeout(p.timer); p.reject(new Error(reason)); }
160
+ state.pending.clear();
161
+ }
162
+
163
+ function writeMessage(state, msg) {
164
+ if (state.closed || !state.child.stdin || state.child.stdin.destroyed) throw new Error('transport closed');
165
+ state.child.stdin.write(JSON.stringify(msg) + '\n');
166
+ }
167
+
168
+ // Send a request and await its reply. `timeoutMs` guards a wedged server.
169
+ export function request(state, method, params, timeoutMs = 30_000) {
170
+ const id = state.nextId++;
171
+ return new Promise((resolve, reject) => {
172
+ const timer = setTimeout(() => {
173
+ state.pending.delete(id);
174
+ reject(new Error(`${method} timed out after ${timeoutMs}ms`));
175
+ }, timeoutMs);
176
+ state.pending.set(id, { resolve, reject, timer });
177
+ try { writeMessage(state, { jsonrpc: '2.0', id, method, params: params || {} }); }
178
+ catch (e) { state.pending.delete(id); clearTimeout(timer); reject(e); }
179
+ });
180
+ }
181
+
182
+ function notify(state, method, params) {
183
+ try { writeMessage(state, { jsonrpc: '2.0', method, params: params || {} }); } catch { /* best-effort */ }
184
+ }
185
+
186
+ // ---- HTTP transport ---------------------------------------------------------
187
+
188
+ // Streamable-HTTP transport: one POST per JSON-RPC message. The server may reply
189
+ // with JSON or an SSE stream; both are parsed here.
190
+ export function createHttpTransport(url, headers) {
191
+ return {
192
+ kind: 'http',
193
+ url,
194
+ headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(headers || {}) },
195
+ sessionId: null,
196
+ nextId: 1,
197
+ };
198
+ }
199
+
200
+ export async function httpRequest(state, method, params, timeoutMs = 30_000) {
201
+ const id = state.nextId++;
202
+ const ctrl = new AbortController();
203
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
204
+ try {
205
+ const res = await fetch(state.url, {
206
+ method: 'POST',
207
+ headers: state.sessionId ? { ...state.headers, 'mcp-session-id': state.sessionId } : state.headers,
208
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }),
209
+ signal: ctrl.signal,
210
+ });
211
+ const sid = res.headers.get('mcp-session-id');
212
+ if (sid) state.sessionId = sid;
213
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
214
+ const ct = (res.headers.get('content-type') || '').toLowerCase();
215
+ if (ct.includes('text/event-stream')) {
216
+ const msg = parseSseForId(await res.text(), id);
217
+ if (!msg) throw new Error('no response message in SSE stream');
218
+ if (msg.error) throw new Error(msg.error.message || JSON.stringify(msg.error));
219
+ return msg.result;
220
+ }
221
+ const json = await res.json();
222
+ if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
223
+ return json.result;
224
+ } finally {
225
+ clearTimeout(timer);
226
+ }
227
+ }
228
+
229
+ // Pull the JSON-RPC reply for `id` out of an SSE body.
230
+ export function parseSseForId(body, id) {
231
+ for (const line of String(body || '').split('\n')) {
232
+ const l = line.trim();
233
+ if (!l.startsWith('data:')) continue;
234
+ const payload = l.slice(5).trim();
235
+ if (!payload || payload === '[DONE]') continue;
236
+ try {
237
+ const msg = JSON.parse(payload);
238
+ if (msg && msg.id === id) return msg;
239
+ } catch { /* not JSON: skip */ }
240
+ }
241
+ return null;
242
+ }
243
+
244
+ // ---- connection + tool discovery -------------------------------------------
245
+
246
+ // Connect to one server, handshake, and list its tools.
247
+ // Returns { ok, tools, error, serverInfo, transport, isHttp } — never throws.
248
+ export async function connectServer(name, def, opts = {}) {
249
+ // CONNECT_TIMEOUT (not the 30s tool-call timeout): this runs during startup, so
250
+ // a wedged server must not hold the session hostage. 8s is generous for a
251
+ // handshake while keeping a dead server's cost to single-digit seconds.
252
+ const timeoutMs = opts.timeoutMs || CONNECT_TIMEOUT_MS;
253
+ let transport;
254
+ const isHttp = !!def.url;
255
+ try {
256
+ transport = isHttp
257
+ ? createHttpTransport(def.url, def.headers)
258
+ : createStdioTransport(def.command, def.args, { env: def.env, cwd: def.cwd });
259
+ } catch (e) {
260
+ return { ok: false, tools: [], error: `could not start "${name}": ${e.message}` };
261
+ }
262
+
263
+ const send = (method, params) => (isHttp
264
+ ? httpRequest(transport, method, params, timeoutMs)
265
+ : request(transport, method, params, timeoutMs));
266
+
267
+ try {
268
+ const init = await send('initialize', {
269
+ protocolVersion: PROTOCOL_VERSION,
270
+ capabilities: { tools: {} },
271
+ clientInfo: CLIENT_INFO,
272
+ });
273
+ if (!isHttp) notify(transport, 'notifications/initialized', {});
274
+ const list = await send('tools/list', {});
275
+ const tools = Array.isArray(list && list.tools) ? list.tools : [];
276
+ return { ok: true, tools, error: null, serverInfo: (init && init.serverInfo) || {}, transport, isHttp };
277
+ } catch (e) {
278
+ try { if (!isHttp) transport.child.kill(); } catch { /* already gone */ }
279
+ return { ok: false, tools: [], error: `"${name}": ${e.message}` };
280
+ }
281
+ }
282
+
283
+ // Call one tool on a connected server. Returns { ok, text, error, raw }.
284
+ export async function callTool(transport, isHttp, toolName, args, timeoutMs = 60_000) {
285
+ try {
286
+ const result = isHttp
287
+ ? await httpRequest(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs)
288
+ : await request(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs);
289
+ return { ok: true, text: flattenContent(result), error: null, raw: result };
290
+ } catch (e) {
291
+ return { ok: false, text: '', error: e.message, raw: null };
292
+ }
293
+ }
294
+
295
+ // MCP tool results carry a `content` array of typed blocks. Flatten to text for
296
+ // the model; non-text blocks are described rather than dropped silently.
297
+ export function flattenContent(result) {
298
+ if (!result) return '';
299
+ if (typeof result === 'string') return result;
300
+ const blocks = Array.isArray(result.content) ? result.content : [];
301
+ const parts = [];
302
+ for (const b of blocks) {
303
+ if (!b || typeof b !== 'object') continue;
304
+ if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
305
+ else if (b.type === 'resource' && b.resource) parts.push(`[resource ${b.resource.uri || ''}]`);
306
+ else if (b.type === 'image') parts.push(`[image ${b.mimeType || ''}]`);
307
+ else parts.push(`[${b.type || 'unknown'} block]`);
308
+ }
309
+ if (!parts.length && typeof result.text === 'string') parts.push(result.text);
310
+ return parts.join('\n');
311
+ }
312
+
313
+ // A tool name safe for every provider's function-name rules and for matching
314
+ // back to its server + original name. Providers want [A-Za-z0-9_-], so
315
+ // "server.tool" becomes "mcp__server__tool".
316
+ export function mcpToolName(server, tool) {
317
+ const clean = (s) => String(s || '').replace(/[^A-Za-z0-9_-]/g, '_');
318
+ return `mcp__${clean(server)}__${clean(tool)}`;
319
+ }
320
+
321
+ // Convert an MCP tool definition into an hncode tool spec whose execute() calls
322
+ // back into the server.
323
+ export function toHncodeSpec(server, tool, transport, isHttp) {
324
+ return {
325
+ name: mcpToolName(server, tool.name),
326
+ description: (tool.description || `MCP tool ${tool.name} from ${server}.`).slice(0, 4000),
327
+ parameters: tool.inputSchema && typeof tool.inputSchema === 'object'
328
+ ? tool.inputSchema
329
+ : { type: 'object', properties: {} },
330
+ _mcp: { server, tool: tool.name },
331
+ async execute(args) {
332
+ const r = await callTool(transport, isHttp, tool.name, args);
333
+ if (!r.ok) return `Error calling ${server}/${tool.name}: ${r.error}`;
334
+ // MCP signals tool-level failure with isError, not a JSON-RPC error.
335
+ if (r.raw && r.raw.isError) return `Error from ${server}/${tool.name}: ${r.text}`;
336
+ return r.text || '(no output)';
337
+ },
338
+ };
339
+ }
340
+
341
+ // Connect every configured server and register its tools through the plugin API.
342
+ // Returns { servers: [...], toolCount }.
343
+ export async function connectAll(config, registerTool, opts = {}) {
344
+ const only = opts.only;
345
+ const entries = Object.entries((config && config.servers) || {}).filter(([name]) => !only || only.includes(name));
346
+ // Connect in PARALLEL. This runs on the startup path, before the TUI opens, so
347
+ // a serial loop meant one slow/wedged server delayed every later one — N dead
348
+ // servers cost N × timeout before the user saw anything. Runs concurrently, the
349
+ // whole set costs the SLOWEST single server instead of their sum.
350
+ const results = await Promise.all(entries.map(async ([name, def]) => {
351
+ // PROJECT-SCOPE GATE. A def from the repo's own .hncode/mcp.json spawns an
352
+ // arbitrary command on open, so it never connects silently: it needs the
353
+ // HNCODE_ALLOW_PROJECT_MCP=1 escape hatch, or an async approver (the TUI
354
+ // wires one to its approval prompt). Everything else still connects.
355
+ if (def && def._project && process.env.HNCODE_ALLOW_PROJECT_MCP !== '1') {
356
+ if (typeof opts.approveProjectServer !== 'function') {
357
+ return { name, ok: false, toolCount: 0, skipped: true,
358
+ error: 'project-scope server needs approval — set HNCODE_ALLOW_PROJECT_MCP=1 to allow it' };
359
+ }
360
+ let approved = false;
361
+ try { approved = await opts.approveProjectServer(name, def); } catch { approved = false; }
362
+ if (!approved) {
363
+ return { name, ok: false, toolCount: 0, skipped: true,
364
+ error: 'not approved — the project-scope server was not started' };
365
+ }
366
+ }
367
+ const conn = await connectServer(name, def, opts);
368
+ if (!conn.ok) return { name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} };
369
+ let registered = 0;
370
+ let err = null;
371
+ for (const tool of conn.tools) {
372
+ if (!tool || !tool.name) continue;
373
+ try {
374
+ registerTool(toHncodeSpec(name, tool, conn.transport, conn.isHttp));
375
+ registered++;
376
+ } catch (e) {
377
+ // A name clash with a built-in is reported, not fatal: keep the rest.
378
+ err = err || `tool "${tool.name}": ${e.message}`;
379
+ }
380
+ }
381
+ return { name, ok: true, toolCount: registered, error: err, serverInfo: conn.serverInfo, transport: conn.transport, isHttp: conn.isHttp };
382
+ }));
383
+ const toolCount = results.reduce((n, r) => n + (r.toolCount || 0), 0);
384
+ return { servers: results, toolCount };
385
+ }
386
+ // Disconnect every connected server (kills stdio children). HTTP needs nothing.
387
+
388
+ // Disconnect every connected server (kills stdio children). HTTP needs nothing.
389
+ export function disconnectAll(connections) {
390
+ for (const c of connections || []) {
391
+ if (c && c.transport && c.transport.child) {
392
+ try { c.transport.child.kill(); } catch { /* already gone */ }
393
+ }
394
+ }
395
+ }
396
+
397
+ // Human-readable status for /mcp.
398
+ export function describeServers(config, connections) {
399
+ const names = Object.keys((config && config.servers) || {});
400
+ if (!names.length) {
401
+ return ['No MCP servers configured.', '', `Config file: ${globalMcpFile()}`,
402
+ 'Add one with /mcp-config add <name> <command|url> [args...]'];
403
+ }
404
+ const byName = new Map((connections || []).map((c) => [c.name, c]));
405
+ const lines = [`MCP servers (${names.length}) — config: ${globalMcpFile()}`, ''];
406
+ for (const n of names) {
407
+ const def = config.servers[n];
408
+ const conn = byName.get(n);
409
+ const target = def.url ? def.url : `${def.command || ''} ${(def.args || []).join(' ')}`.trim();
410
+ let status;
411
+ if (!conn) status = 'not connected';
412
+ else if (!conn.ok) status = `FAILED: ${conn.error}`;
413
+ else status = `connected — ${conn.toolCount} tool(s)${conn.serverInfo && conn.serverInfo.name ? ` (${conn.serverInfo.name})` : ''}`;
414
+ lines.push(` ${n}: ${status}`);
415
+ lines.push(` ${target}`);
416
+ if (conn && conn.error) lines.push(` ! ${conn.error}`);
417
+ }
418
+ return lines;
419
+ }