@hncode/hncode 0.3.2 → 0.3.3
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/README.md +43 -0
- package/package.json +1 -1
- package/src/agent.js +75 -0
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +7 -0
- package/src/git.js +332 -0
- package/src/hooks.js +247 -0
- package/src/index.js +116 -16
- package/src/llm.js +14 -2
- package/src/mcp.js +376 -0
- package/src/plan.js +127 -0
- package/src/skills.js +150 -0
- package/src/subagent-types.js +1 -1
- package/src/tools/edit.js +17 -5
- package/src/tools/git.js +155 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +988 -100
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { Agent, SYSTEM_PROMPT } from './agent.js';
|
|
|
9
9
|
import { llmTools } from './tools/index.js';
|
|
10
10
|
import { setToolsList } from './llm.js';
|
|
11
11
|
import { localVersion } from './version.js';
|
|
12
|
+
import { EXIT, FORMATS, isValidFormat, buildResult, exitCodeFor, resultLine, createRunTracker, trackEvent, finalReply } from './ci.js';
|
|
12
13
|
|
|
13
14
|
// The version shown by `hncode -V` and the help banner. Read from package.json
|
|
14
15
|
// (via version.js) so it can never drift from the published version again.
|
|
@@ -29,7 +30,11 @@ Options:
|
|
|
29
30
|
--auto Never ask; everything runs automatically.
|
|
30
31
|
-m, --model <model> Model alias to use for this invocation.
|
|
31
32
|
-p, --prompt <prompt> Run one prompt non-interactively and print the reply.
|
|
32
|
-
--output-format <format>
|
|
33
|
+
--output-format <format> Prompt mode output: text (default), json, or stream-json.
|
|
34
|
+
json prints one result object; stream-json streams events then a result.
|
|
35
|
+
Exit code reflects the outcome: 0 ok, 1 failure, 2 config, 3 no answer, 4 interrupted.
|
|
36
|
+
--control Expose a local control socket (prompt/status/interrupt) while the TUI runs.
|
|
37
|
+
--control <path> Same, at an explicit socket path.
|
|
33
38
|
--plan Start in plan mode (research only, no writes).
|
|
34
39
|
--add-dir <dir> Add an additional workspace directory. Repeatable.
|
|
35
40
|
-h, --help Show help.
|
|
@@ -40,6 +45,12 @@ Commands:
|
|
|
40
45
|
doctor config [path] Validate a config.toml (default: merged config).
|
|
41
46
|
init Create ~/.hncode/config.toml with defaults.
|
|
42
47
|
export [id] Export a session to ~/.hncode/export/<id>.json.
|
|
48
|
+
control status Ask a running session (started with --control) for its status.
|
|
49
|
+
control prompt <text> Queue a prompt on a running session.
|
|
50
|
+
control interrupt Interrupt the running turn.
|
|
51
|
+
hooks list Show configured shell hooks.
|
|
52
|
+
skills list List installed skills.
|
|
53
|
+
mcp list Show configured MCP servers.
|
|
43
54
|
`;
|
|
44
55
|
|
|
45
56
|
function printHelp() { process.stdout.write(HELP); }
|
|
@@ -55,7 +66,7 @@ export function parseArgs(argv) {
|
|
|
55
66
|
if (eq >= 0) { key = a.slice(2, eq); val = a.slice(eq + 1); }
|
|
56
67
|
else { key = a.slice(2); val = null; }
|
|
57
68
|
if (key === 'resume') key = 'session'; // `--resume <id>` is an alias for `--session <id>`
|
|
58
|
-
if (['session'].includes(key) && val === null) { const nx = argv[i + 1]; out[key] = nx !== undefined && !nx.startsWith('-') ? (i++, nx) : true; }
|
|
69
|
+
if (['session', 'control'].includes(key) && val === null) { const nx = argv[i + 1]; out[key] = nx !== undefined && !nx.startsWith('-') ? (i++, nx) : true; }
|
|
59
70
|
else if (key === 'model' || key === 'prompt' || key === 'output-format' || key === 'add-dir') {
|
|
60
71
|
if (val === null) {
|
|
61
72
|
const nx = argv[i + 1];
|
|
@@ -177,14 +188,42 @@ const SUBCOMMANDS = {
|
|
|
177
188
|
'doctor': { 'config': cmdDoctorConfig },
|
|
178
189
|
'init': cmdInit,
|
|
179
190
|
'export': cmdExport,
|
|
191
|
+
'control': {
|
|
192
|
+
// Subcommands pass only positional[2] to their handler, so bind the op.
|
|
193
|
+
'status': (rest) => cmdControl('status', rest),
|
|
194
|
+
'prompt': (rest) => cmdControl('prompt', rest),
|
|
195
|
+
'interrupt': (rest) => cmdControl('interrupt', rest),
|
|
196
|
+
},
|
|
180
197
|
};
|
|
181
198
|
|
|
199
|
+
// ---- control (talk to a running --control session) --------------------------
|
|
200
|
+
// `hncode control status|prompt|interrupt` — the client half of the control
|
|
201
|
+
// socket the TUI opens with --control. Prints the JSON reply so a script can
|
|
202
|
+
// parse it; exits non-zero when the running session could not be reached.
|
|
203
|
+
async function cmdControl(sub, arg) {
|
|
204
|
+
const { sendControl } = await import('./ci.js');
|
|
205
|
+
const op = sub || 'status';
|
|
206
|
+
if (!['status', 'prompt', 'interrupt'].includes(op)) {
|
|
207
|
+
console.error(`unknown control op: ${op}. Use status | prompt <text> | interrupt.`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const req = { op };
|
|
211
|
+
if (op === 'prompt') {
|
|
212
|
+
const text = String(arg || '').trim();
|
|
213
|
+
if (!text) { console.error('control prompt requires text.'); return 1; }
|
|
214
|
+
req.text = text;
|
|
215
|
+
}
|
|
216
|
+
const res = await sendControl(req, { path: process.env.HNCODE_CONTROL_SOCKET || undefined });
|
|
217
|
+
console.log(JSON.stringify(res));
|
|
218
|
+
return res && res.ok ? 0 : 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
182
221
|
// ---- headless prompt mode ----
|
|
183
222
|
async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
184
223
|
cfg = resolveModelArg(cfg, modelArg);
|
|
185
224
|
if (!cfg.apiKey) {
|
|
186
225
|
console.error('hncode: no api_key configured. Set HNCODE_API_KEY or configure a provider first. Run `hncode doctor config` to check.');
|
|
187
|
-
return
|
|
226
|
+
return EXIT.CONFIG;
|
|
188
227
|
}
|
|
189
228
|
const messages = [];
|
|
190
229
|
if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
|
|
@@ -195,19 +234,28 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
|
195
234
|
if (personal) sysText += '\n\n' + personal;
|
|
196
235
|
messages.push({ role: 'system', content: sysText });
|
|
197
236
|
const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
|
|
237
|
+
const tracker = createRunTracker();
|
|
238
|
+
const startedAt = Date.now();
|
|
239
|
+
let fatal = null;
|
|
198
240
|
const agent = new Agent({
|
|
199
241
|
cfg,
|
|
200
242
|
messages,
|
|
201
243
|
// Uncapped: run until the model finishes (see agent.js).
|
|
202
244
|
onEvent: (e) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
245
|
+
trackEvent(tracker, e);
|
|
246
|
+
if (e.type === 'error') fatal = (e.error && e.error.message) || String(e.error);
|
|
247
|
+
if (format === 'stream-json' || format === 'json') {
|
|
248
|
+
// `json` mode keeps stdout clean for the final envelope, so events go to
|
|
249
|
+
// stderr there. `stream-json` is the streaming contract: one event/line.
|
|
250
|
+
if (format === 'stream-json') {
|
|
251
|
+
if (e.type === 'context') return; // TUI-only gauge; not part of the stream
|
|
252
|
+
const line = { type: e.type };
|
|
253
|
+
if (e.text) line.text = e.text;
|
|
254
|
+
if (e.name) line.name = e.name;
|
|
255
|
+
if (e.id) line.id = e.id;
|
|
256
|
+
if (e.content != null) line.content = e.content;
|
|
257
|
+
process.stdout.write(JSON.stringify(line) + '\n');
|
|
258
|
+
}
|
|
211
259
|
} else if (e.type === 'data') {
|
|
212
260
|
process.stdout.write(e.text);
|
|
213
261
|
}
|
|
@@ -216,8 +264,30 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
|
216
264
|
saved.messages = messages;
|
|
217
265
|
await agent.run();
|
|
218
266
|
sess.saveSession(saved);
|
|
219
|
-
|
|
220
|
-
|
|
267
|
+
|
|
268
|
+
const reply = finalReply(messages, tracker.text);
|
|
269
|
+
const result = buildResult({
|
|
270
|
+
reply,
|
|
271
|
+
toolCalls: tracker.toolCalls,
|
|
272
|
+
files: tracker.files,
|
|
273
|
+
error: fatal || (tracker.errors.length ? tracker.errors[tracker.errors.length - 1] : null),
|
|
274
|
+
stopped: !!agent.stopRequested,
|
|
275
|
+
durationMs: Date.now() - startedAt,
|
|
276
|
+
model: cfg.model,
|
|
277
|
+
sessionId: saved.id,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (format === 'json') {
|
|
281
|
+
// Exactly one JSON object on stdout, so `| jq` / `tail -1` works.
|
|
282
|
+
process.stdout.write(resultLine(result) + '\n');
|
|
283
|
+
} else if (format === 'stream-json') {
|
|
284
|
+
// A terminating event tells a streaming reader the run is over and carries
|
|
285
|
+
// the same envelope, so both formats end with a `result` record.
|
|
286
|
+
process.stdout.write(resultLine(result) + '\n');
|
|
287
|
+
} else {
|
|
288
|
+
process.stdout.write('\n');
|
|
289
|
+
}
|
|
290
|
+
return exitCodeFor(result, false);
|
|
221
291
|
}
|
|
222
292
|
|
|
223
293
|
// ---- TUI dispatch ----
|
|
@@ -286,17 +356,44 @@ export async function main(argv) {
|
|
|
286
356
|
await loadPlugins(cfg.pluginDir);
|
|
287
357
|
}
|
|
288
358
|
|
|
359
|
+
// Connect configured MCP servers and register their tools, so a headless
|
|
360
|
+
// `-p` run and the TUI both see them. Failures are reported but never fatal:
|
|
361
|
+
// an unreachable server must not stop the session from starting.
|
|
362
|
+
if (cfg.mcp !== false) {
|
|
363
|
+
try {
|
|
364
|
+
const mcp = await import('./mcp.js');
|
|
365
|
+
const { API } = await import('./plugin.js');
|
|
366
|
+
const mcpCfg = mcp.loadMcpConfig(cfg.workspace);
|
|
367
|
+
if (Object.keys(mcpCfg.servers).length) {
|
|
368
|
+
const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
|
|
369
|
+
mcp.setLiveConnections(res.servers);
|
|
370
|
+
if (process.env.HNCODE_MCP_VERBOSE === '1') {
|
|
371
|
+
for (const s of res.servers) {
|
|
372
|
+
console.error(s.ok ? `[hncode-mcp] ${s.name}: ${s.toolCount} tool(s)` : `[hncode-mcp] ${s.name}: ${s.error}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch (e) {
|
|
377
|
+
console.error(`hncode: MCP setup failed: ${e.message}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
289
381
|
// headless prompt
|
|
290
382
|
if (args.prompt) {
|
|
383
|
+
const format = args['output-format'];
|
|
384
|
+
if (!isValidFormat(format)) {
|
|
385
|
+
console.error(`hncode: unknown --output-format "${format}". Use one of: ${FORMATS.join(', ')}.`);
|
|
386
|
+
return EXIT.CONFIG;
|
|
387
|
+
}
|
|
291
388
|
let session = null;
|
|
292
389
|
if (args.session) session = typeof args.session === 'string' ? sess.loadSession(args.session) : sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
293
390
|
else if (args.continue) session = sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
294
|
-
return runPrompt({ cfg, prompt: args.prompt, format
|
|
391
|
+
return runPrompt({ cfg, prompt: args.prompt, format, session, modelArg: args.model });
|
|
295
392
|
}
|
|
296
393
|
|
|
297
394
|
if (!process.stdout.isTTY) {
|
|
298
395
|
console.error('hncode: interactive mode requires a TTY. Use `hncode -p "prompt"` for non-interactive use.');
|
|
299
|
-
return
|
|
396
|
+
return EXIT.FAILURE;
|
|
300
397
|
}
|
|
301
398
|
|
|
302
399
|
const opts = {
|
|
@@ -308,10 +405,13 @@ export async function main(argv) {
|
|
|
308
405
|
auto: !!args.auto,
|
|
309
406
|
plan: !!args.plan,
|
|
310
407
|
addDirs: args['add-dir'] || [],
|
|
408
|
+
// Remote control: expose a local socket so a script can queue a prompt or
|
|
409
|
+
// query status on the running session (see ci.js).
|
|
410
|
+
control: args.control === true ? '' : (typeof args.control === 'string' ? args.control : null),
|
|
311
411
|
session: loadOrCreateSession(args, cfg),
|
|
312
412
|
};
|
|
313
413
|
await startTui(opts);
|
|
314
|
-
return
|
|
414
|
+
return EXIT.OK;
|
|
315
415
|
}
|
|
316
416
|
|
|
317
417
|
// main() is invoked by bin/hncode (the CLI entry point), which imports this module
|
package/src/llm.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { llmTools } from './tools/index.js';
|
|
11
11
|
import { effortWire } from './config.js';
|
|
12
|
+
import { applyPromptCache } from './cache.js';
|
|
12
13
|
|
|
13
14
|
export function openAiToolDefs(tools) {
|
|
14
15
|
return tools.map((t) => ({
|
|
@@ -101,9 +102,20 @@ function buildBody(cfg, messages, streaming = true) {
|
|
|
101
102
|
const wire = effortWire(cfg, cfg.effort) || {};
|
|
102
103
|
if (cfg.protocol === 'anthropic') {
|
|
103
104
|
const { system, messages: am } = toAnthropic(messages);
|
|
104
|
-
|
|
105
|
+
const body = { model: cfg.innerModel, system, messages: am, tools: anthropicToolDefs(llmToolsList), ...common, ...wire };
|
|
106
|
+
// Prompt caching: mark the stable prefix (tools + system + conversation head)
|
|
107
|
+
// so the provider bills it as a cache READ instead of fresh input. The key is
|
|
108
|
+
// the SESSION id, so it stays identical across turns and never thrashes.
|
|
109
|
+
return JSON.stringify(applyPromptCache('anthropic', body, {
|
|
110
|
+
enabled: cfg.promptCache !== false,
|
|
111
|
+
sessionKey: cfg.sessionId,
|
|
112
|
+
}));
|
|
105
113
|
}
|
|
106
|
-
|
|
114
|
+
const body = { model: cfg.innerModel, messages: toOpenAi(messages), tools: openAiToolDefs(llmToolsList), ...common, ...wire };
|
|
115
|
+
return JSON.stringify(applyPromptCache('openai', body, {
|
|
116
|
+
enabled: cfg.promptCache !== false,
|
|
117
|
+
sessionKey: cfg.sessionId,
|
|
118
|
+
}));
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
// tool list used for defs (injected via setTools)
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
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
|
+
// Live connections made at startup, shared by every consumer of the MCP client
|
|
36
|
+
// (the CLI startup and the TUI's /mcp). Kept in this module rather than passed
|
|
37
|
+
// around so the status view can always see the real state.
|
|
38
|
+
let liveConnections = [];
|
|
39
|
+
export function setLiveConnections(conns) { liveConnections = conns || []; }
|
|
40
|
+
export function getLiveConnections() { return liveConnections; }
|
|
41
|
+
|
|
42
|
+
export function globalMcpFile() {
|
|
43
|
+
return process.env.HNCODE_MCP_FILE || path.join(os.homedir(), '.hncode', 'mcp.json');
|
|
44
|
+
}
|
|
45
|
+
export function projectMcpFile(workspace) {
|
|
46
|
+
return path.join(workspace || process.cwd(), '.hncode', 'mcp.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Merge global + project server definitions (project wins on a name clash).
|
|
50
|
+
// Returns { servers: {name: def}, errors: [string] }. Never throws.
|
|
51
|
+
export function loadMcpConfig(workspace) {
|
|
52
|
+
const out = { servers: {}, errors: [] };
|
|
53
|
+
const files = workspace ? [globalMcpFile(), projectMcpFile(workspace)] : [globalMcpFile()];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
const abs = path.resolve(f);
|
|
57
|
+
if (seen.has(abs)) continue;
|
|
58
|
+
seen.add(abs);
|
|
59
|
+
let text;
|
|
60
|
+
try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
61
|
+
let doc;
|
|
62
|
+
try { doc = JSON.parse(text); } catch (e) { out.errors.push(`${abs}: invalid JSON (${e.message})`); continue; }
|
|
63
|
+
const servers = (doc && doc.mcpServers) || {};
|
|
64
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
65
|
+
if (!def || typeof def !== 'object') { out.errors.push(`${abs}: server "${name}" must be an object`); continue; }
|
|
66
|
+
if (!def.command && !def.url) { out.errors.push(`${abs}: server "${name}" needs "command" or "url"`); continue; }
|
|
67
|
+
out.servers[name] = { ...def, _source: abs };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- JSON-RPC framing -------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
// A stdio connection: newline-delimited JSON-RPC over the child's stdin/stdout.
|
|
76
|
+
// MCP's stdio transport is line-delimited (NOT Content-Length framed — that is
|
|
77
|
+
// the LSP convention, and mixing them up is the usual way this breaks).
|
|
78
|
+
export function createStdioTransport(command, args, opts = {}) {
|
|
79
|
+
const child = cp.spawn(command, args || [], {
|
|
80
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
81
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
82
|
+
cwd: opts.cwd || process.cwd(),
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
// npm-installed shims are `.cmd` on Windows and need a shell to run.
|
|
85
|
+
shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(command),
|
|
86
|
+
});
|
|
87
|
+
const state = {
|
|
88
|
+
child,
|
|
89
|
+
buffer: '',
|
|
90
|
+
nextId: 1,
|
|
91
|
+
pending: new Map(), // id -> { resolve, reject, timer }
|
|
92
|
+
closed: false,
|
|
93
|
+
stderr: '',
|
|
94
|
+
handlers: [],
|
|
95
|
+
};
|
|
96
|
+
child.stdout.on('data', (d) => {
|
|
97
|
+
state.buffer += d.toString('utf8');
|
|
98
|
+
let i;
|
|
99
|
+
while ((i = state.buffer.indexOf('\n')) >= 0) {
|
|
100
|
+
const line = state.buffer.slice(0, i).trim();
|
|
101
|
+
state.buffer = state.buffer.slice(i + 1);
|
|
102
|
+
if (!line) continue;
|
|
103
|
+
let msg;
|
|
104
|
+
try { msg = JSON.parse(line); } catch { continue; } // ignore non-JSON noise
|
|
105
|
+
handleMessage(state, msg);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
child.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString('utf8')).slice(-4000); });
|
|
109
|
+
child.on('error', (e) => failAll(state, `server process error: ${e.message}`));
|
|
110
|
+
child.on('close', (code) => { state.closed = true; failAll(state, `server exited (code ${code})`); });
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handleMessage(state, msg) {
|
|
115
|
+
if (msg.id != null && state.pending.has(msg.id)) {
|
|
116
|
+
const p = state.pending.get(msg.id);
|
|
117
|
+
state.pending.delete(msg.id);
|
|
118
|
+
clearTimeout(p.timer);
|
|
119
|
+
if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
120
|
+
else p.resolve(msg.result);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// A server-initiated REQUEST (e.g. sampling). Answer politely so the server
|
|
124
|
+
// does not hang; hncode does not implement sampling, so it is "not supported".
|
|
125
|
+
if (msg.method && msg.id != null) {
|
|
126
|
+
try {
|
|
127
|
+
writeMessage(state, { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `method not supported: ${msg.method}` } });
|
|
128
|
+
} catch { /* transport closed */ }
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const h of state.handlers) { try { h(msg); } catch { /* a notification handler must not throw */ } }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function failAll(state, reason) {
|
|
135
|
+
for (const [, p] of state.pending) { clearTimeout(p.timer); p.reject(new Error(reason)); }
|
|
136
|
+
state.pending.clear();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function writeMessage(state, msg) {
|
|
140
|
+
if (state.closed || !state.child.stdin || state.child.stdin.destroyed) throw new Error('transport closed');
|
|
141
|
+
state.child.stdin.write(JSON.stringify(msg) + '\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Send a request and await its reply. `timeoutMs` guards a wedged server.
|
|
145
|
+
export function request(state, method, params, timeoutMs = 30_000) {
|
|
146
|
+
const id = state.nextId++;
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
state.pending.delete(id);
|
|
150
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
151
|
+
}, timeoutMs);
|
|
152
|
+
state.pending.set(id, { resolve, reject, timer });
|
|
153
|
+
try { writeMessage(state, { jsonrpc: '2.0', id, method, params: params || {} }); }
|
|
154
|
+
catch (e) { state.pending.delete(id); clearTimeout(timer); reject(e); }
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function notify(state, method, params) {
|
|
159
|
+
try { writeMessage(state, { jsonrpc: '2.0', method, params: params || {} }); } catch { /* best-effort */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- HTTP transport ---------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
// Streamable-HTTP transport: one POST per JSON-RPC message. The server may reply
|
|
165
|
+
// with JSON or an SSE stream; both are parsed here.
|
|
166
|
+
export function createHttpTransport(url, headers) {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'http',
|
|
169
|
+
url,
|
|
170
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(headers || {}) },
|
|
171
|
+
sessionId: null,
|
|
172
|
+
nextId: 1,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function httpRequest(state, method, params, timeoutMs = 30_000) {
|
|
177
|
+
const id = state.nextId++;
|
|
178
|
+
const ctrl = new AbortController();
|
|
179
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
180
|
+
try {
|
|
181
|
+
const res = await fetch(state.url, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: state.sessionId ? { ...state.headers, 'mcp-session-id': state.sessionId } : state.headers,
|
|
184
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }),
|
|
185
|
+
signal: ctrl.signal,
|
|
186
|
+
});
|
|
187
|
+
const sid = res.headers.get('mcp-session-id');
|
|
188
|
+
if (sid) state.sessionId = sid;
|
|
189
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
190
|
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
191
|
+
if (ct.includes('text/event-stream')) {
|
|
192
|
+
const msg = parseSseForId(await res.text(), id);
|
|
193
|
+
if (!msg) throw new Error('no response message in SSE stream');
|
|
194
|
+
if (msg.error) throw new Error(msg.error.message || JSON.stringify(msg.error));
|
|
195
|
+
return msg.result;
|
|
196
|
+
}
|
|
197
|
+
const json = await res.json();
|
|
198
|
+
if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
|
|
199
|
+
return json.result;
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Pull the JSON-RPC reply for `id` out of an SSE body.
|
|
206
|
+
export function parseSseForId(body, id) {
|
|
207
|
+
for (const line of String(body || '').split('\n')) {
|
|
208
|
+
const l = line.trim();
|
|
209
|
+
if (!l.startsWith('data:')) continue;
|
|
210
|
+
const payload = l.slice(5).trim();
|
|
211
|
+
if (!payload || payload === '[DONE]') continue;
|
|
212
|
+
try {
|
|
213
|
+
const msg = JSON.parse(payload);
|
|
214
|
+
if (msg && msg.id === id) return msg;
|
|
215
|
+
} catch { /* not JSON: skip */ }
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- connection + tool discovery -------------------------------------------
|
|
221
|
+
|
|
222
|
+
// Connect to one server, handshake, and list its tools.
|
|
223
|
+
// Returns { ok, tools, error, serverInfo, transport, isHttp } — never throws.
|
|
224
|
+
export async function connectServer(name, def, opts = {}) {
|
|
225
|
+
const timeoutMs = opts.timeoutMs || 30_000;
|
|
226
|
+
let transport;
|
|
227
|
+
const isHttp = !!def.url;
|
|
228
|
+
try {
|
|
229
|
+
transport = isHttp
|
|
230
|
+
? createHttpTransport(def.url, def.headers)
|
|
231
|
+
: createStdioTransport(def.command, def.args, { env: def.env, cwd: def.cwd });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return { ok: false, tools: [], error: `could not start "${name}": ${e.message}` };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const send = (method, params) => (isHttp
|
|
237
|
+
? httpRequest(transport, method, params, timeoutMs)
|
|
238
|
+
: request(transport, method, params, timeoutMs));
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const init = await send('initialize', {
|
|
242
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
243
|
+
capabilities: { tools: {} },
|
|
244
|
+
clientInfo: CLIENT_INFO,
|
|
245
|
+
});
|
|
246
|
+
if (!isHttp) notify(transport, 'notifications/initialized', {});
|
|
247
|
+
const list = await send('tools/list', {});
|
|
248
|
+
const tools = Array.isArray(list && list.tools) ? list.tools : [];
|
|
249
|
+
return { ok: true, tools, error: null, serverInfo: (init && init.serverInfo) || {}, transport, isHttp };
|
|
250
|
+
} catch (e) {
|
|
251
|
+
try { if (!isHttp) transport.child.kill(); } catch { /* already gone */ }
|
|
252
|
+
return { ok: false, tools: [], error: `"${name}": ${e.message}` };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Call one tool on a connected server. Returns { ok, text, error, raw }.
|
|
257
|
+
export async function callTool(transport, isHttp, toolName, args, timeoutMs = 60_000) {
|
|
258
|
+
try {
|
|
259
|
+
const result = isHttp
|
|
260
|
+
? await httpRequest(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs)
|
|
261
|
+
: await request(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs);
|
|
262
|
+
return { ok: true, text: flattenContent(result), error: null, raw: result };
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return { ok: false, text: '', error: e.message, raw: null };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// MCP tool results carry a `content` array of typed blocks. Flatten to text for
|
|
269
|
+
// the model; non-text blocks are described rather than dropped silently.
|
|
270
|
+
export function flattenContent(result) {
|
|
271
|
+
if (!result) return '';
|
|
272
|
+
if (typeof result === 'string') return result;
|
|
273
|
+
const blocks = Array.isArray(result.content) ? result.content : [];
|
|
274
|
+
const parts = [];
|
|
275
|
+
for (const b of blocks) {
|
|
276
|
+
if (!b || typeof b !== 'object') continue;
|
|
277
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
278
|
+
else if (b.type === 'resource' && b.resource) parts.push(`[resource ${b.resource.uri || ''}]`);
|
|
279
|
+
else if (b.type === 'image') parts.push(`[image ${b.mimeType || ''}]`);
|
|
280
|
+
else parts.push(`[${b.type || 'unknown'} block]`);
|
|
281
|
+
}
|
|
282
|
+
if (!parts.length && typeof result.text === 'string') parts.push(result.text);
|
|
283
|
+
return parts.join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A tool name safe for every provider's function-name rules and for matching
|
|
287
|
+
// back to its server + original name. Providers want [A-Za-z0-9_-], so
|
|
288
|
+
// "server.tool" becomes "mcp__server__tool".
|
|
289
|
+
export function mcpToolName(server, tool) {
|
|
290
|
+
const clean = (s) => String(s || '').replace(/[^A-Za-z0-9_-]/g, '_');
|
|
291
|
+
return `mcp__${clean(server)}__${clean(tool)}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Convert an MCP tool definition into an hncode tool spec whose execute() calls
|
|
295
|
+
// back into the server.
|
|
296
|
+
export function toHncodeSpec(server, tool, transport, isHttp) {
|
|
297
|
+
return {
|
|
298
|
+
name: mcpToolName(server, tool.name),
|
|
299
|
+
description: (tool.description || `MCP tool ${tool.name} from ${server}.`).slice(0, 4000),
|
|
300
|
+
parameters: tool.inputSchema && typeof tool.inputSchema === 'object'
|
|
301
|
+
? tool.inputSchema
|
|
302
|
+
: { type: 'object', properties: {} },
|
|
303
|
+
_mcp: { server, tool: tool.name },
|
|
304
|
+
async execute(args) {
|
|
305
|
+
const r = await callTool(transport, isHttp, tool.name, args);
|
|
306
|
+
if (!r.ok) return `Error calling ${server}/${tool.name}: ${r.error}`;
|
|
307
|
+
// MCP signals tool-level failure with isError, not a JSON-RPC error.
|
|
308
|
+
if (r.raw && r.raw.isError) return `Error from ${server}/${tool.name}: ${r.text}`;
|
|
309
|
+
return r.text || '(no output)';
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Connect every configured server and register its tools through the plugin API.
|
|
315
|
+
// Returns { servers: [...], toolCount }.
|
|
316
|
+
export async function connectAll(config, registerTool, opts = {}) {
|
|
317
|
+
const servers = [];
|
|
318
|
+
let toolCount = 0;
|
|
319
|
+
const only = opts.only;
|
|
320
|
+
for (const [name, def] of Object.entries((config && config.servers) || {})) {
|
|
321
|
+
if (only && !only.includes(name)) continue;
|
|
322
|
+
const conn = await connectServer(name, def, opts);
|
|
323
|
+
if (!conn.ok) {
|
|
324
|
+
servers.push({ name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
let registered = 0;
|
|
328
|
+
let err = null;
|
|
329
|
+
for (const tool of conn.tools) {
|
|
330
|
+
if (!tool || !tool.name) continue;
|
|
331
|
+
try {
|
|
332
|
+
registerTool(toHncodeSpec(name, tool, conn.transport, conn.isHttp));
|
|
333
|
+
registered++;
|
|
334
|
+
} catch (e) {
|
|
335
|
+
// A name clash with a built-in is reported, not fatal: keep the rest.
|
|
336
|
+
err = err || `tool "${tool.name}": ${e.message}`;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
toolCount += registered;
|
|
340
|
+
servers.push({ name, ok: true, toolCount: registered, error: err, serverInfo: conn.serverInfo, transport: conn.transport, isHttp: conn.isHttp });
|
|
341
|
+
}
|
|
342
|
+
return { servers, toolCount };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Disconnect every connected server (kills stdio children). HTTP needs nothing.
|
|
346
|
+
export function disconnectAll(connections) {
|
|
347
|
+
for (const c of connections || []) {
|
|
348
|
+
if (c && c.transport && c.transport.child) {
|
|
349
|
+
try { c.transport.child.kill(); } catch { /* already gone */ }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Human-readable status for /mcp.
|
|
355
|
+
export function describeServers(config, connections) {
|
|
356
|
+
const names = Object.keys((config && config.servers) || {});
|
|
357
|
+
if (!names.length) {
|
|
358
|
+
return ['No MCP servers configured.', '', `Config file: ${globalMcpFile()}`,
|
|
359
|
+
'Add one with /mcp-config add <name> <command|url> [args...]'];
|
|
360
|
+
}
|
|
361
|
+
const byName = new Map((connections || []).map((c) => [c.name, c]));
|
|
362
|
+
const lines = [`MCP servers (${names.length}) — config: ${globalMcpFile()}`, ''];
|
|
363
|
+
for (const n of names) {
|
|
364
|
+
const def = config.servers[n];
|
|
365
|
+
const conn = byName.get(n);
|
|
366
|
+
const target = def.url ? def.url : `${def.command || ''} ${(def.args || []).join(' ')}`.trim();
|
|
367
|
+
let status;
|
|
368
|
+
if (!conn) status = 'not connected';
|
|
369
|
+
else if (!conn.ok) status = `FAILED: ${conn.error}`;
|
|
370
|
+
else status = `connected — ${conn.toolCount} tool(s)${conn.serverInfo && conn.serverInfo.name ? ` (${conn.serverInfo.name})` : ''}`;
|
|
371
|
+
lines.push(` ${n}: ${status}`);
|
|
372
|
+
lines.push(` ${target}`);
|
|
373
|
+
if (conn && conn.error) lines.push(` ! ${conn.error}`);
|
|
374
|
+
}
|
|
375
|
+
return lines;
|
|
376
|
+
}
|